
1. 这一篇要解决的问题从能跑起来到像游戏如果你一路跟着系列走到现在前面几篇大概率已经把 Godot 的节点体系、场景结构、基础 GDScript 都摸了个大概。你可能已经拼出过一个小方块在屏幕上左右移动的 Demo甚至做过一个简单的 Platformer 关卡。但到了这个节点很多人会突然卡住东西都跑得起来为什么玩起来还是不像游戏没打击感、没挑战、没有一个完整的玩法循环更像是一个功能演示。这就是系列一第五篇想带你跨过的坎。从这一篇开始我们不再把注意力放在某个功能能不能实现上而是放在一个完整的 2D 动作游戏局部闭环怎么搭上。我会用一个非常典型的场景作为主线玩家角色进入一个区域遇到一个会追踪你的敌人击败它然后解锁前进的道路。听起来简单但里面涉及的信号管理、角色状态机、TileMap 关卡结构、敌人 AI、伤害反馈这些玩意几乎是所有 2D 游戏的基础骨架。你把这套东西理顺了后面做平台跳跃、俯视角射击、甚至轻量 Roguelike 都会轻松很多。读者不需要有很强的编程背景但最好已经完成过系列前面的内容至少知道 Godot 的场景树长什么样、脚本怎么挂、基础输入映射怎么做。如果你是个完全没碰过 Godot 的新手这篇读起来会有一些吃力建议先回去补前面的基础。2. 整体设计思路不要把每个功能当成孤岛2.1 先想清楚玩法闭环再动手我见过很多刚开始学 Godot 的人包括我自己早期写代码的习惯是想到什么写什么。今天想加个跳跃就写一段跳跃逻辑明天想要个敌人又新建一个敌场景然后把玩家和敌人放在同一个场景里互相调用。结果就是项目写不到一个月场景间的依赖乱成一团改一个跳跃参数敌人也跟着出 bug。第五篇这个阶段应该开始有意识地做模块化设计。我一贯的做法是动手前先画一个很粗的流程图不是用工具就是用纸笔把谁在什么时候通知谁写清楚。拿刚才那个遇怪、战斗、解锁前进路线的场景来说涉及的模块大概有四个玩家角色、敌人、关卡地形、一个全局调度者。它们之间不应该是玩家脚本直接调用敌人脚本的强耦合关系而是通过信号Signal传递事件。玩家砍到敌人了玩家自己负责播动画、算伤害值但我造成了伤害这件事应该发一个信号出去至于敌人要掉血、要闪白、要后退让敌人自己的脚本去监听这个信号各自处理各自的事情。这样设计的好处最直接的一点是可复用性。2.2 为什么用信号风暴而不是互相调用很多从其他引擎转过来的朋友一开始不太习惯 Godot 这套信号机制。他们觉得我直接 get_node 拿到敌人的引用然后 enemy.hurt(damage) 不是更直接吗确实在功能很少的时候直接调用更省事。但等你开始加第二个敌人、第三种攻击方式、甚至敌人之间需要配合的时候直接调用会让每个脚本都要知道别人的接口长什么样代码耦合度直线上升。信号相当于一个广播。玩家脚本根本不关心谁在听这个广播可能是敌人、可能是计分器、可能是音效管理器、甚至以后加入的成就系统。这样一来新功能永远是加一个监听者而不是改原有代码的调用逻辑。我在实际项目里测试过同样做一个 Boss 战用强耦合写法大概 400 行脚本用信号解耦后主逻辑只要 250 行左右而且新增一个 BOSS 阶段只需要加一个监听信号的函数不用碰玩家逻辑。2.3 场景树的结构规划这一篇的 Demo我推荐的场景结构是这样的主场景Main存放 TileMap 关卡、玩家实例、敌人实例、一个隐藏的全局调度节点Player 场景负责移动、跳跃、攻击、自身状态对外只广播事件不直接操作别人Enemy 场景负责巡逻、追击、受击、死亡监听玩家广播的攻击信号同时广播自己的状态信号HUD/UI 层监听双方信号更新血条和提示文字全局调度节点监听关键事件比如敌人死亡负责解锁门、切换后续关卡逻辑这套结构看起来很常规但核心价值在于层级清晰UI 永远不直接读取玩家内部变量敌人永远不需要知道玩家按了什么键。任何单一模块出问题都可以独立调试。3. 核心机制拆解信号系统、状态机、TileMap 三件套3.1 信号系统不仅仅是发射和接收信号这个功能Godot 初学者最常见的误区是在脚本里定义了一个 signal然后在代码里 emit就以为完事了。但真实项目里信号的关键在于信号应该携带什么信息和在哪里连接。信号的定义建议挂在事件的发起者上。比如玩家攻击信号就定义在 Player 脚本里signal attacked(attack_data)。这个 attack_data 最好是一个包含伤害数值、攻击位置、击退方向的小字典或自定义资源而不是把每个参数都拆开传。因为后续你可能要加元素属性、暴击率、连击段数拆开传的话每加一个字段就要改所有连接方。信号的连接方式有两种一种是在编辑器里通过 [连接] 面板手动拉线另一种是在代码里 connect。我的经验是生命周期较长的对象比如主场景里的全局调度者用代码 connect 更可控场景内部两三个节点之间的简单通知用编辑器拉线更快。但切记不要混用否则项目大了之后你根本不知道哪个信号是在哪里连上的。还要注意一点如果一个节点被释放了它发出的信号没有监听者Godot 不会报错但如果你监听着一个已经被释放的节点就有可能出现Attempt to call function on a previously freed instance这种崩溃。所以敌人死亡后一定要记得在死亡流程里断开信号连接或者用 Callable 的 is_valid 检查。3.2 状态机让角色不乱套2D 动作游戏里角色最容易出的问题就是动作优先级乱套。你跳跃到一半还能出拳落地瞬间又能再跳一次空中可以二段跳但三段跳也莫名触发。这些问题几乎都是因为缺少状态控制。这个阶段我不建议引入特别复杂的状态机框架用枚举加一个状态变量就够了。enum State { IDLE, RUN, JUMP, ATTACK, HURT, DEAD } var current_state: State State.IDLE关键是定义好状态切换表。我常见的做法是写一个专门的处理函数func _try_change_state(new_state: State) - bool: if not _can_transition_to(new_state): return false _exit_state(current_state) current_state new_state _enter_state(current_state) return true func _can_transition_to(new_state: State) - bool: match current_state: State.IDLE: return new_state in [State.RUN, State.JUMP, State.ATTACK, State.HURT, State.DEAD] State.ATTACK: return new_state in [State.IDLE, State.HURT, State.DEAD] State.HURT: return new_state in [State.IDLE, State.DEAD] _: return false这套逻辑看起来有点粗糙但它强制你把什么状态下不能做什么写清楚。比如攻击后必须回到 IDLE 才能再次攻击这就天然解决了攻击硬直的问题。这里有个我踩过的坑很多教程会教你先改状态变量再播动画但如果你在攻击动画中途被敌人打到正确的顺序应该是先进入 HURT 状态、再播受击动画、然后才允许切回 IDLE。顺序反了会出现你明明看到角色被打飞但下一秒又能攻击的鬼畜画面。可以理解为状态切换必须先锁后放而不是先放后锁。3.3 TileMap别再把关卡画成一张大图如果你之前做关卡用的是一整张大图片加 Area2D 碰撞框那从这一篇开始真的要换成 TileMap 了。原因很简单TileMap 不只是画地图它本身就是一个带数据结构的地图系统。每一块 tile 都有自己的坐标、图层、物理属性后续要扩展随机地图、破坏地形、动态刷怪都会方便得多。在这个 Demo 里我用 TileMap 搭了一个战斗竞技场 通道的地形四面墙、中间几个障碍物、一条通向出口的路。只要把 TileSet 配置好在 TileMap 上用刷子画出来然后开启物理层玩家和敌人的碰撞就直接有保障了。TileSet 配置有几个细节值得注意。一个是碰撞层的设置如果你给所有 tile 都开了碰撞玩家走过去就会像撞在空气墙上一样体验很别扭。我的做法是用单独的物理 tile 图层可以理解为看不见的碰撞层专门负责挡住角色显示层只负责画好看的地砖。另一个是 TileSet 里不同的 tile 可以用不同的碰撞形状比如斜坡要设置成梯形碰撞而不是一个死板的方形碰撞否则角色走斜坡的时候会有明显的顿挫感。还有一点容易被忽略TileMap 节点的 y_sort 属性。俯视角或斜视角游戏里人物走到墙后面应该被遮挡走到墙前面应该遮挡墙这个效果就靠 y_sort 控制。4. 实操阶段搭建一个遇敌战斗Demo4.1 建立项目与输入映射打开 Godot新项目我建议直接用 Godot 4.x 版本GDScript 语法和 3.x 有一些区别但整体思路一致。项目名称用 2d_action_demo 就行。先配置输入映射。我习惯的映射方案是ui_left / ui_right沿用系统自带的左右方向jump绑定空格键和手柄南键attack绑定 J 键和鼠标左键dash闪避绑定 K 键和 Shift输入映射最好一开始就建好因为后面调试时频繁在代码里写 Input.is_action_pressed(jump) 比直接检查键位更清晰换键位也不用改代码。4.2 玩家角色脚本的完整骨架玩家场景建议用 CharacterBody2D 作为根节点下面挂 CollisionShape2D、AnimatedSprite2D或 Sprite2D 动画播放器、以及一个攻击检测用的 Area2D。攻击检测是很多新手困惑的地方。攻击动作只有几帧检测范围怎么判断我的方案是攻击时启动一个短暂的 Hitbox也就是一个在攻击帧内启用的 Area2D每一个攻击帧结束后关闭。这个 Hitbox 连接到敌人身体的 Hurtbox一旦 overlap 就发起伤害信号。下面是一个简化版玩家核心脚本extends CharacterBody2D signal died signal attacked(attack_data: Dictionary) enum State { IDLE, RUN, JUMP, ATTACK, HURT, DEAD } const SPEED : 160.0 const JUMP_VELOCITY : -300.0 const ATTACK_DAMAGE : 10 const ATTACK_COOLDOWN : 0.4 var gravity : 900.0 var current_state : State.IDLE var can_attack : true onready var hitbox : $Hitbox onready var anim : $AnimatedSprite2D func _physics_process(delta: float) - void: if current_state in [State.HURT, State.DEAD]: _apply_gravity(delta) move_and_slide() return _apply_gravity(delta) _handle_movement_input() _handle_attack_input() move_and_slide() _update_animations() func _handle_movement_input() - void: var direction : Input.get_axis(ui_left, ui_right) if direction ! 0: velocity.x direction * SPEED _try_change_state(State.RUN) else: velocity.x move_toward(velocity.x, 0, SPEED * 0.3) if is_on_floor() and current_state ! State.JUMP: _try_change_state(State.IDLE) if Input.is_action_just_pressed(jump) and is_on_floor(): velocity.y JUMP_VELOCITY _try_change_state(State.JUMP) func _handle_attack_input() - void: if Input.is_action_just_pressed(attack) and can_attack: can_attack false _try_change_state(State.ATTACK) hitbox.monitoring true await get_tree().create_timer(0.12).timeout hitbox.monitoring false await get_tree().create_timer(ATTACK_COOLDOWN - 0.12).timeout can_attack true if current_state State.ATTACK: _try_change_state(State.IDLE)这段代码里有一个关键细节攻击时我先 await 了一个 0.12 秒的 timer再关闭 hitbox。如果你不关 hitbox攻击结束之后碰到的敌人也会受到伤害这会导致隔空打人的 bug。很多刚上手的人都遇到过这种情况以为是自己碰撞框调错了其实是 monitoring 忘记关。地面检测这里也有学问。is_on_floor() 是 CharacterBody2D 自带的方法但它只有在 move_and_slide() 之后才有效。如果你在物理帧开头判断可能拿到的是上一帧的结果。常规做法是多加一个 RayCast2D 或者用 move_and_slide 之后再判断。我自己倾向于在 _physics_process 末尾更新状态避免时序错乱。4.3 敌人有限但够用的追踪 AI敌人我用 CharacterBody2D 做挂一个简单的巡逻 追击逻辑。核心思路是玩家进入敌人视野范围后敌人开始追踪追踪不是盲目走向玩家而是先进行视线检测——也就是用 RayCast2D 从敌人位置射向玩家如果中间没有障碍物阻挡才真正追击。这个视线检测非常提升游戏体验。没有它的话敌人会隔着墙走向你看起来像个没脑子的憨憨。加了它之后敌人会先在墙后徘徊等你露头才冲过来攻防节奏立刻就出来了。extends CharacterBody2D signal died enum EnemyState { PATROL, CHASE, ATTACK, DEAD } const SPEED : 70.0 const CHASE_RANGE : 220.0 const ATTACK_RANGE : 32.0 const DAMAGE : 5 var state : EnemyState.PATROL var player_ref: Node2D null onready var ray : $RayCast2D onready var sprite : $Sprite2D func _physics_process(delta: float) - void: match state: EnemyState.PATROL: velocity.x SPEED _patrol_turn_at_wall() EnemyState.CHASE: _chase_player() EnemyState.ATTACK: velocity Vector2.ZERO EnemyState.DEAD: velocity Vector2.ZERO move_and_slide() func _on_vision_body_entered(body: Node2D) - void: if body.has_meta(is_player): player_ref body func _chase_player() - void: if player_ref null or not is_instance_valid(player_ref): state EnemyState.PATROL return var to_player player_ref.global_position - global_position ray.target_position to_player ray.force_raycast_update() if ray.is_colliding(): # 被墙挡住保持巡逻或原地等待 velocity.x 0 return var dir sign(to_player.x) velocity.x dir * SPEED sprite.flip_h dir 0 if abs(to_player.x) ATTACK_RANGE and abs(to_player.y) 16: state EnemyState.ATTACK这段脚本的核心价值是追踪状态不是简单的永远走向玩家而是有一个视野被遮挡时的安静等待。这里的 sign(to_player.x) 是一个很实用的 GDScript 函数返回 1 或 -1用来确定朝向。在我早期做敌人 AI 时直接用了 to_player.normalized().x一旦玩家和敌人位置完全重合这个值会是 0敌人就面朝天空原地不动了。sign() 则能保证敌人至少会选择一个方向。敌人视野的检测范围我建议用 Area2D就是敌人的视觉范围进入后开始追踪。而不是每帧用 distance_to 判断距离。因为 Area2D 可以拖一个圆形碰撞框可视化程度高调试方便而且它也支持分组检测比手写距离检测更省心。4.4 伤害反馈三件套闪白、震退、无敌帧很多新手做完战斗后觉得砍人不痛多数是因为缺少反馈。一套基本的伤害反馈我认为至少要有三件事受击闪白、击退位移、短暂无敌帧。受击闪白用 shader 做最方便。给敌人的 Sprite2D 挂一个简单的 CanvasItemMaterial然后在受击时将 modulate 改成灰色或白色再快速恢复。GDScript 里我习惯用 tween 来恢复而不是直接写 timer因为 tween 可以自动插值视觉上更平滑。func _flash_white() - void: var original_modulate sprite.modulate sprite.modulate Color(3, 3, 3, 1) var tween : create_tween() tween.tween_property(sprite, modulate, original_modulate, 0.1)击退位移不是随便加一个 velocity 就行。我踩过最深的坑是在敌人受到伤害时直接把敌人往一个方向推结果敌人被推到墙里然后 CharacterBody2D 的 move_and_slide 开始疯狂抖动。原因是击退方向和墙面法线方向反复冲突物理系统每帧都在推入-推出之间震荡。解决办法是击退速度只在短时间内施加并且在敌人碰到墙后立即清零。可以理解为给击退一个短暂的冲量而不是持续的推进力。func apply_knockback(from_position: Vector2) - void: var dir : (global_position - from_position).normalized() knockback_velocity dir * 200.0 state EnemyState.HURT get_tree().create_timer(0.15).timeout.connect(_clear_knockback) func _clear_knockback() - void: knockback_velocity Vector2.ZERO无敌帧是另一个重点。没有无敌帧的话敌人陷在玩家攻击范围里一帧掉一次血几帧就死了玩家根本感受不到每次攻击是单独的。给敌人一个 0.3 秒的无敌时间每次受击后进入短暂闪烁状态不但保持了 DPS 稳定也让玩家看清自己在打谁。实现无敌帧最简单的做法是受击后把 collision_layer 里的player_hurt层临时移除或者直接用一个 bool 变量加冷却计时。我推荐用 Visibility 变化来做视觉提示也就是在无敌期间让敌人闪烁。但注意不要用 modulate 的 alpha 做闪烁因为 alpha 跌倒一半时敌人看起来像透明了容易误判死亡。4.5 死亡处理一个完整的清理流程敌人死亡不只是把 Sprite 隐藏那么简单。我在实战中发现死亡流程处理不好会带来大量内存泄漏和信号错误。标准的死亡流程应该是播放死亡动画 - 禁用碰撞 - 断开所有信号 - 延迟释放节点。func die() - void: if state EnemyState.DEAD: return state EnemyState.DEAD set_physics_process(false) $CollisionShape2D.set_deferred(disabled, true) died.emit() var tween : create_tween() tween.tween_property(sprite, modulate:a, 0.0, 0.5) await tween.finished queue_free()注意这里的 set_deferred。在物理回调里直接禁用 CollisionShape2D 会导致一个编辑器警告因为物理系统正在使用这个形状。set_deferred 会把操作排到这一帧结束后执行是一个老手习惯。另外我把 died 信号的 emit 放在禁用碰撞之后这样全局调度器收到通知时敌人已经不再参与物理计算不会再出现敌人死了一半还在撞玩家的怪事。5. 让关卡和战斗联动起来5.1 全局调度节点串起整个局一个完整的关卡需要有前进条件。这个 Demo 里我设定的是两只巡逻怪全部死亡后出口处的大门打开。全局调度节点只需要监听两个 Enemy 实例的 died 信号计数归零时发出一个 gate_open 信号门节点的动画开始播放。这个一个节点管全局流程的角色很多项目会放到 AutoLoad 单例里但我建议在早期不要滥用全局单例用场景内一个普通节点就足够了避免养成遇到问题就开全局变量的坏习惯。extends Node onready var gate : $Gate var total_enemies : 2 var killed_enemies : 0 func _ready() - void: for enemy in get_tree().get_nodes_in_group(enemies): enemy.died.connect(_on_enemy_died) func _on_enemy_died() - void: killed_enemies 1 if killed_enemies total_enemies: gate.open()这个脚本里有一个很值得说的细节用 get_tree().get_nodes_in_group(enemies) 拿到的节点列表是在 _ready 时快照。如果你在游戏中途动态实例化新敌人必须在生成新敌人后重新连接信号否则新敌人不会计入死亡计数。我遇到过这个问题当时打了一波怪门死活不开排查了半天才发现是动态生成的敌人没有被连接。5.2 门节点的实现带动画的静态机制门节点我用 StaticBody2D AnimatedSprite2D 实现。关闭状态是实体碰撞打开后把 CollisionShape2D 禁用。这里同样要用 set_deferred。有一点很实用门的打开动画播放完毕之前碰撞就应该先禁用还是后禁用如果先禁用碰撞玩家会看到动画还没播人已经穿过门了很出戏。正确的顺序是播放动画 - 等待动画到某个关键帧 - 禁用碰撞。我实现方式是直接 await 动画帧长或者用 AnimatedSprite2D 的 frame_changed 信号。这个视觉和物理分离的原则在动作游戏里极其常见攻击动作的伤害判定通常不在动画第一帧而在动作挥到一半的关键帧上同理门的物理开启也应该对齐到动画里门已经挪开的那一帧。新手容易犯的错误是同步操作导致视觉一路物理一路。6. 调试与常见问题那些让我熬夜的坑6.1 碰撞抖动和穿透抖动主要来自两个原因。一个是上面说的击退方向与墙面冲突另一个是 move_and_slide 的参数没调好。CharacterBody2D 的 safe_margin 参数默认是 0.001如果你把角色碰撞形状做小了移动速度又快就有可能在穿透边缘时发生抖动。可以适当提高 safe_margin 到 0.01但不要过高否则角色会在墙边滑得特别肉。碰撞穿透则多半是碰撞层的问题。Godot 默认的物理层有 32 层我建议专门划分出几个意义明确的层layer用途1世界地形2玩家身体3敌人身体4玩家攻击 Hitbox5敌人受击 Hurtbox然后在项目设置里勾选哪些层和哪些层碰撞。比如玩家攻击 Hitboxlayer 4应该检测敌人 Hurtboxlayer 5而玩家的身体layer 2永远不会和玩家的攻击 Hitboxlayer 4碰撞。这样配置一次后面所有场景都不会因为误设层而互相干扰。6.2 信号重复连接导致多次扣血敌人在同一个攻击帧里被 Hitbox 碰了两次或者是 Area2D 的 body_entered 信号连接了两次会导致一次攻击扣两次血。我调试时最直观的方法是在伤害处理函数里打印一行日志观察。这个问题根源往往是连接时机。如果信号在 _ready 里连接而敌人又被实例化了多次每个实例都连接一次就会出现 N 倍伤害。另一个常见情况是你在代码里 connect但没绑定方法时用了 bind导致同一个信号被传递了多个额外参数看起来是重复扣血实际是处理函数被异常调用。我的建议在处理伤害时做一个全局唯一的 UUID 标记。比如每次攻击生成一个自增 id敌人记录自己上次处理过的 id重复就忽略。这个方法虽然有点重但确实能根治所有重复处理问题。6.3 TileMap 里物理层不生效很多人配好了 TileSet也勾选了物理层但玩家还是直接穿过地板。原因往往是忘了给 TileMap 节点里的物理层选择正确的 collision_layer 和 collision_mask。虽然 tile 本身有碰撞形状但 TileMap 节点作为一个整体的物理体它的碰撞掩码要和角色的一致才响应。还有一个小坑如果你修改了 TileSet 的碰撞形状但在编辑器里没有点重新生成物理体运行时依旧使用旧的碰撞数据。遇到这种我明明改了但没生效的问题先试着重开场景或者重新选择一下 tile 刷子。6.4 动画播放和状态不同步角色看起来在做攻击动画但脚本认为它还在 IDLE导致后续输入不响应。问题几乎都出在动画播放位置。你需要在动画的某个关键帧处收到回调通知脚本当前动画进入有效帧。AnimatedSprite2D 没有内置的帧回调但你可以用 animation_finished 信号或者自己监听 frame_changed 信号。我强烈建议养成一个习惯把战斗判定帧统一放在动画的第 2~3 帧而不是动画一启动就生效。这样你的动作表现会自然很多。虽然只是延迟了 0.05 秒左右但玩家体验上会觉得攻击更有挥出去的感觉而不是瞬发。7. 从这一篇到下一篇你的项目可以怎么继续扩展这个 Demo 到这里已经具备了一个小型 2D 动作游戏的完整切片主角状态机、攻击判定、敌人追踪 AI、受击反馈、死亡清理、关卡调度、门机关。我的建议是你拿到这套代码后不要急着做下一个新功能而是先改造现有的东西。比如试着给玩家增加一个单独的攻击动作序列让普通攻击和重击是两套不同的状态并且共用同一个 Hitbox或者把敌人从近战追踪型改成一个远程射击型让它每隔几秒朝玩家方向发射一个小型投射物看看你的信号架构能不能轻松支撑这种扩展。如果这套信号体系设计得足够好后一种扩展应该只需要新建一个投射物场景然后在敌人的攻击状态下实例化它不需要碰玩家脚本也不需要碰关卡调度。如果你发现自己改起来很费劲说明信号边界还没切干净这时候重构成本还低赶紧回头收拾一下。我个人比较喜欢把这个游戏类型最核心的一个循环做透而不是急着铺量。一个手感扎实的战斗循环比十个粗糙的机制堆叠更能撑起一个项目。前面几篇你可能已经体会过功能堆得越多项目越难维护的滋味这一篇就是在教你用信号和状态机把复杂度管住。下一篇我会在这个 Demo 的基础上把敌人掉落物品和玩家拾取金币反馈这两块加上顺便讲讲 Godot 的 Tween 在 UI 动画里的实战用法。在动手之前请你先确认一个事现在你的玩家脚本里还找得到任何直接调用敌人脚本方法的代码吗如果找不到说明你已经跨过初学者的那道坎了。