RPG(上):地图、移动与对话
做一小块塞尔达风格的俯视角地图:用 Input.get_vector 做八方向移动并弄清斜向归一化,用 TileSet 和三层 TileMapLayer 搭地图,用 Y 排序让角色走到树后面,再用 Camera2D、Area2D 交互检测和自定义 Resource 做一套数据驱动的对话系统,最后用 autoload 保存全局状态。
这一章和下一章围绕同一个示例:一个俯视角动作 RPG 的小地图。村子里有村长和村民,北边是森林,南边是池塘和草地,草地上有史莱姆,森林里有蝙蝠。村长会给你一个任务:打倒 3 只史莱姆。
本章讲"世界"这一半:角色怎么走、地图怎么搭、谁挡住谁、镜头怎么跟、怎么和 NPC 说话。下一章讲"战斗"那一半:状态机、敌人 AI、攻击判定、背包和存档。先跑起来看看:
操作:WASD 或方向键移动,走到村长身边按 E 交谈,对话中按 E 或空格继续,←→ 选择选项。J 或空格挥剑,I 打开背包。开局你就站在村长旁边。
俯视角移动
CharacterBody2D 的 Floating 模式
第 6 章的平台跳跃用的是 CharacterBody2D 的默认模式 Grounded:它有"地面""墙""天花板"的概念,is_on_floor() 根据 up_direction 判断哪边是地。俯视角游戏里没有重力,也没有哪个方向是"地面",四面八方的碰撞都应该一视同仁,这时要把 motion_mode 改成 Floating(检查器里的 Motion Mode,场景文件里存成 1):
[node name="Player" type="CharacterBody2D"]
collision_layer = 4
collision_mask = 3
motion_mode = 1
script = ExtResource("1")Floating 模式下 move_and_slide() 撞墙时总是沿墙面滑动,不会因为"坡度"减速,也不会产生"站在地上"的状态。这正是俯视角想要的手感:贴着房子墙边斜着走,角色会顺着墙滑过去。
Input.get_vector 和斜向归一化
俯视角要同时读两个轴。最直接的写法是分别读横轴和纵轴,拼成一个向量:
Vector2(Input.get_axis("move_left", "move_right"), Input.get_axis("move_up", "move_down"))
问题出在斜向:同时按 D 和 S,得到的是 (1, 1)。它的长度是 √(1² + 1²) = √2 ≈ 1.414。乘以速度之后,斜着走比直着走快 41%。玩家很快就会发现"斜着跑更快",然后一直斜着跑。
解决方法是归一化:把向量除以自己的长度,变成长度为 1 的方向向量。(1, 1) 归一化后是 (1/√2, 1/√2) ≈ (0.707, 0.707)。Godot 提供了一步到位的 Input.get_vector():它读四个动作,拼成向量,并且把长度限制在 1 以内(注意是"限制"而不是"归一化":手柄摇杆推一半时长度是 0.5,会保留下来,这样摇杆仍然能控制走路快慢)。
示例里两种写法都保留了,用参数 normalize_diagonal 切换,方便对比:
func get_input_vector() -> Vector2:
if normalize_diagonal:
# get_vector 把结果的长度限制在 1 以内:同时按右和下得到 (0.707, 0.707)
return Input.get_vector("move_left", "move_right", "move_up", "move_down")
# 两个轴各取各的:斜向时是 (1, 1),长度 √2 ≈ 1.414,跑得比直走快
return Vector2(Input.get_axis("move_left", "move_right"), Input.get_axis("move_up", "move_down"))加速度:move_toward
速度从 0 瞬间跳到 90,角色会显得很"硬"。和第 6 章一样,这里也给移动加上加速度:每一帧让当前速度向目标速度靠近一点,每秒最多变化 acceleration。Vector2.move_toward(to, delta) 正好做这件事:沿直线朝 to 走 delta 那么远,不会冲过头。
func move(delta: float, input: Vector2) -> void:
if input != Vector2.ZERO:
# 朝目标速度逼近,每秒最多改变 acceleration:起步有个很短的加速过程
velocity = velocity.move_toward(input * speed, acceleration * delta)
facing = _to_four_way(input)
else:
velocity = velocity.move_toward(Vector2.ZERO, friction * delta)
move_and_slide()从静止加速到最高速度需要的时间是 t = speed / acceleration。默认 90 / 900 = 0.1 秒,几乎感觉不到,但能消掉"硬"的感觉;松手后的刹车时间是 90 / 1100 ≈ 0.08 秒。俯视角动作游戏一般都希望操作跟手,这两个时间通常压在 0.15 秒以内;做冰面关卡时才会故意把它们拉长。
速度方向可以是任意角度,但精灵图只有上、下、侧面三组(向左走时把侧面图水平翻转)。_to_four_way() 把移动方向归到四个朝向之一:横向分量大就朝左右,纵向分量大就朝上下;正好 45° 斜走时保持当前朝向,避免每帧来回翻。
用手柄时,摇杆推到斜上方一半的位置,读到原始值约 (0.35, -0.35)。Input.get_vector() 返回什么?
- (0.707, -0.707),因为它会归一化
- (1, -1),因为超过死区就算按满
- 约 (0.35, -0.35),长度 0.5 小于 1,原样保留
- (0, 0),因为斜向不在死区之外
get_vector() 只在长度超过 1 时才把它缩到 1,长度小于 1 的值保持不变(死区以内的会被清零)。所以摇杆半推还是能"慢慢走",而键盘斜向的 (1, 1) 会被缩到 0.707。
TileSet 和多层 TileMapLayer
地图用瓦片拼:把一张图集切成 16×16 的小格子,每个格子是一种瓦片(草、路、水、树……),然后在网格上"刷"出地图。Godot 里这件事由两样东西配合完成:
- TileSet(资源):描述"有哪些瓦片"。包括瓦片尺寸、图集来源(TileSetAtlasSource)、物理层,以及每个瓦片自己的碰撞多边形、绘制偏移、Y 排序原点等。保存成 .tres,可以被多个地图共用。
- TileMapLayer(节点):一张网格,每个格子记录"放的是哪个瓦片"。一个场景里放几个 TileMapLayer,就是几层地图。
TileMap 节点从 4.3 起已经废弃。以前是一个 TileMap 节点里有多个"层",现在每一层是一个独立的 TileMapLayer 节点,层的属性(z_index、Y 排序、是否启用碰撞)直接就是节点属性。旧工程打开后,选中 TileMap,底部 TileMap 面板工具栏的菜单里有"把 TileMap 图层提取为单独的 TileMapLayer 节点"的选项,可以一键转换。新代码里不要再写 tile_map.set_cell(layer, coords, ...),改为对对应的 TileMapLayer 调用 set_cell(coords, source_id, atlas_coords)。瓦片集是怎么建出来的
在编辑器里的流程是:新建 TileMapLayer → 检查器里 Tile Set 选"新建 TileSet" → 底部 TileSet 面板里把图集 PNG 拖进去 → 在 TileSet 的检查器里添加物理层 → 切到"绘制属性"给每个需要挡路的瓦片画碰撞多边形。
示例的美术是 Python 程序生成的(dev/make_art.py),瓦片有六七十个,手工一个个画碰撞太慢,所以写了一个开发用脚本 dev/bake.gd 用代码完成同样的步骤。它生成的 world/tileset.tres 和编辑器里手工做出来的完全一样,打开工程后在 TileSet 面板里能看到每个瓦片的碰撞形状。用代码建 TileSet 的好处是每一步都写得明明白白:
func _make_tileset() -> TileSet:
var ts := TileSet.new()
ts.tile_size = Vector2i(T, T)
ts.add_physics_layer()
ts.set_physics_layer_collision_layer(PHYS_WORLD, 1) # 第 1 层 world
ts.set_physics_layer_collision_mask(PHYS_WORLD, 0)
ts.add_physics_layer()
ts.set_physics_layer_collision_layer(PHYS_WATER, 2) # 第 2 层 water(蝙蝠不检测这一层)
ts.set_physics_layer_collision_mask(PHYS_WATER, 0)
var src := TileSetAtlasSource.new()
src.texture = load("res://art/tiles.png")
src.texture_region_size = Vector2i(T, T)
ts.add_source(src, SRC)
for c in GRASS + [FLOWER_RED, FLOWER_YELLOW, TUFT, PEBBLES, BRIDGE]:
src.create_tile(c)
for m in 16:
src.create_tile(_path(m))
src.create_tile(_water(m))
_solid(src, _water(m), PHYS_WATER, _rect(-8, -8, 8, 8))
src.create_tile(_fence(m))
var fence: Array[PackedVector2Array] = [_rect(-3, -3, 3, 5)]
if m & 8: fence.append(_rect(-8, -1, -3, 4))
if m & 2: fence.append(_rect(3, -1, 8, 4))
if m & 1: fence.append(_rect(-1, -8, 1, -3))
if m & 4: fence.append(_rect(-1, 5, 1, 8))
for poly in fence:
_solid(src, _fence(m), PHYS_WORLD, poly)
src.get_tile_data(_fence(m), 0).y_sort_origin = 4
_obstacle(src, ROCK, Vector2i.ONE, Vector2i.ZERO, _rect(-5, -1, 5, 5), 4)
_obstacle(src, BUSH, Vector2i.ONE, Vector2i.ZERO, _rect(-7, -3, 7, 5), 4)
_obstacle(src, STUMP, Vector2i.ONE, Vector2i.ZERO, _rect(-5, -1, 4, 4), 3)
_obstacle(src, SIGN, Vector2i.ONE, Vector2i.ZERO, _rect(-6, -3, 6, 3), 5)
# 16×32 的树:图集里占 1×2 格,texture_origin 往上抬 8 像素,让树根落在格子里
_obstacle(src, TREE, Vector2i(1, 2), Vector2i(0, 8), _rect(-3, 0, 3, 6), 5)
_obstacle(src, PINE, Vector2i(1, 2), Vector2i(0, 8), _rect(-3, 0, 3, 6), 5)
# 64×64 的房子:锚点是底边从左数第 2 格
_obstacle(src, HOUSE, Vector2i(4, 4), Vector2i(-8, 24), _rect(-20, -22, 35, 6), 6)
return ts
func _solid(src: TileSetAtlasSource, coords: Vector2i, layer: int, poly: PackedVector2Array) -> void:
var data := src.get_tile_data(coords, 0)
var i := data.get_collision_polygons_count(layer)
data.add_collision_polygon(layer)
data.set_collision_polygon_points(layer, i, poly)
func _obstacle(src: TileSetAtlasSource, coords: Vector2i, size: Vector2i, origin: Vector2i,
poly: PackedVector2Array, y_sort: int) -> void:
src.create_tile(coords, size)
var data := src.get_tile_data(coords, 0)
data.texture_origin = origin # 图片相对格子中心的偏移
data.y_sort_origin = y_sort # 排序时用的"脚底"位置(相对格子中心)
_solid(src, coords, PHYS_WORLD, poly)几个要点:
- 物理层:TileSet 的物理层 0 放进碰撞第 1 层 world(树、栅栏、房子),物理层 1 放进第 2 层 water。分开是为了让蝙蝠能飞过水面、却撞不穿树,下一章讲碰撞层时会用到。
- 碰撞多边形的坐标以格子中心为原点。16×16 的格子范围是 (-8, -8) 到 (8, 8)。树的碰撞只画在树根附近 (-3, 0)–(3, 6),这样角色的头可以和树冠重叠,看起来像走到了树后面。
- 比一格大的瓦片(16×32 的树、64×64 的房子)用
create_tile(coords, size)在图集里占多格。它默认以格子中心为中心绘制,所以用texture_origin把图往上抬,让树根落在格子里。
三层地图
| 节点 | 内容 | 关键设置 | 碰撞 |
|---|---|---|---|
| Ground | 草地、小路、水、木桥 | z_index = -2 | 只有水 |
| Decor | 花、草丛、小石子 | z_index = -1 | 无 |
| Obstacles | 树、栅栏、石头、灌木、房子 | y_sort_enabled = true | 有 |
地图的布局写在一个文本文件 dev/map.txt 里,每个字符代表一格(T 树、= 路、~ 水、S 史莱姆……)。bake 脚本逐格读取,往三层里 set_cell(),再把角色、宝箱的场景实例摆到对应位置,最后用 PackedScene.pack() 存成 world/world.tscn:
func _make_world(ts: TileSet) -> void:
var rows := FileAccess.get_file_as_string("res://dev/map.txt").strip_edges().split("\n")
var world := Node2D.new()
world.name = "World"
world.y_sort_enabled = true
world.set_script(load("res://world/world.gd"))
var ground := _layer(world, "Ground", ts)
ground.z_index = -2 # 地面永远在最下面
var decor := _layer(world, "Decor", ts)
decor.z_index = -1 # 花草装饰压在地面上、角色下
var obstacles := _layer(world, "Obstacles", ts)
obstacles.y_sort_enabled = true # 树、栅栏、房子和角色一起按 y 排序
var h := rows.size()
var w := rows[0].length()
var at := func(x: int, y: int) -> String:
if x < 0 or y < 0 or x >= w or y >= h:
return ""
return rows[y][x]
var counts := {}
for y in h:
for x in w:
var c := Vector2i(x, y)
var ch: String = rows[y][x]
var r := absi(hash(c)) % 100
match ch:
"=":
ground.set_cell(c, SRC, _path(_mask(at, x, y, "=#", true)))
"~":
ground.set_cell(c, SRC, _water(_mask(at, x, y, "~#", true)))
"#":
ground.set_cell(c, SRC, BRIDGE)
_:
ground.set_cell(c, SRC, GRASS[0 if r < 45 else (1 if r < 80 else (2 if r < 93 else 3))])
match ch:
"T":
obstacles.set_cell(c, SRC, PINE if (x >= 20 and y <= 10) == (r < 80) else TREE)
"f":
obstacles.set_cell(c, SRC, _fence(_mask(at, x, y, "f", false)))
"r":
obstacles.set_cell(c, SRC, ROCK)
"b":
obstacles.set_cell(c, SRC, BUSH)
"t":
obstacles.set_cell(c, SRC, STUMP)
"s":
obstacles.set_cell(c, SRC, SIGN)
"H":
obstacles.set_cell(c, SRC, HOUSE)
"*":
decor.set_cell(c, SRC, FLOWER_RED if r % 2 == 0 else FLOWER_YELLOW)
"'":
decor.set_cell(c, SRC, TUFT)
".":
if r < 4:
decor.set_cell(c, SRC, PEBBLES)
elif r < 9:
decor.set_cell(c, SRC, TUFT)
"P", "C", "V", "S", "B", "X":
_spawn(world, ch, ground.map_to_local(c) + Vector2(0, 6), counts)
var ps := PackedScene.new()
ps.pack(world)
ResourceSaver.save(ps, "res://world/world.tscn")
world.free()
## 4 位掩码:上 1、右 2、下 4、左 8,邻居属于 kinds 时置位。出界当作同类。
func _mask(at: Callable, x: int, y: int, kinds: String, edge_same: bool) -> int:
var m := 0
for d: Array in [[0, -1, 1], [1, 0, 2], [0, 1, 4], [-1, 0, 8]]:
var n: String = at.call(x + d[0], y + d[1])
if (n == "" and edge_same) or (n != "" and kinds.contains(n)):
m |= d[2]
return mset_cell(coords, source_id, atlas_coords) 的三个参数分别是:网格坐标、TileSet 里的图集编号(这里只有一个图集,编号 0)、瓦片在图集里的坐标。map_to_local(coords) 把网格坐标换成像素坐标(格子中心),local_to_map(pos) 反过来。
自动选边:4 位掩码
小路和池塘的边缘需要过渡:路的左边是草地,就得用"左边有草边"的那张瓦片。示例用的是最简单的办法:看上、右、下、左四个邻居是不是同类,每个方向占一个二进制位,拼出 0–15 的数字,正好对应图集里预先画好的 16 种瓦片。
set_cells_terrain_connect() 画,引擎自动挑瓦片,还能处理对角。原理和上面的掩码相同,只是位数更多(算上四个角是 8 位)。瓦片种类多、需要在编辑器里手绘地图时,优先用 Terrain。Y 排序:让角色走到树后面
2D 里谁画在上面,默认由节点在场景树里的顺序决定:排在后面的画在上面。平台跳跃游戏这样就够了,但俯视角游戏里,"前后"关系是随着位置变化的:角色站在树的下方(屏幕上更靠下),应该挡住树;走到树的上方,应该被树挡住。
规则很简单:谁的脚更靠下(y 更大),谁就离镜头更近,画在上面。打开父节点的 y_sort_enabled,它的子节点就会每帧按全局 y 坐标排序,y 小的先画。
要让这条规则成立,有三件事要做对:
- 原点放在脚底。排序用的是节点原点的 y。如果精灵居中(原点在身体中间),角色的"排序点"就比脚高半个身子,会提前被树挡住。示例里所有角色的
AnimatedSprite2D都设了offset = (0, -8),图往上画,原点就在脚底。 - 瓦片要参与排序。树是瓦片,不是节点。Obstacles 这个 TileMapLayer 也要打开
y_sort_enabled,它的每个格子才会被当成单独的物体,和兄弟节点(玩家、敌人)一起排序。否则整层被当成一个物体,按 TileMapLayer 自己的 y(0)排序。 - 瓦片的排序点默认是格子中心,用 TileData 的
y_sort_origin调。树根在格子中心往下 5 像素,所以树的y_sort_origin = 5。
地面和装饰层不需要排序,它们永远在最下面。但它们也是 World(开了 Y 排序)的子节点,如果不处理,会按自己的 y 和角色混在一起排。示例用 z_index 把它们压下去:z_index 先决定"在哪一层",同一个 z_index 之内才按 y 排序。
- 中间隔了一个普通 Node2D 容器(比如 "Enemies"),容器没开
y_sort_enabled,里面的敌人就被当成一个整体排序。每一级容器都要开。 - 给角色设了
z_index = 1想让它"在上面",结果它永远盖住树:z_index 优先于 Y 排序。 - Godot 3 的
YSort节点在 4.x 已经没有了,用任意 Node2D 加y_sort_enabled = true代替。
碰撞形状放在哪
俯视角的"伪 3D"透视下,角色的身体碰撞只应该覆盖脚底那一小块。示例里玩家的身体碰撞是 10×6 的矩形,放在脚边 (0, -3);而用来挨打的 Hurtbox 是 10×14,覆盖整个身体。这样走到树后面时,头和树冠可以重叠,只有脚碰到树根才会被挡住;而敌人打到头也算打中。下一章会详细讲 Hitbox 和 Hurtbox。
Camera2D
镜头是一个单独的 Camera2D 节点,不放在玩家下面,由脚本每帧把位置设成玩家的位置。这样做方便切换两种模式:
[node name="Camera" type="Camera2D" parent="."]
zoom = Vector2(3, 3)
process_callback = 0
limit_left = 0
limit_top = 0
limit_right = 640
limit_bottom = 352
script = ExtResource("3")zoom = (3, 3):美术是 16 像素一格,放大 3 倍后,960×540 的窗口正好显示 20×11 格。limit_*:镜头不会照到地图外面。走到地图角落时,角色会偏离屏幕中心。process_callback = 0(Physics):镜头和角色都在物理帧更新,避免高刷新率屏幕上角色抖动。- 像素风还要在项目设置里把
rendering/textures/canvas_textures/default_texture_filter设成 Nearest,否则放大后的像素是糊的。
func _physics_process(_delta: float) -> void:
if target == null:
return
if not room_mode:
global_position = target.global_position
return
var room := Vector2i((target.global_position / ROOM_SIZE).floor())
if room == _room:
return
var first := _room == NO_ROOM
_room = room
var center := (Vector2(room) + Vector2(0.5, 0.5)) * ROOM_SIZE
if _tween:
_tween.kill()
if first:
global_position = center
else:
_tween = create_tween().set_process_mode(Tween.TWEEN_PROCESS_PHYSICS)
_tween.tween_property(self, "global_position", center, 0.45).set_trans(Tween.TRANS_SINE)平滑跟随用的是 Camera2D 自带的 position_smoothing:节点位置瞬间跟上玩家,但实际画面中心每帧只追上一部分距离,速度由 position_smoothing_speed 控制。按房间切换则把地图分成 20×11 格的房间,玩家跨过边界时用 Tween 把镜头滑到新房间中心。读档、复活这种瞬移之后要调 reset_smoothing(),不然镜头会从老位置慢慢飘过来。
交互检测:Area2D 加交互键
"走到 NPC 旁边按 E 说话"可以拆成两部分:知道身边有谁,以及按键时找谁。
玩家身上挂一个 InteractArea(Area2D,半径 12 的圆),它的遮罩只勾第 7 层 interact;NPC 和宝箱身上各挂一个 InteractZone,放在第 7 层。每个物理帧,玩家从重叠的区域里挑最近的一个,记成 interact_target;按下交互键时调用它的 interact():
func _update_interact_target() -> void:
var best: Node2D = null
var best_d := INF
for area in interact_area.get_overlapping_areas():
var target := area.get_parent() as Node2D
if target and target.has_method("interact") and target.can_interact():
var d := global_position.distance_squared_to(target.global_position)
if d < best_d:
best = target
best_d = d
interact_target = best
func try_interact() -> void:
if interact_target:
interact_target.interact(self)这里没有要求 NPC 和宝箱继承同一个基类,而是用 has_method("interact") 判断"它会不会被交互"。这种写法叫鸭子类型(duck typing),在 GDScript 里很常见:任何有 interact()、can_interact()、interact_label() 三个方法的节点,挂上一个第 7 层的 Area2D,就能被玩家交互,路牌、门、开关都可以照做。
屏幕上的"E 交谈"提示是 HUD(CanvasLayer)里的一个 Label,它要跟着世界里的 NPC 走。世界坐标换成屏幕坐标只要一步:get_viewport().get_canvas_transform() * 世界坐标。提示放在 CanvasLayer 里而不是世界里,文字不会被镜头放大 3 倍而变糊。
用 Resource 做数据驱动的对话
对话最省事的写法是在 NPC 脚本里写一串 if 和字符串。一旦有十几个 NPC、几十段对话,再加上"任务进行到哪一步说哪句话",代码会迅速变成一团。更好的办法是把对话变成数据:脚本只管"怎么播","播什么"放在资源文件里,在检查器里就能编辑。
自定义 Resource 类
任何继承 Resource 并写了 class_name 的脚本,都可以在检查器里"新建",可以保存成 .tres 文件,可以被 @export 引用。示例定义了两个:一句台词 DialogueLine,一段对话 Dialogue:
class_name DialogueLine
extends Resource
@export var speaker := ""
@export_multiline var text := "" ## 可以写 {left} 这类占位符,见 Game.format_text()
@export var choices: PackedStringArray = [] ## 非空时,这句播完后让玩家选
@export var choice_flags: PackedStringArray = [] ## 选第 i 项时设置的全局标记(可以留空)class_name Dialogue
extends Resource
@export var require_flag := "" ## 只有这个标记为真时才会被选中(空 = 无条件)
@export var lines: Array[DialogueLine] = []
@export var flag_on_finish := "" ## 播完后设置的标记
@export var reward_coins := 0
@export var reward_max_hp := 0 ## 增加的血量上限(半颗心为 1)@export var lines: Array[DialogueLine] = [] 是类型化数组。写上元素类型之后,检查器里每个元素的下拉菜单会直接出现"新建 DialogueLine",而不是让你从所有 Resource 类型里找。存成 .tres 是这个样子,纯文本,可以用 Git 对比,也可以让写剧情的人直接改:
[gd_resource type="Resource" script_class="Dialogue" load_steps=6 format=3]
[ext_resource type="Script" path="res://dialogue/dialogue.gd" id="1"]
[ext_resource type="Script" path="res://dialogue/dialogue_line.gd" id="2"]
[sub_resource type="Resource" id="line0"]
script = ExtResource("2")
speaker = "村长"
text = "哦,是你啊,年轻人。"
[sub_resource type="Resource" id="line1"]
script = ExtResource("2")
speaker = "村长"
text = "最近南边草地上冒出好多史莱姆,把庄稼都啃坏了。"
[sub_resource type="Resource" id="line2"]
script = ExtResource("2")
speaker = "村长"
text = "你能帮我打倒 {target} 只史莱姆吗?"
choices = PackedStringArray("没问题,交给我", "我再想想")
choice_flags = PackedStringArray("quest_active", "")
[resource]
script = ExtResource("1")
lines = Array[ExtResource("2")]([SubResource("line0"), SubResource("line1"), SubResource("line2")])NPC 的脚本只剩下"挑一段来播"。它导出一个对话数组,从上往下找第一个条件满足的。村长的数组顺序是:任务完成后 → 领奖 → 任务进行中 → 初次见面,越"靠后"的剧情放越前面:
func pick_dialogue() -> Dialogue:
for d in dialogues:
if d.require_flag == "" or Game.has_flag(d.require_flag):
return d
return nullext_resource ... path="res://dialogue/dialogue.gd")。在 Godot 编辑器的文件系统面板里移动或重命名脚本,引擎会自动更新引用;但在编辑器外(资源管理器、命令行)移动文件,引用就断了,资源会加载失败。另外 class_name 是全局的,不要和引擎内置类或别的脚本重名。对话框:逐字显示
对话框用 RichTextLabel 显示台词。它有一个属性 visible_characters:只显示前 N 个字符,-1 表示全部显示。打字机效果就是每帧把 N 加一点:
func _show_line() -> void:
var line := _dialogue.lines[_index]
speaker.text = line.speaker
text.text = Game.format_text(line.text)
text.visible_characters = 0 # 先一个字都不显示
_shown = 0.0
choices.hide()
_choice = 0func _process(delta: float) -> void:
if not panel.visible:
return
var total := text.get_total_character_count()
if text.visible_characters < total:
_shown += chars_per_sec * delta
text.visible_characters = mini(int(_shown), total)
elif not _current_line().choices.is_empty() and not choices.visible:
_build_choices()
arrow.visible = text.visible_characters >= total and not choices.visible
arrow.modulate.a = 1.0 if Time.get_ticks_msec() % 800 < 500 else 0.3为什么要用一个浮点数 _shown 累加,而不是直接 visible_characters += 1?因为每帧加一个字的话,打字速度取决于帧率:60 Hz 屏幕上每秒 60 个字,144 Hz 屏幕上 144 个。按"字/秒 × delta"累加,速度就和帧率无关。同样的道理在移动、计时里处处都要注意。
get_total_character_count() 返回去掉 BBCode 标签以后的字符数,所以台词里写 [color=red]史莱姆[/color] 也不会把标签一个个"打"出来。
按键处理有一个细节:对话框和玩家都监听"交互"键。对话最后一句按 E 关掉对话框的同时,游戏恢复运行,如果玩家也收到了这次按键,就会立刻又和村长说上话。解决办法是对话框处理完按键后调用 set_input_as_handled(),事件不再往下传:
func _unhandled_input(event: InputEvent) -> void:
if not panel.visible:
return
if event.is_action_pressed("interact") or event.is_action_pressed("attack"):
get_viewport().set_input_as_handled() # 吞掉这次按键,玩家那边收不到
if text.visible_characters < text.get_total_character_count():
text.visible_characters = text.get_total_character_count() # 没打完:先一下子显示全
_shown = text.visible_characters
elif choices.visible:
_pick(_choice)
else:
_next()
elif choices.visible and (event.is_action_pressed("move_left") or event.is_action_pressed("move_right")):
get_viewport().set_input_as_handled()
_choice = wrapi(_choice + (1 if event.is_action_pressed("move_right") else -1), 0, choices.get_child_count())
_highlight()对话期间用 get_tree().paused = true 暂停游戏,敌人不会趁机偷袭。对话框节点的 process_mode 设成 Always,暂停时它照常运行。播完以后,Dialogue 里声明的效果(设标记、给金币、加血量上限)由对话框统一执行,NPC 脚本完全不用关心剧情:
func _pick(i: int) -> void:
var flags := _current_line().choice_flags
if i < flags.size():
Game.set_flag(flags[i])
_next()
func _next() -> void:
_index += 1
if _index < _dialogue.lines.size():
_show_line()
return
# 对话结束:把这段对话声明的效果应用到全局状态
Game.set_flag(_dialogue.flag_on_finish)
if _dialogue.reward_coins:
Game.add_coins(_dialogue.reward_coins)
if _dialogue.reward_max_hp:
Game.max_hp += _dialogue.reward_max_hp
Game.change_hp(Game.max_hp)
panel.hide()
get_tree().paused = false村长的 dialogues 数组如果把"初次见面"(无条件)放在第一个,会发生什么?
- 没有影响,引擎会自动挑最合适的
- 不管任务进行到哪一步,村长永远只说初次见面那段
- 报错,因为无条件的对话必须放最后
pick_dialogue() 返回第一个满足条件的对话。无条件的那段永远满足,放在第一个就会挡住后面所有的对话。所以条件越苛刻的越要往前放。
autoload:全局状态
血量、金币、背包、任务标记,这些数据很多地方都要用:HUD 要显示,对话要检查,宝箱要往里放东西,存档要读写。而且换场景、重新加载场景时它们不能丢。把它们放进一个 autoload(自动加载)单例最合适:项目设置 → Globals → Autoload 里添加一个脚本,起个名字,它就会在游戏启动时创建,挂在场景树的根节点下,任何脚本都能直接用这个名字访问。
[autoload]
Tuning="*res://tuning.gd"
Game="*res://autoload/game.gd"var max_hp := 6 ## 半颗心为 1,6 = 三颗心
var hp := 6
var coins := 0
var inventory := Inventory.new()
var flags: Dictionary = {} ## 任务标记,如 {"quest_active": true}
var slimes_killed := 0
var opened_chests: Array[String] = []状态变化时,Game 不直接去改 HUD,而是发信号。HUD 连接 hp_changed 自己刷新心形,任务追踪栏连接 flags_changed。这样 Game 不需要知道界面长什么样,界面也可以随时换掉:
signal hp_changed(hp: int, max_hp: int)
signal coins_changed(coins: int)
signal flags_changed
signal toast(text: String) ## HUD 底部弹出一行提示
signal dialogue_requested(dialogue: Dialogue) ## NPC 请求播放对话,由对话框接收
signal loaded ## 读档完成,场景里的宝箱等据此刷新func _on_hp_changed(hp: int, max_hp: int) -> void:
for c in _hearts.get_children():
c.queue_free()
for i in range(0, max_hp, 2): # 每颗心代表 2 点血
var frame := 0 if hp >= i + 2 else (1 if hp == i + 1 else 2) # 满 / 半 / 空
_hearts.add_child(_icon(HEARTS, Rect2(frame * 16, 0, 16, 16), 40))- 它不会随场景重新加载而重置。
get_tree().reload_current_scene()只重建当前场景,Game 里的血量、任务标记都还在。示例的「重新开始」按钮先调Game.reset()再重载场景。 - 不要在 autoload 里长期保存场景里节点的引用(比如
var player: Player)。换场景后那个节点被释放,引用就变成了"已释放的对象",访问时报错。要保存就保存数据(位置、血量),或者用is_instance_valid()检查。
玩家在对话中把血量改成了 2,然后点了「重新开始」,但示例的 restart 只调用了 reload_current_scene()、没有调 Game.reset()。重新开始后血量是多少?
- 还是 2,因为 Game 是 autoload,不属于当前场景
- 恢复成 6,因为整个游戏重启了
- 变成 0,因为节点被释放了
autoload 节点挂在根节点下,和当前场景是兄弟关系。重载场景只会释放并重建当前场景,autoload 保持不变。
小结
- 俯视角角色用
CharacterBody2D的 Floating 模式;方向用Input.get_vector(),它把斜向长度限制到 1;速度用move_toward做加速和减速。 - TileSet 是"有哪些瓦片"的资源,TileMapLayer 是一层地图。地面、装饰、障碍分三层,地面和装饰用 z_index 压在下面,障碍层和角色一起 Y 排序。
- Y 排序的前提是原点在脚底:角色的精灵往上偏移,瓦片用
y_sort_origin。 - 交互用一对 Area2D(玩家探测、物体被探测)加鸭子类型的
interact()。 - 对话写成自定义 Resource,NPC 按条件挑选,对话框用
visible_characters做打字效果。 - 全局状态放 autoload,用信号通知界面,重载场景时记得手动重置。
下一章接着做战斗:敌人的状态机 AI、Hitbox/Hurtbox、碰撞层规划、击退和无敌帧、背包和存档。