GameTech/Godot 4.7 互动教程
目录示例游戏
第三部分 · 2D 实战 · 第 9 章

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):

player/player.tscn第 28–32 行玩家根节点:Floating 模式,碰撞层和遮罩下一章细讲在工程中查看 ↗
[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 切换,方便对比:

player/player.gd第 56–61 行Player.get_input_vector()在工程中查看 ↗
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"))
在上面的示例里,先按住 D 直走,看右侧"实时数据"里的速度是 90;再同时按 DS 斜走,速度仍然是 90。然后关掉「斜向归一化」,再斜着走:速度变成 127(= 90 × 1.414)。

加速度:move_toward

速度从 0 瞬间跳到 90,角色会显得很"硬"。和第 6 章一样,这里也给移动加上加速度:每一帧让当前速度向目标速度靠近一点,每秒最多变化 acceleration。Vector2.move_toward(to, delta) 正好做这件事:沿直线朝 to 走 delta 那么远,不会冲过头。

player/player.gd第 66–73 行Player.move():有输入就加速,没输入就减速在工程中查看 ↗
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 秒以内;做冰面关卡时才会故意把它们拉长。

把「加速度」和「摩擦减速」都拖到 100:起步要 0.9 秒,松手后还会滑一段,像走在冰面上。拖到 4000 则几乎是瞬间起停。

速度方向可以是任意角度,但精灵图只有上、下、侧面三组(向左走时把侧面图水平翻转)。_to_four_way() 把移动方向归到四个朝向之一:横向分量大就朝左右,纵向分量大就朝上下;正好 45° 斜走时保持当前朝向,避免每帧来回翻。

用手柄时,摇杆推到斜上方一半的位置,读到原始值约 (0.35, -0.35)。Input.get_vector() 返回什么?

  1. (0.707, -0.707),因为它会归一化
  2. (1, -1),因为超过死区就算按满
  3. 约 (0.35, -0.35),长度 0.5 小于 1,原样保留
  4. (0, 0),因为斜向不在死区之外

get_vector() 只在长度超过 1 时才把它缩到 1,长度小于 1 的值保持不变(死区以内的会被清零)。所以摇杆半推还是能"慢慢走",而键盘斜向的 (1, 1) 会被缩到 0.707。

TileSet 和多层 TileMapLayer

地图用瓦片拼:把一张图集切成 16×16 的小格子,每个格子是一种瓦片(草、路、水、树……),然后在网格上"刷"出地图。Godot 里这件事由两样东西配合完成:

  • TileSet(资源):描述"有哪些瓦片"。包括瓦片尺寸、图集来源(TileSetAtlasSource)、物理层,以及每个瓦片自己的碰撞多边形、绘制偏移、Y 排序原点等。保存成 .tres,可以被多个地图共用。
  • TileMapLayer(节点):一张网格,每个格子记录"放的是哪个瓦片"。一个场景里放几个 TileMapLayer,就是几层地图。
从 Godot 3 或 4.2 以前迁移过来的注意: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 的好处是每一步都写得明明白白:

dev/bake.gd第 59–114 行dev/bake.gd:建 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:

dev/bake.gd第 158–231 行dev/bake.gd:按字符画往三层里刷瓦片在工程中查看 ↗
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 m

set_cell(coords, source_id, atlas_coords) 的三个参数分别是:网格坐标、TileSet 里的图集编号(这里只有一个图集,编号 0)、瓦片在图集里的坐标。map_to_local(coords) 把网格坐标换成像素坐标(格子中心),local_to_map(pos) 反过来。

自动选边:4 位掩码

小路和池塘的边缘需要过渡:路的左边是草地,就得用"左边有草边"的那张瓦片。示例用的是最简单的办法:看上、右、下、左四个邻居是不是同类,每个方向占一个二进制位,拼出 0–15 的数字,正好对应图集里预先画好的 16 种瓦片。

Godot 自带更完整的方案叫 Terrain(地形):在 TileSet 里给每个瓦片标出"哪条边、哪个角属于哪种地形",然后用地形刷子或 set_cells_terrain_connect() 画,引擎自动挑瓦片,还能处理对角。原理和上面的掩码相同,只是位数更多(算上四个角是 8 位)。瓦片种类多、需要在编辑器里手绘地图时,优先用 Terrain。
打开示例的「显示碰撞盒」:灰色方块是树、栅栏、房子的碰撞,青色是水。注意树的碰撞只有树根那一小块;房子的碰撞只覆盖墙,屋顶不挡路。

Y 排序:让角色走到树后面

2D 里谁画在上面,默认由节点在场景树里的顺序决定:排在后面的画在上面。平台跳跃游戏这样就够了,但俯视角游戏里,"前后"关系是随着位置变化的:角色站在树的下方(屏幕上更靠下),应该挡住树;走到树的上方,应该被树挡住。

规则很简单:谁的脚更靠下(y 更大),谁就离镜头更近,画在上面。打开父节点的 y_sort_enabled,它的子节点就会每帧按全局 y 坐标排序,y 小的先画。

要让这条规则成立,有三件事要做对:

  1. 原点放在脚底。排序用的是节点原点的 y。如果精灵居中(原点在身体中间),角色的"排序点"就比脚高半个身子,会提前被树挡住。示例里所有角色的 AnimatedSprite2D 都设了 offset = (0, -8),图往上画,原点就在脚底。
  2. 瓦片要参与排序。树是瓦片,不是节点。Obstacles 这个 TileMapLayer 也要打开 y_sort_enabled,它的每个格子才会被当成单独的物体,和兄弟节点(玩家、敌人)一起排序。否则整层被当成一个物体,按 TileMapLayer 自己的 y(0)排序。
  3. 瓦片的排序点默认是格子中心,用 TileData 的 y_sort_origin 调。树根在格子中心往下 5 像素,所以树的 y_sort_origin = 5。

地面和装饰层不需要排序,它们永远在最下面。但它们也是 World(开了 Y 排序)的子节点,如果不处理,会按自己的 y 和角色混在一起排。示例用 z_index 把它们压下去:z_index 先决定"在哪一层",同一个 z_index 之内才按 y 排序。

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 节点,不放在玩家下面,由脚本每帧把位置设成玩家的位置。这样做方便切换两种模式:

main.tscn第 16–23 行main.tscn:放大 3 倍,限制在地图范围内在工程中查看 ↗
[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,否则放大后的像素是糊的。
world/camera_rig.gd第 30–48 行camera_rig.gd:平滑跟随,或者塞尔达式按房间切换在工程中查看 ↗
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(),不然镜头会从老位置慢慢飘过来。

打开「按房间切换镜头」,往南走出村子:镜头停在村子里不动,跨过那排树之后整屏滑到下一个房间。把「镜头平滑」拖到 0 再关掉房间模式,镜头会死死贴着玩家。

交互检测:Area2D 加交互键

"走到 NPC 旁边按 E 说话"可以拆成两部分:知道身边有谁,以及按键时找谁。

玩家身上挂一个 InteractArea(Area2D,半径 12 的圆),它的遮罩只勾第 7 层 interact;NPC 和宝箱身上各挂一个 InteractZone,放在第 7 层。每个物理帧,玩家从重叠的区域里挑最近的一个,记成 interact_target;按下交互键时调用它的 interact():

player/player.gd第 151–166 行Player:找最近的可交互对象在工程中查看 ↗
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:

dialogue/dialogue_line.gd第 3–9 行dialogue/dialogue_line.gd在工程中查看 ↗
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 项时设置的全局标记(可以留空)
dialogue/dialogue.gd第 3–10 行dialogue/dialogue.gd:台词数组 + 触发条件 + 播完后的效果在工程中查看 ↗
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 对比,也可以让写剧情的人直接改:

dialogue/data/chief_intro.tres第 1–25 行村长的第一段对话,最后一句带两个选项在工程中查看 ↗
[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 的脚本只剩下"挑一段来播"。它导出一个对话数组,从上往下找第一个条件满足的。村长的数组顺序是:任务完成后 → 领奖 → 任务进行中 → 初次见面,越"靠后"的剧情放越前面:

npc/npc.gd第 24–28 行npc/npc.gd:按条件挑对话在工程中查看 ↗
func pick_dialogue() -> Dialogue:
	for d in dialogues:
		if d.require_flag == "" or Game.has_flag(d.require_flag):
			return d
	return null
自定义 Resource 的坑:.tres 里是按路径引用脚本的(ext_resource ... path="res://dialogue/dialogue.gd")。在 Godot 编辑器的文件系统面板里移动或重命名脚本,引擎会自动更新引用;但在编辑器外(资源管理器、命令行)移动文件,引用就断了,资源会加载失败。另外 class_name 是全局的,不要和引擎内置类或别的脚本重名。

对话框:逐字显示

对话框用 RichTextLabel 显示台词。它有一个属性 visible_characters:只显示前 N 个字符,-1 表示全部显示。打字机效果就是每帧把 N 加一点:

ui/dialogue_box.gd第 37–44 行换到新的一句:先一个字都不显示在工程中查看 ↗
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 = 0
ui/dialogue_box.gd第 49–59 行每帧累加:_shown 是带小数的字数在工程中查看 ↗
func _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(),事件不再往下传:

ui/dialogue_box.gd第 64–79 行没打完先显示全,打完了再翻页或选择在工程中查看 ↗
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 脚本完全不用关心剧情:

ui/dialogue_box.gd第 108–128 行选项设标记;整段播完后应用奖励在工程中查看 ↗
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
走到村长旁边按 E。把「打字速度」拖到 5,字会一个一个蹦出来;这时按 E 会先把整句显示完,再按一次才翻页。拖到 120 基本就是整句一起出来。

村长的 dialogues 数组如果把"初次见面"(无条件)放在第一个,会发生什么?

  1. 没有影响,引擎会自动挑最合适的
  2. 不管任务进行到哪一步,村长永远只说初次见面那段
  3. 报错,因为无条件的对话必须放最后

pick_dialogue() 返回第一个满足条件的对话。无条件的那段永远满足,放在第一个就会挡住后面所有的对话。所以条件越苛刻的越要往前放。

autoload:全局状态

血量、金币、背包、任务标记,这些数据很多地方都要用:HUD 要显示,对话要检查,宝箱要往里放东西,存档要读写。而且换场景、重新加载场景时它们不能丢。把它们放进一个 autoload(自动加载)单例最合适:项目设置 → Globals → Autoload 里添加一个脚本,起个名字,它就会在游戏启动时创建,挂在场景树的根节点下,任何脚本都能直接用这个名字访问。

project.godot第 12–15 行project.godot:两个 autoload,Tuning 是教程的参数桥,Game 是游戏的全局状态在工程中查看 ↗
[autoload]

Tuning="*res://tuning.gd"
Game="*res://autoload/game.gd"
autoload/game.gd第 26–32 行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 不需要知道界面长什么样,界面也可以随时换掉:

autoload/game.gd第 7–12 行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                                  ## 读档完成,场景里的宝箱等据此刷新
ui/hud.gd第 68–73 行HUD 根据血量画心形:每颗心 2 点血,满 / 半 / 空三种图在工程中查看 ↗
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))
autoload 的两个坑:
  • 它不会随场景重新加载而重置。get_tree().reload_current_scene() 只重建当前场景,Game 里的血量、任务标记都还在。示例的「重新开始」按钮先调 Game.reset() 再重载场景。
  • 不要在 autoload 里长期保存场景里节点的引用(比如 var player: Player)。换场景后那个节点被释放,引用就变成了"已释放的对象",访问时报错。要保存就保存数据(位置、血量),或者用 is_instance_valid() 检查。

玩家在对话中把血量改成了 2,然后点了「重新开始」,但示例的 restart 只调用了 reload_current_scene()、没有调 Game.reset()。重新开始后血量是多少?

  1. 还是 2,因为 Game 是 autoload,不属于当前场景
  2. 恢复成 6,因为整个游戏重启了
  3. 变成 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、碰撞层规划、击退和无敌帧、背包和存档。