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. 实操阶段:搭建一个"遇敌战斗"Demo
4.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 | 玩家攻击 Hitbox |
| 5 | 敌人受击 Hurtbox |
然后在项目设置里勾选"哪些层和哪些层碰撞"。比如玩家攻击 Hitbox(layer 4)应该检测敌人 Hurtbox(layer 5),而玩家的身体(layer 2)永远不会和玩家的攻击 Hitbox(layer 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 动画里的实战用法。在动手之前,请你先确认一个事:现在你的玩家脚本里,还找得到任何"直接调用敌人脚本方法"的代码吗?如果找不到,说明你已经跨过初学者的那道坎了。