做2D游戏做到第五期,最容易遇上的状态就是:玩家已经能跑能跳了,但游戏还不像游戏。前四期我们一路把角色动画、基础TileMap关卡、金币收集勉强拼了出来,可一旦想打通从"进入关卡"到"死亡重来"再到"结算展示"的完整流程,就发现处处欠着债。本期系列一第5篇,我打算集中把这笔债还掉:把场景结构理清楚、把游戏状态接起来、把敌人从木桩改成会巡逻会攻击的活物、再把UI和音效补上。这期结束之后,你手里的Godot 2D工程才算真正有了一个"可玩闭环"的雏形。
如果你是从前几期一直跟下来的,那这期会很舒服,因为每一步都是在前面的工程上做加法。如果你是半路看到这篇,只要手头有玩家控制、基础平台关卡的2D工程,也可以直接照着改。
1. 进度盘点:前四期留下了一个"能跑不能玩"的Demo
1.1 当前场景树长什么样
按照前几期的搭法,你的主场景大概长这样:
Main(Node2D) ├── Player(CharacterBody2D) │ ├── CollisionShape2D │ └── AnimatedSprite2D ├── LevelRoot(Node2D) │ └── TileMapLayer(或旧版TileMap) │ ├── 地面/平台 │ ├── 金币(Area2D实例) │ └── 终点门 └── Camera2D玩家脚本里塞了移动、跳跃、重力处理,金币用Area2D检测到玩家后调用queue_free()销毁并加一分。看起来没有问题,但只要你稍微往后想一步,就会发现三件大事完全没着落:
- 角色死亡后没有重生机制;
- 金币分数没有全局保存;
- 从一关到另一关的切换逻辑是空的。
这些问题如果不先解决,后面加敌人、加机关、加Boss都只是往一个地基都没打稳的房子上添砖。
1.2 排查清单:距离可玩还有哪些缺口
我习惯在每个阶段结束时列一份"可玩性缺口清单",比盲目堆功能要高效得多。第5期开动前,我给自己列的清单是这样的:
| 模块 | 当前状态 | 本期目标 |
|---|---|---|
| 场景切换 | 完全没写 | 支持关卡重载与下一关跳转 |
| 死亡逻辑 | 玩家掉出地图就结束 | 扣生命、重生、游戏结束 |
| 分数与生命 | 存在某个临时变量里 | 放进全局单例 |
| 敌人 | 没有 | 巡逻+追击+攻击 |
| UI | 没有 | 分数、生命、关卡提示 |
| 音效 | 没有 | 背景音乐与关键反馈音效 |
这是很典型的"第5期该做的事":不搞新花样,先把核心循环打通。这条循环一旦通了,后面再加任何玩法都是往框架里填东西。
2. 场景切换与全局数据:用Autoload把状态接住
2.1 GameState单例的数据设计
Godot里跨场景保存数据最直接的办法就是Autoload单例。在Project Settings -> Autoload里注册一个脚本,比如叫GameState,生命周期从游戏启动一直持续到退出,天然就是全局数据中心。
我通常会在里面放这些内容:
extends Node signal score_changed(new_score) signal lives_changed(new_lives) var score: int = 0 var lives: int = 3 var current_level: int = 1 func add_score(amount: int) -> void: score += amount score_changed.emit(score) func lose_life() -> void: lives -= 1 lives_changed.emit(lives) func reset_game() -> void: score = 0 lives = 3 current_level = 1为什么要把分数和生命放在这里而不是放在关卡场景里?因为场景在切换时会释放,那个瞬间除了Autoload和场景树本身,其他东西全都保不住。前四期你可能侥幸没遇到过切场景掉数据的坑,但只要开始做多关卡,这个坑迟早踩。与其到时候手忙脚乱,不如现在就把状态抽出去。
2.2 重生、场景切换与退出流程
状态放好了,接下来是"怎么用"。玩家死亡的处理可以统一放在Player脚本里,但决策交给GameState:
func _on_player_died() -> void: GameState.lose_life() if GameState.lives <= 0: get_tree().change_scene_to_file("res://scenes/game_over.tscn") else: get_tree().reload_current_scene()这里关键点有两个:
一是reload_current_scene()。它不仅重新加载关卡,还会把关卡里所有子节点重置回来,金币、敌人位置、机关状态都会回到初始状态。这就是为什么我们不用手动去"清空场上敌人"——只要场景所有可变化状态都能从头开始重建,重生就是免费的。
二是切换场景建议用change_scene_to_file还是change_scene_to_packed。前者每次加载需要重新从磁盘读取,资源大了以后有明显卡顿;后者把PackedScene预先加载好,切换时基本是瞬时的。我的习惯是一旦项目资源多起来,立刻改用后者:
const GAME_OVER_SCENE: PackedScene = preload("res://scenes/game_over.tscn") func go_game_over() -> void: get_tree().change_scene_to_packed(GAME_OVER_SCENE)顺带说一个新手容易忽略的点:如果玩家是从平台上掉下去触发了死亡,一定要先把玩家的碰撞处理关闭或者做一个死亡动画,否则切场景前的一两帧里可能触发第二次死亡信号。我踩过这个坑,反应是死亡画面刚弹出来就闪退回关卡,非常奇怪。最简单的规避办法是在死亡函数开头加一个set_deferred("process_mode", Node.PROCESS_MODE_DISABLED),让玩家在切换场景前暂停一切处理。
3. 敌人从"木桩"到"巡逻者"的改造
3.1 往返巡逻的几种实现与选型
敌人是很多2D游戏教程里最容易糊弄过去的部分,但恰恰是这一步决定玩家觉得"游戏有内容"还是"游戏很空"。最简单的敌人实现方式是"站在一个点上不动",这只能算障碍物,不能算敌人。我们要做的是让它动起来。
巡逻逻辑本质上就是"在两点之间来回走"。Godot里有两种主流做法:
- 用Path2D/PathFollow2D:适合敌人走固定路线、多节点折线路径的场景;
- 手写左右边界判断:适合平台跳跃游戏,敌人沿水平方向在一个区间内往返。
平台跳跃关卡里手写边界更直接。原因是玩家与平台交互时,敌人的巡逻区间经常受地形影响,Path2D的路径要一个个点去摆,手写边界只需要两个数字:
extends CharacterBody2D @export var patrol_left: float = 100.0 @export var patrol_right: float = 300.0 @export var speed: float = 60.0 var direction: int = -1 func _physics_process(delta: float) -> void: if global_position.x <= patrol_left: direction = 1 elif global_position.x >= patrol_right: direction = -1 velocity.x = direction * speed move_and_slide()这里需要提醒一点:patrol_left和patrol_right尽量不要用绝对坐标硬编码,我更喜欢在场景里摆好两个Marker2D子节点来标记边界,然后取它们的global_position.x。后期调整敌人巡逻范围不用改数字,直接拖Marker2D就行。
3.2 追击与攻击判定:Area2D触发器的正确用法
光会巡逻的敌人还是木桩,得让它在玩家靠近时切换行为。这里要用到Area2D而不是直接在敌人身上改碰撞范围。
最合理的节点结构是这样的:
Enemy(CharacterBody2D) ├── CollisionShape2D(身体碰撞) ├── AnimatedSprite2D ├── DetectRange(Area2D) │ └── CollisionShape2D(检测范围) └── AttackRange(Area2D) └── CollisionShape2D(攻击判定)DetectRange负责告诉敌人"玩家来了",AttackRange负责触发伤害。两个Area2D各管一摊,比在一个Area2D里既检测接近又检测攻击要清爽得多。
追击代码很好写:
func _on_detect_range_body_entered(body: Node2D) -> void: if body is Player: is_chasing = true func _on_detect_range_body_exited(body: Node2D) -> void: if body is Player: is_chasing = false当is_chasing为true时,敌人的水平移动从"巡逻区间"切换成"朝玩家方向移动",边界也不再限制它。追击时记得保留物理层里敌人的碰撞,否则它会直接穿过平台。
攻击判定要配套一套碰撞层规范。Godot 4的碰撞层在项目设置里配置,我的习惯是:
| 层 | 对象 | 说明 |
|---|---|---|
| 1 | 玩家 | 玩家身体 |
| 2 | 环境 | 地面、平台、墙体 |
| 3 | 敌人 | 敌人身体与检测触发 |
| 4 | 玩家攻击 | 玩家挥剑/射击的判定 |
| 5 | 敌人攻击 | 敌人碰撞玩家的伤害判定 |
AttackRange的collision_mask只需要勾选第1层玩家,玩家方面则要确保collision_layer=1。然后写:
func _on_attack_range_body_entered(body: Node2D) -> void: if body is Player and not _has_attacked: _has_attacked = true body.take_damage(1) _attack_timer.start()注意_has_attacked这个标记非常重要。Area2D在玩家持续站在范围内时会不断触发body_entered,如果不加冷却,玩家的血量会在一瞬间被扣光。用计时器做攻击间隔是常规解法,我习惯给敌人一个attack_cooldown,默认1秒。
4. TileMap的图层规划与机关配置
4.1 图层编号与渲染遮挡
前四期的TileMap可能只有一个图层,所有东西堆一起。到了第5期,机关、平台、装饰逐渐多起来,图层规划立刻变成刚需。
Godot 4里推荐使用多个TileMapLayer节点,而不是在旧版TileMap里管理多个图层。每个TileMapLayer独立渲染、独立设置物理属性,改起来不会牵一发动全身。我的规划通常四层:
LevelRoot(Node2D) ├── GroundLayer(TileMapLayer) # YSort基准层,承载物理碰撞 ├── PropsLayer(TileMapLayer) # 装饰,无碰撞 ├── CollisionLayer(TileMapLayer) # 单向平台、跳台等特殊物理 └── FrontLayer(TileMapLayer) # 前景遮挡,渲染在最前面这四层的顺序不是随便排的。底层贡献物理碰撞,装饰层负责让场景不单调,特殊物理层放"只能从下面跳上来"的single direction平台,前景层则承担"玩家从树后走过时被树冠遮挡"的纵深效果。
牵涉到渲染遮挡时,记得把需要排序的节点放在同一个YSort容器下:如果地面层和玩家都不属于YSort,玩家走过去时可能被错误地画在装饰物上面,画面层次出问题。很多新人在这卡住,表现是"人都走到树后面了,树却在人后面",其实就是忘了继承YSort。
4.2 移动平台与一次性机关的连接方式
移动平台在2D平台跳跃里几乎是必需品。最常见实现是AnimatableBody2D,配上脚本控制位置:
extends AnimatableBody2D @export var move_distance: Vector2 = Vector2(200, 0) @export var move_duration: float = 2.0 var _start_pos: Vector2 var _t: float = 0.0 func _ready() -> void: _start_pos = global_position func _physics_process(delta: float) -> void: _t += delta var weight = (sin(_t / move_duration * PI * 2) + 1.0) / 2.0 global_position = _start_pos + move_distance * weight这段代码里最值得留意的是AnimatableBody2D这个名字。如果用的是普通的StaticBody2D,它没有任何"把玩家带起来"的能力,玩家站在上面就会原地往下掉。用AnimatableBody2D后,引擎会自动处理与玩家的相对速度,玩家站在上面就跟坐电梯似的自然移动。
一次性机关的连接逻辑也很典型:开关放Area2D,目标是门或者移动平台。关键在用Export导出节点引用,而不是硬编码NodePath:
extends Area2D @export var target_door: AnimatableBody2D # 或 NodePath func _ready() -> void: body_entered.connect(_on_body_entered) func _on_body_entered(body: Node2D) -> void: if body is Player: target_door.open()这里有个小经验:从场景面板把目标节点拖到Export变量上,比在代码里写get_node("../Door")要安全得多。一旦场景结构调整,字符串路径会断,Export引用的容错率更高。
5. UI接入与音效:别让游戏在"看得见反馈"上露怯
5.1 CanvasLayer里的HUD组织
很多Godot新手做UI,第一反应是把Label直接挂在主场景的Node2D下。这样做的结果是UI会跟随摄像机运动——主场景的Node2D节点如果被Camera2D移动影响,UI就飘走了。正确做法一定是用CanvasLayer。
我惯用的HUD场景结构:
HUD(CanvasLayer) ├── MarginContainer │ └── VBoxContainer │ ├── HBoxContainer │ │ ├── ScoreLabel(Label) │ │ └── LivesLabel(Label) │ └── LevelLabel(Label) └── CenterContainer └── MessageLabel(Label,用于弹出"Level Start"提示)5.2 分数、生命值、游戏结束的联动
是时候把第2章里GameState的signal用起来了。HUD脚本只需要连接信号,然后更新Label:
extends CanvasLayer @onready var score_label: Label = %ScoreLabel @onready var lives_label: Label = %LivesLabel func _ready() -> void: GameState.score_changed.connect(_on_score_changed) GameState.lives_changed.connect(_on_lives_changed) _on_score_changed(GameState.score) _on_lives_changed(GameState.lives) func _on_score_changed(new_score: int) -> void: score_label.text = "分数:%d" % new_score func _on_lives_changed(new_lives: int) -> void: lives_label.text = "生命:%d" % new_lives这里有一个我之前常常忽略、后来才想明白的点:连接到signal之后,记得在_ready里手动刷新一次UI,否则场景加载后、玩家捡到第一枚金币前,屏幕上的分数会停留在初始值不对。很多教程没提这个,但实际交接工程时,这个问题会让第一次运行的人以为UI坏了。
游戏结束画面不用花哨,一个简单的Control场景,包含"你的得分"、"重新开始"按钮就行。按钮的处理:
func _on_restart_button_pressed() -> void: GameState.reset_game() get_tree().change_scene_to_file("res://scenes/level_1.tscn")重置不掉的话,玩家下次开始游戏会带着上一局剩的命和分继续,那属于硬伤。
5.3 音效接入与资源加载注意事项
音效接入这事儿说难不难,但细节特别多。
先在场景里放几个AudioStreamPlayer节点,我通常放两个:一个BGMPlayer用来循环播放背景音乐,一个SFXPlayer用来播放金币、跳跃、受伤这些短音效。注意BGM的音频导入设置里,一定要在Import面板勾上"Loop"循环模式。不然一段音乐播完就戛然而止,很尴尬。
短音效的play写法有讲究:
sfx_player.stream = coin_sfx sfx_player.play()如果连续捡金币,这样写每次都会因为重新设置stream而打断上一次播放。做连击收集时,我会用AudioStreamPlaybackPolyphonic或者干脆放一组SFXPlayerPool,按顺序轮换播放。对第5期而言,简单点可以用:
func play_sfx(stream: AudioStream, pitch_scale: float = 1.0) -> void: sfx_player.pitch_scale = randf_range(0.9, 1.1) sfx_player.stream = stream sfx_player.play()给每次播放加个微小的随机音调变化,能让连续触发的声音听着不那么机械。这个技巧很多音效细节里都会用到,但教程很少说,属于那种"你注意到了才觉得高级"的小处理。
最后提一个很多人都会踩的坑:切场景时音效突然断了。原因是AudioStreamPlayer挂在关卡场景里,场景一切就被释放。解法很简单——把BGMPlayer和全局音效播放器都放在Autoload单例里,或者放在单独的AudioManager节点里做成Autoload。这样音乐才能跨场景连续播放,不会每进一关就断一次。
顺带聊下资源加载,音效文件格式别直接用MP3。Godot对OGG和WAV的支持更稳,尤其环形循环的BGM,OGG质量好体积小。短音效用WAV加载速度更快。一个2D游戏如果素材库里全是MP3,导入阶段就可能出现解码延迟,影响播放响应。
接着上面的思路,现在把敌人、UI、音效都挂进了现有的项目里,再回头看第1章列的那张缺口清单,会发现每一项都已经落地了。剩下的事情不是再加功能,而是打磨手感:跳跃参数、攻击判定范围、敌人追踪速度、UI字体大小……这些数值的调整才是让游戏从"能玩"变成"好玩"的关键。我个人的习惯是每次改完一个数值,立刻实际跑一遍关卡,别一次改一堆参数,否则手感坏了都不知道是哪个参数出了问题。
第6期开始,我们可以放心地把视野放大去做更复杂的玩法了,比如Boss战、多关卡之间的进度保存,或者商店系统。这些内容有一个共同前提:基础循环够稳。这期做完的工程,就是后面的地基。