很多人做类银河恶魔城(Metroidvania)项目,前期最顺手的就是平台跳跃、冲刺、墙壁跳这几板斧,一旦开始做 BOSS 战,马上就卡住了。尤其是“远程攻击”这段:Boss 不会追踪玩家、弹道速度不对、命中判定乱七八糟、攻击完不知道怎么切回原来的状态。结果就是 Boss 要么原地发呆,要么疯狂乱放技能,完全不像一个有压迫感的守关怪物。
这一集教程讲的其实是 BOSS 战里一个非常关键的落点:让普通 Boss 变成一个远程“弹幕型”敌人。解决的不只是“发射一个东西”,而是状态切换、方向锁定、弹道生成、伤害判定、视觉反馈这一整套链路。如果你正卡在 Boss AI 不知道怎么写,或者写完像木桩互啄,这篇文章值得看完。
1. 为什么 BOSS 远程攻击这么容易写崩?
先说一个现象:很多人给 Boss 加远程攻击,第一反应是“发射一个 Area2D 就行了”。这句话理论上没问题,但放到真正的 BOSS 战里,会出现三个问题。
第一个问题:Boss 根本不面向玩家。如果你的攻击动画是朝右的,但 Boss 站着的位置在玩家左边,那发射出来的弹就有可能背对玩家打空气。严格说,这才是远程攻击的第一件正事——先面向目标,再谈攻击。
第二个问题:Boss 的攻击流程是“一锤子买卖”。很多新手代码里,Boss 发射完一个弹就完事了。真正的 BOSS 战里,一次远程攻击应该包含:进入攻击状态、播放起手动画、确定弹道方向、生成弹、等待攻击动画结束、退出攻击状态、回到 Idle。这个流程如果只写一半,Boss 就很容易卡在某个状态里出不来。
第三个问题:弹道的伤害判定频闪。很多人的弹是 Area2D,但忘记处理“已经碰到玩家一次,不要再重复扣血”的问题。于是玩家站在弹道里,血条瞬间暴跌。这在单机游戏里被玩家骂“阴间 Boss”,在技术角度就是缺少已命中对象管理。
所以这一集的核心,不是“教你怎么写一个 Bullet”,而是把远程攻击当成一个完整战斗事件来设计。代码逻辑和状态机的耦合程度,决定了 Boss 战最后好不好玩、稳不稳定。
2. 用状态机管理 BOSS 远程攻击
Godot 4 里做 Boss 逻辑,最推荐的方式还是状态机。它不一定是你查到的 State Machine 插件,完全可以自己用枚举或者类来管理。重点在于,要让攻击只是状态机里的一个状态,而不是一个独立的函数到处飘。
2.1 基础状态机结构
Boss 远程攻击的状态流转一般是这样:
Idle(等待) ↓ 检测到玩家进入攻击范围 / 攻击冷却结束 Attack(远程攻击) ↓ 动画播放完毕 ↓ 弹道已经生成,切换回 Idle Idle(继续巡逻或等待)这个流程看起来简单,但有一点值得注意:弹道发射的时机不一定等于动画开头。通常会有延迟帧,比如 Boss 先蓄力 0.5 秒,再发射弹。这样做是为了给玩家反应时间,而不是子弹一出动画就打人。
如果你用的是 Godot 4 自带的 AnimationPlayer,最稳妥的做法是在攻击动画的关键帧上调用动画事件(Animation Callback),这样发射弹的时机和动画严格同步。如果没有关键帧事件,也可以在脚本里用 Timer 手动延迟,但这样容易出现“动画还没播完,弹已经打出去了”的错位感。
2.2 用枚举表示状态
在 GDScript 里,最轻量的状态机可以这样设计:
extends CharacterBody2D # Boss 基础状态 enum State { IDLE, ATTACK_RANGED, ATTACK_MELEE, HURT, DEAD } var current_state: State = State.IDLE每次切换状态时做三件事:
- 退出上一个状态时清理临时数据。
- 设置当前状态。
- 根据状态播放对应动画。
具体点说,进入远程攻击状态时,要记录“当前面朝方向”,而不是在发射弹的时候再读取玩家位置。因为在弹道生成的一瞬间,玩家可能刚好跳过头顶,或者从另一侧绕过来了。提前锁定方向,玩家更容易通过走位躲避,同时 Boss 也显得更有设计感。
3. 让 BOSS 远程攻击“看向玩家”:面朝方向锁定
3.1 什么是面朝方向锁定?
先看一个最容易踩的坑。很多人的代码这么写:
func _on_attack_finished(): var bullet = bullet_scene.instantiate() bullet.position = global_position + Vector2(50, -50) get_parent().add_child(bullet) bullet.velocity = Vector2(1, 0)这段代码会导致两个问题:
- 不管玩家在哪,子弹永远向右飞。
- 子弹生成位置是固定的,看起来像从 Boss 身体的某个点凭空冒出来。
正确的做法是,在 Boss 进入攻击状态时,先计算一个方向向量,并把它存储起来。比如:
func _enter_ranged_attack(): if player: var direction = (player.global_position - global_position).normalized() attack_direction = direction # 根据方向设置 Boss 的朝向 if direction.x > 0: scale.x = abs(scale.x) # 面向右 else: scale.x = -abs(scale.x) # 面向左这样做的好处非常明显:
- Boss 在起手阶段就完成转向,看起来像是在“瞄准”玩家。
- 弹道方向在攻击开始时已经确定,避免连射时方向突然扭曲。
- 后续动画、弹道生成都能共享同一个方向向量。
需要注意,2D 游戏里如果角色默认面朝右,那么 scale.x 取正就是右,取反就是左。如果你用的素材默认是面朝左的,那么方向判断也要对应调整。还有一个细节:如果 Boss 在攻击期间“锁定方向”,玩家绕到身后,Boss 不会转身。这是正常设计,不要额外做跟踪修正。真正有压迫感的弹幕 Boss 往往在攻击完后才会重新调整朝向。
3.2 弹道生成位置和炮口
BOSS 远程攻击的弹道生成位置,不能直接放在 Boss 的 global_position,否则子弹可能从胸口或者脚下出来。最佳实践是给 Boss 加一个独立的 Marker2D 节点,例如:
BossRoot ├── Sprite2D ├── AnimationPlayer ├── RangeAttackMarker(Marker2D) └── Hitbox生成弹时:
var marker = $RangeAttackMarker var bullet = bullet_scene.instantiate() bullet.global_position = marker.global_position这个 Marker 放在 Boss 的“炮口”或者“手部”,视觉上子弹就从合理位置发出。这个细节看起来小,但对 Boss 战的观感影响非常大。很多独立游戏项目就是因为子弹从身体中心发射,才会在玩家眼里显得特别廉价。
3.3 完整的远程攻击状态代码示例
下面是这一集教程里的核心代码,做了稍许整理,你可以直接复制到 Boss 脚本里参考:
extends CharacterBody2D enum State { IDLE, ATTACK_RANGED, HURT, DEAD } @export var bullet_scene: PackedScene @export var attack_cooldown: float = 1.5 var current_state: State = State.IDLE var player: CharacterBody2D var attack_direction: Vector2 = Vector2.RIGHT func _ready(): player = get_tree().get_first_node_in_group("player") func _physics_process(delta): match current_state: State.IDLE: # 简易AI:冷却完成后,如果玩家在范围内就发起攻击 if player and can_attack: _enter_ranged_attack() State.ATTACK_RANGED: # 攻击期间锁定位置或锁定面朝方向 pass func _enter_ranged_attack(): current_state = State.ATTACK_RANGED # 锁定方向 attack_direction = (player.global_position - global_position).normalized() _update_facing() # 播放攻击动画,动画播放完后会触发 _on_attack_finished $AnimationPlayer.play("boss_ranged_attack") func _update_facing(): if attack_direction.x > 0: scale.x = abs(scale.x) else: scale.x = -abs(scale.x) func _spawn_bullet(): var marker = $RangeAttackMarker var bullet = bullet_scene.instantiate() bullet.global_position = marker.global_position # 把方向传给子弹 bullet.set_direction(attack_direction) get_parent().add_child(bullet) func _on_attack_finished(): current_state = State.IDLE在这个结构里,动画播放完毕后调用_on_attack_finished()。你可以通过 AnimationPlayer 的动画结束信号,或者在最末尾的关键帧插入一个调用。这样 Boss 的远程攻击就变成了一个完整、闭环的动作,而不是一笔糊涂账。
注意_update_facing()这里用了scale.x翻转。如果你使用的素材本身没有对称性,比如 Boss 的左手有特殊装备,那么建议把“面向”和“动画翻转”分开处理,或者使用一个单独的Sprite2D节点来控制翻转,而不是直接翻转整个 Boss。
4. 子弹对象的设计:ENEMY BULLET
在 Godot 4 中,Boss 的远程弹和玩家的技能弹在设计上是相似的,但有一个关键区别:所属阵营不同,判定 Hurtbox 也不同。Boss 的弹应该伤害玩家,而它的 Hitbox 结算对象应该是玩家的 Hurtbox。
4.1 子弹场景节点结构
新建一个子弹场景,节点结构建议如下:
EnemyBullet ├── Sprite2D ├── CollisionShape2D(圆形) ├── Area2D └── ParticleEmitter(可选,用于特效)子弹的核心是一个 Area2D,而不是 CharacterBody2D。因为子弹一般不需要参与物理碰撞和移动碰撞,使用 Area2D 更容易控制伤害判定。
4.2 子弹 GDScript 示例
子弹脚本的主要职责有四个:
- 接收攻击方向。
- 按速度移动。
- 检测是否命中玩家 Hurtbox。
- 命中或出界后销毁。
extends Area2D @export var speed: float = 300.0 @export var damage: int = 10 var direction: Vector2 = Vector2.RIGHT func set_direction(dir: Vector2): direction = dir func _physics_process(delta): global_position += direction * speed * delta func _on_body_entered(body): if body.is_in_group("player"): body.take_damage(damage) queue_free() print("玩家受到伤害")这里又出现一个新的细节:_on_body_entered和_on_area_entered的区别。
在 Godot 4 里,Area2D 的碰撞检测有两种信号的触发方式:
body_entered:检测到 PhysicsBody2D(例如 CharacterBody2D、RigidBody2D)。area_entered:检测到 Area2D。
一般玩家用的是 CharacterBody2D,所以body_entered是正确选择。如果玩家本身是 Area2D(比如纯悬空 Hitbox),那就要用area_entered。这个判断如果搞错了,子弹会直接穿透玩家,Boss 变成了摆设。
4.3 防止弹幕重复伤害
如果一颗子弹是一个 Area2D,命中玩家后立即销毁,那重复扣血问题会被规避一大半。但如果是激光类、持续电圈类等“持续性伤害”,就必须额外做处理。
最简单的处理方式,是在玩家的 Hurtbox 脚本里添加“无敌帧”或“受击时间戳”:
var last_hurt_time: float = 0.0 func take_damage(dmg: int): if Time.get_ticks_msec() - last_hurt_time < 500: return last_hurt_time = Time.get_ticks_msec() health -= dmg无敌帧不仅适合 BOSS 弹幕,也适合玩家被连续碰到时防止瞬间暴毙。实际上,这一集教程虽然没有深入做玩家受伤逻辑,但如果你直接把 Boss 弹接进玩家 Hurtbox,不加无敌帧,很快就会发现玩家被一颗弹连续扣血多次。这个坑几乎人人都踩。
4.4 子弹生命周期
子弹飞出屏幕后,也应该销毁,否则场景里会堆越来越多的节点。最轻量的方案是:
# 子弹脚本里 func _ready(): await get_tree().create_timer(5.0).timeout queue_free()或者更精确的做法是判断是否在屏幕外:
func _process(delta): if global_position.x < -100 or global_position.x > 2000: queue_free()某些 Boss 战里弹幕需要长时间存在,比如全屏弹。用恒定时间销毁比较稳妥,也能避免弹幕积累导致性能下降。
5. 完整 BOSS 战远程攻击流程拆解
现在我们把整体流程串起来,从玩家进入 Boss 房间开始,到远程攻击结束,一共有这么几个环节。
5.1 进入战斗范围
Boss 初始状态通常是 Idle,它在原地等待,或者缓慢巡逻。玩家靠近后,Boss 才进入战斗 AI。在 Godot 里可以用 Area2D 做“警戒区”:
BossAwakeArea(Area2D) └── CollisionShape2D(大范围圆)当玩家进入警戒区,就设置一个变量,例如can_attack = true。这样 Boss 不会在玩家还没看到它的时候就开始放技能,非常影响公平性。
5.2 攻击判定范围
Boss 远程攻击不需要像近战那样贴脸判定,只要玩家在设定的攻击范围(例如 400 像素)内,Boss 就可以发起攻击。范围判定推荐使用global_position.distance_to(player.global_position):
if global_position.distance_to(player.global_position) < 400: can_attack = true注意,最理想的设计是“进入警戒区优先近战,距离拉开后选择远程”。如果你的 Boss 技能少,可以只留远程攻击,效果已经不错。技能多的 Boss,可以按距离和随机权重来选状态。
5.3 攻击动画和延迟
Boss 远程攻击动画一般分成三段:前摇、出弹、后摇。
- 前摇:动画前 0.2-0.5 秒,Boss 蓄力、发光、转身。这个阶段不发射弹,但玩家已经能预判。
- 出弹:在动画中间关键帧生成子弹并发射。
- 后摇:发射后到动画结束,Boss 保持硬直,留给玩家输出窗口。
这种设计是 Boss 战手感的灵魂。如果前摇时间太长,玩家会觉得无聊;如果几乎没有前摇,玩家又会觉得不公平。中等难度下,300 到 600 毫秒是常见区间,具体取决于弹速和伤害。
在 Godot 4 里,关键帧调用最好用 AnimationPlayer 的 Call Method Track。在动画里选定一帧,插入“调用方法”轨道,写上_spawn_bullet。这样你的逻辑与动画同步,不需要额外计时器,也不会出现“动画播完了弹还没出来”的错乱。
5.4 状态退出
动画结束后,Boss 要回到 Idle。这里有一个很多人忽略的问题:如果玩家在 Boss 攻击期间贴脸近战,Boss 要不要强制转向或者中断攻击?
一种简单方案是:攻击期间完全不处理玩家位置,让 Boss 朝锁定方向攻击;攻击结束才更新朝向。这会让玩家产生“走位后躲开了”的反馈,策略性强。另一种方案是攻击期间持续瞄准玩家,导致很难躲,适合高难度弹幕 BOSS。
这一集教程更偏向轻量设计,建议用锁定方向方案。玩家会明显感觉到“Boss 起手的时候朝我这边攻击,我避开之后它就打空了”,这个反馈在单机游戏里很重要。
5.5 多段远程攻击
有些 Boss 会一次连续发射三颗弹。实现方法有几种:
- 在同一动画里生成多个 Marker,然后依次生成多个子弹。
- 使用一个“连发计数器”,每次攻击后不回到 Idle,而是再进入一次攻击状态。
- 让子弹自动控制连发,Boss 只负责生成。
最简单的是方案 1。在 Boss 的攻击动画里,每隔几帧调用一次_spawn_bullet(),并让每次生成的位置用小偏移量,形成扇形弹幕:
for i in range(3): var offset_angle = (i - 1) * 15 var bullet = bullet_scene.instantiate() bullet.global_position = $RangeAttackMarker.global_position bullet.set_direction(attack_direction.rotated(deg_to_rad(offset_angle))) get_parent().add_child(bullet)对于类银河恶魔城游戏,这种 3 连发或 5 连发的远程攻击已经足够撑起一个 BOSS 战的基础招式。
6. 给新手的两点提醒:不要一开始就写“完美 BOSS AI”
写 BOSS 战特别容易陷入的一个心态:想把所有技能、AI、动画全部设计好再动手。实际上新手项目最佳做法是“最小闭环”:一个 Boss,一个远程攻击,一个动画,一颗子弹。跑通后,再逐步加入连发、弹射、召唤等机制。
第一点:动画不要一开始就追求豪华。可以先做一个简单的“闪一下光,手抬起来”的动画,脚本逻辑跑通了再让美术资源替换,或者你自己画几帧简单的像素图。很多项目是死在“等着美术资源”这一步,实际完全可以用 Placeholder 先把玩法验证出来。
第二点:不要急着加血量变化、屏幕震动、音效。这些是增强打击感的东西,但和“远程攻击逻辑是否成立”没有关系。先把弹能不能飞出去、能不能命中、能不能重复伤害解决,再考虑反馈效果。
7. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| Boss 不发射子弹 | 信号没连接或攻击状态没切换 | 打印_enter_ranged_attack是否被调用 | 确保动画信号或 Timer 正确触发逻辑 |
| 子弹没有飞到玩家方向 | 方向向量计算时机不对 | 检查攻击开始时是否已经更新attack_direction | 在进入攻击状态时立即锁定方向 |
| 子弹从 Boss 的脚底下发出来 | 生成位置使用了根节点坐标 | 检查生成时的坐标来源 | 使用 Marker2D 作为炮口位置 |
| 子弹打中玩家但没扣血 | body_entered信号类型选错 | 确认玩家节点是否为 CharacterBody2D | 改用body_entered或用area_entered检测 Hurtbox |
| 连续扣血 | 没有无敌帧或没有销毁子弹 | 打印受伤次数 | 给玩家 Hurtbox 添加无敌时间或子弹命中后queue_free() |
| Boss 发射后卡在攻击状态不动 | 动画结束回调没有触发 | 检查 AnimationPlayer 的动画结束信号连接 | 确认动画播放完毕或结束信号连接正确 |
| 转向让整个 Boss 的碰撞体也翻转了 | 直接用 scale.x 反转了根节点 | 观察翻转对 CollisionShape2D 的影响 | 用 Sprite2D 节点或专用 Facing 节点控制翻转 |
| 远程弹有时穿墙 | 子弹没有进行物理碰撞排除 | 给弹加 CollisionLayer 和 Mask | 调整子弹的 CollisionLayer,排除墙壁层或手动探测墙体 |
排查时记住一个原则:从逻辑链路的上游往下游查。先确认 Boss 是否进入了攻击状态,再确认动画回调是否触发,再确认子弹是否生成,再确认子弹方向是否正确,最后确认子弹是否命中玩家。不要一开始就怀疑子弹速度或者碰撞层有问题,先看日志输出到哪一步停了。
8. 实际项目中的最佳实践
这类 BOSS 远程攻击逻辑,放到一个中型 Metroidvania 项目里,有一些经验值得提前记下。
8.1 子弹池管理
如果 Boss 一次发射很多弹,而且弹还不会及时销毁,项目后期会出现节点数量爆炸。小的 BOSS 战问题不大,如果 Boss 带“弹幕阶段”,建议用简单的对象池。
Godot 4 中对象池的基本思路:预先创建一组子弹,标记为 inactive,使用时移动到目标位置并激活,回收时再设为 inactive。虽然实现起来比直接instantiate多几行代码,但对性能提升非常明显。
8.2 碰撞层分配
建议固定好层,不要所有东西都在同一层:
Layer 1: 玩家 Layer 2: 敌人 Layer 3: 玩家子弹 Layer 4: 敌人子弹 Layer 5: 墙壁 Layer 6: 玩家 Hurtbox Layer 7: 敌人 HurtboxBoss 的远程弹只放在 Layer 4,跟玩家的 Hurtbox 检测。不会误伤敌人,也不会和 Boss 自身发生碰撞。这种分层方式在 Godot 4 的 PhysicsLayer 设置非常直观。
8.3 血量、伤害数字、硬直
Boss 远程攻击命中玩家后,一般要配合受击反馈。最简单的是让玩家闪白、红闪、或者受伤音效。如果需要伤害数字,尽量把 UI 逻辑和伤害结算逻辑分开。我见过很多项目把 UI 写死在怪物节点里,导致后面想加“多段伤害”或“暴击”非常痛苦。
8.4 远程攻击的冷却管理
Boss 不能一直放技能。推荐给 Idle 状态加一个cooldown_timer,攻击结束后倒计时冷却,冷却期间 Boss 可以做一些移动、近战或者等待动作。这样不会让战斗变成单纯弹幕对射。
var cooldown_timer: float = 0.0 func _physics_process(delta): cooldown_timer -= delta if cooldown_timer <= 0.0: can_attack = true8.5 不要让 Boss 生成子弹后直接遗忘
很多新手把子弹生成出来,然后就不管了。如果子弹不会销毁,也不做任何碰撞处理,这些节点就会一直堆在内存里,游戏越来越卡。所以子弹的销毁逻辑必须和生命周期强绑定,要么命中销毁,要么定时销毁,要么离开屏幕销毁,至少三选一。
9. 从简单远程攻击到复杂弹幕模式的思考
这一集教程虽然只讲了 Boss 远程攻击的基础实现,但它打开了一条进阶路径。当你熟练之后,可以在这套基础上做更多扩展。
- 分裂弹:子弹飞到中途分裂成几个子弹。
- 追踪弹:子弹在飞行过程中缓慢朝玩家方向旋转。
- 弧形弹:子弹速度方向不断变化,产生弧线轨迹。
- 振波弹:子弹有点状不连续伤害,需要用多段 Area2D 实现。
- 地形交互弹:子弹碰到墙壁后反弹一次或两次。
所有这些都建立在“状态机 + 方向锁定 + 炮口 Marker + 子弹对象 + 命中检测”这套底座上。所以这一集的价值不在代码量,而在把基础模型搭稳。
对类银河恶魔城项目来说,Boss 攻调节奏比弹幕华丽程度更重要。一个 Boss 的远程攻击你不能让它无缝衔接,必须给玩家足够的闪避窗口。比如你这一次设计了 5 连发弹,那下一次攻击之间要留 1.5 到 2 秒的间隔。玩家不是要在弹幕里硬抗,而是要通过走位找到输出时机。设计 Boss 战的时候,多站在玩家的角度想想:这个攻击给不给反应时间?是不是连续两次远程攻击就无法接近了?
如果你现在正在写自己的 BOSS 战,可以先用一个最小场景:一个 Boss 节点、一个子弹场景、一个玩家角色。把远程攻击跑通,再从视觉和音效上做打磨。这套思路无论放在 Godot 4 还是其他引擎,底层都是通用的:状态机负责行为,方向锁定保证精准,弹道对象负责表现和伤害。
下次 BOSS 战迷茫的时候,记住一个检查顺序:状态切换正不正确、炮口位置对不对、子弹速度合不合理、命中反馈到不到位。按这个顺序调整,Boss 战不会差到哪里去。