做2D俯视角射击生存游戏时,最能让项目一夜之间从“能玩”变成“幻灯片”的,就是敌人数量。拿Godot写敌人AI、批量刷新、伤害结算,每一步看起来都有现成接口,可真把几百个敌人同时丢进场景里,才会意识到每个看似正常的决策背后都藏着性能开销。这篇系列二的第十五篇,我想集中聊一聊2D俯视角生存类游戏中最核心的敌人系统:状态机怎么拆、寻路到底要不要开、伤害结算为什么总把玩家秒死、以及怎么用对象池让同屏500只怪跑起来不卡。
适合这篇文章的场景很明确:你已经能用Godot做一个会移动、会射击的玩家角色(如果还没有,建议先回头把系列里移动和弹幕那几篇看一遍),现在想让场上同时刷出大量敌人,并且维持稳定的帧率。我会按自己实际调试的顺序来讲,把每个坑、每段排查过程都展开,方便你直接抄到自己的项目里。
1. 敌人行为状态机:别让每个敌人都顶着一整套“大脑”
1.1 从三类基础敌人拆分设计
俯视角生存游戏里的敌人虽然千奇百怪,但底层行为基本能归纳成几类:无脑冲向玩家的近战、保持距离点射的远程、以及血厚带技能的精英。不要一上来就写一个万能敌人脚本,而是把公共逻辑抽到基类里,再让子类覆盖移动和攻击策略。
我自己常用的分层是这样的:
- 近战敌人(Chaser):速度中等,碰撞伤害,靠近玩家后扑咬。
- 远程敌人(Shooter):与玩家保持固定距离,间歇性射出子弹。
- 精英敌人(Elite):血厚、攻击频次低但伤害高,通常会带一个冲锋或范围爆发技能。
先做一个enemy_base.gd,把血量、移动速度、攻击冷却、当前状态都放进去。子类只需要改_setup_stats()和_attack_behavior()这两个函数,剩下的状态切换逻辑复用同一套。这样后面新增敌人类型时不会把项目变成一锅粥。
用一个表格来区分三种敌人的核心参数,这样后续调数值时也方便:
| 类型 | 速度 | 血量 | 攻击方式 | 攻击范围 | 特殊行为 |
|---|---|---|---|---|---|
| 近战 | 120~180 | 30~50 | 接触伤害 | 32px | 无 |
| 远程 | 80~110 | 20~30 | 子弹 | 300~400px | 保持距离 |
| 精英 | 90~130 | 300~600 | 近战+技能 | 60px | 定期冲锋 |
这些数值不是拍脑袋定的,要按照玩家攻击力反向推导。比如玩家每发子弹打10点伤害,近战敌人血量30,那就是三枪一个。精英600血,需要60发子弹,配合暴击才有点压迫感。
1.2 状态机实现,以及为什么不用行为树
状态机我建议用枚举加match的方式写,别去给每个敌人都装一个行为树插件。行为树在几个高智商Boss身上确实好用,但它需要额外遍历、甚至可能要解释器执行节点,当敌人数量上到两三百时,这部分开销会非常显眼。
基础状态可以这样定义:
enum State { IDLE, CHASE, ATTACK, HURT, DEAD } var state: State = State.IDLE var state_time: float = 0.0 func _physics_process(delta: float) -> void: state_time += delta match state: State.IDLE: _state_idle(delta) State.CHASE: _state_chase(delta) State.ATTACK: _state_attack(delta) State.HURT: _state_hurt(delta) State.DEAD: _state_dead(delta)match分支里每个函数都很短,本质上是把行为分割成小块。这样做的好处是调试方便:你想让敌人发呆,就直接state = State.IDLE,想让它强制进入受击,就把state切到HURT并设置state_time。
最关键的优化点是:不要让每个敌人在_physics_process里去做权重很高的寻路或遍历计算。我最初写的时候,把 NavigationAgent2D 的get_next_path_position()放进每个敌人每帧的CHASE状态里调用,结果100个敌人时就掉到40帧,200个直接没法玩。原因在于寻路查询是同步阻塞的,而且每个agent都要维护一组内部路径点。
后面改成“定时寻路 + 直线移动”的思路:
- 每隔0.3~0.5秒调用一次寻路,更新目标方向。
- 两次寻路之间,敌人直接沿当前方向用
move_and_slide()前进。 - 如果敌人视线中间没有障碍,直接用
global_position.direction_to(player.global_position),连寻路都可以省掉。
这个“定时寻路”的思路在大量敌人时特别管用。对于大多数低智商敌人,路径不需要每帧重新算,稍微迟钝一点玩家完全感知不到,反而还制造出一种“笨拙”的真实感。
2. NavigationAgent2D:从“能用”到“大量敌人不出错”的调参边界
2.1 烘焙与导航设置里最容易翻车的几步
Godot 4的2D寻路主要靠NavigationRegion2D配合NavigationPolygon烘焙。很多新手卡在没有正确烘焙,运行时导航区域是空的,敌人站在原地上蹿下跳。烘焙步骤简单,但有几个细节不能漏:
NavigationRegion2D必须包含一个NavigationPolygon资源,并且这个资源的多边形要覆盖场景可行走的区域。- 烘焙时不要生成“太贴近墙壁”的路径点。给导航多边形留出缩进量,否则敌人会频繁蹭墙。
- 2D格子的世界坐标要和
TileMapLayer的坐标对齐。如果地图从(0,0)开始,TileMap的position手动偏移过,寻路出来的坐标就会整体错位,一眼看过去敌人都在往空气里走。
NavigationAgent2D放在敌人节点下,建议在_ready()里先await get_tree().physics_frame,等所有物理服务就绪后再设置target_position。不然新场景刚实例化时,导航地图还没注册完,再次定位会得到一个空路径。
2.2 avoidance到底开不开?我最后的决定是关掉
Godot 4的NavigationAgent2D自带一个avoidance功能,开启后可以让敌人互相避让、不至于叠成一团。听起来很美好,但在大规模敌人场景下,我实测下来效果非常不稳定。
开启avoidance时,每个agent内部都在做物理推算,几百个agent互相计算避让力,会产生两类现象:
- 敌人像无头苍蝇一样来回抖动,尤其在窄通道里,谁都走不动。
- 帧时间明显上升,因为避让计算比路径查询还要昂贵。
我的最终方案是:彻底关掉所有普通敌人的avoidance,用NavigationPolygon烘焙出的路径 + 敌人碰撞层之间的简单物理推开来替代。做法是在敌人的move_and_slide()后,给它一个极小的侧向推力,或者干脆让敌人碰撞层之间不互相阻挡,只有玩家和障碍阻挡敌人。
具体到Collision层:
- 玩家层:2
- 敌人层:3
- 障碍层:4
- 子弹层:5
敌人collision_mask设为 4(障碍)和 2(玩家),不要包含3(敌人自己)。这样敌人会互相穿过,视觉上形成“蜂群”,大幅减少拥堵计算。如果觉得互穿太假,可以给敌人加一个avoidance的轻量视觉偏移,用Shader或Sprite抖动来模拟拥挤,不在物理层做真正的避让。
当然,作为Boss的精英怪可以例外:给它单独开启avoidance,因为Boss只有一两个,性能压力小,且需要它显得更加“聪明”。
2.3 帧率掉的时候,先查寻路是不是在“空转”
我在调试过程中发现一个很隐蔽的Bug:某个远程敌人在目标超出射程后,会反复向玩家坐标寻路,但玩家其实站在一堵墙后面,而导航路径根本没有变化。由于我没有缓存路径,导致这些敌人每帧都调用寻路查询,白白吃CPU。
解决办法是在敌人脚本里保存一个_last_path_time和_cached_target。只有当target_position变化超过一定距离,或者定时器超时,才触发一次新寻路。相似的场景还有:玩家死亡后,所有敌人的目标变成了 null,此时需要立刻把状态切成IDLE,而不是继续对空位置寻路。
3. 伤害结算与受击反馈:秒死和双倍伤害都出在这里
3.1 hitbox与hurtbox的分层组织
俯视角游戏的战斗本质是Area2D信号碰撞,但每个节点的命名、层级、Layer设置乱了,就会出现“玩家被空气打死”或“子弹穿过敌人”的奇怪现象。
我习惯把战斗组件做成独立的子节点:
HitboxArea2D:表示攻击判定区域,挂在子弹、近战武器、敌人身体上。HurtboxArea2D:表示受伤判定区域,挂在玩家和敌人身上。
命名要带前缀:enemy_hitbox、player_hurtbox、player_hitbox、enemy_hurtbox。这样信号回调里只用判断对方节点的名称前缀,不需要动用 group 遍历。
Layer设计参考如下:
| 节点 | layer | mask | 说明 |
|---|---|---|---|
| 玩家Hitbox | 5 | 3 | 只检测敌人Hurtbox |
| 玩家Hurtbox | 2 | 3 | 接收敌人Hitbox的信号 |
| 敌人Hitbox | 3 | 2 | 只检测玩家Hurtbox |
| 敌人Hurtbox | 4 | 5 | 接收玩家子弹Hitbox |
很多双倍伤害问题的根源,是玩家子弹的Hitbox同时把玩家自己的Hurtbox也检测进去。比如子弹发射瞬间玩家站在子弹生成点,Area2D进入检测时命中了玩家自己,于是扣血。这个问题的排查思路很简单:把玩家Hurtbox的layer设为2,子弹Hitbox的mask只包含敌人Hurtbox的4,不要包含2。同理,敌人Hitbox的mask只包含玩家的Hurtbox。
3.2 同一帧被多个敌人同时攻击:为什么玩家会“瞬间暴毙”
生存类游戏里最典型的“秒杀”场景,不是某个敌人伤害高,而是十个敌人的攻击判定在同一帧叠加到了玩家身上。玩家血量100,敌人接触伤害10,如果三帧内同时进来十个接触信号,玩家瞬间就空了。
最直接的解决办法是给玩家受击加一个短暂的无敌帧:
signal damaged(amount: int) var invincible_timer: float = 0.0 var invincible_duration: float = 0.8 func take_damage(amount: int) -> void: if invincible_timer > 0.0: return invincible_timer = invincible_duration on_hit_flash() health -= amount damaged.emit(amount) if health <= 0.0: die()无敌帧不是万能灵药,它会导致高频低伤敌人变得很弱。如果策划希望敌人靠“数量”造成持续威胁,不要把统一无敌帧设得太长,0.5秒左右刚好,既避免一帧暴毙,又不会让玩家无脑站桩。
3.3 子弹命中同一个敌人两次的排查链路
子弹击穿敌人时,有可能会判定两次伤害:一次是子弹进入敌人Hurtbox时,一次是子弹退出时。如果用的是area_entered和area_exited两个信号都挂回调,就会重复扣血。
我这里建议在子弹脚本中只监听area_entered,并且给子弹加一个processed_targets: Array[RID]或者直接用hit_target_count锁死,一颗子弹最多伤害一个敌人。数组里的RID是物理体唯一ID,比用物体引用更省内存。数组内部用has()判断,命中后push_back(),一帧后直接用queue_free()销毁子弹。
还有一种情况是敌人同时挂了Hitbox和Hurtbox,子弹在碰到敌人Hitbox时,因为mask配置包含敌人Hurtbox,所以一颗子弹同时触发两个敌人体内的逻辑。这仍然要靠Layer/Mask分离来解决,不要试图在业务代码里加各种“是不是同一个敌人”的判断,那会让代码越来越乱。
4. 对象池:换掉instantiate之后同屏500只不卡
4.1 先看实测数据:实例化开销到底有多大
我最初在做压力测试时,往场景里用PackedScene.instantiate()创建敌人,打完后立刻queue_free()。看似正确,但帧率在大量生成瞬间会掉得很猛。
原因有两个:
- 每次
instantiate()都会从头到尾解析一次场景树、注册物理体、初始化资源。 queue_free()不是立刻销毁,它要等到当前帧结束,如果同帧出现大量增删,场景树的构建和销毁会大幅占用CPU。
用Godot自带Debugger里的“网络”帧时间来看,仅实例化阶段的峰值就能吃掉5~8毫秒的帧耗,这还不包括后续的物理碰撞计算。所以大规模同屏敌人一定要依赖对象池。
4.2 一个最小可用的对象池实现
对象池的思想很简单:预先创建一批敌人,藏到地图边缘或隐藏层里,需要时“拿出来”放到指定位置,死亡后“收回去”而不是释放。
下面是我项目里简化的池子,直接挂在一个EnemyManager节点上:
class_name EnemyPool extends Node var _enemy_scene: PackedScene var _available: Array[Node2D] = [] var _active: Array[Node2D] = [] func setup(scene: PackedScene, prewarm_count: int = 100) -> void: _enemy_scene = scene for i in prewarm_count: var enemy := _create_enemy() release(enemy) func _create_enemy() -> Node2D: var enemy: Node2D = _enemy_scene.instantiate() add_child(enemy) enemy.visible = false enemy.set_physics_process(false) return enemy func acquire(spawn_pos: Vector2) -> Node2D: var enemy: Node2D if _available.is_empty(): enemy = _create_enemy() else: enemy = _available.pop_back() _active.append(enemy) enemy.global_position = spawn_pos enemy.visible = true enemy.set_physics_process(true) enemy.reset() return enemy func release(enemy: Node2D) -> void: _active.erase(enemy) _available.append(enemy) enemy.visible = false enemy.set_physics_process(false)核心是那个reset()方法。每个敌人必须实现它,把血量、状态、状态计时器、所有攻击冷却都重置回来。漏掉充填项时,会出现“敌人复活后血量是上次死前的”、“敌人刚出场就在攻击”这种诡异Bug。
4.3 信号重复连接的经典翻车点
对象池和普通创建最大的区别在于:同一个敌人对象会被反复使用。如果你在敌人死亡时把那个敌人身上挂的dead信号与外部管理器_on_enemy_dead连接,那么在acquire()中你可能会重复connect。
比如这样写就会炸:
func acquire(spawn_pos: Vector2) -> Node2D: var enemy := ... enemy.dead.connect(_on_enemy_dead) # 这里不该每次连接每acquire()一次,信号就多连接一次。第一次敌人死亡时回调一次,第二次死亡时回调两次,第三次死亡时回调三次,最终导致对象池回收逻辑执行很多次,敌人位置错乱。
正确做法:池的_create_enemy()里连接一次信号,后续acquire()和release()都不要再动连接关系。reset()里同样不能connect,只能停掉旧的Tween或Timer。
4.4 死亡动画和对象复用:别让视觉表现卡住回收
很多敌人死亡时有爆炸特效或者溶解动画。如果直接visible = false,动画瞬间中断,看起来非常突兀。我的做法是:敌人死亡后停掉AI状态,播放死亡动画,通过动画的finished信号发射dead事件,再由管理器延迟0.5秒调用release()。
注意:AnimationPlayer播放完毕后,如果直接复用而没有手动play("idle")复位,敌人可能会以死亡的最后一帧出现在场上。所以在reset()里必须animation_player.play("idle")、sprite.modulate = Color.WHITE,并把所有碰撞形状的set_deferred("disabled", false)恢复。
粒子和临时节点也不要直接放在敌人节点下。统一用一个DeathEffectPool管理爆炸特效,敌人被回收时特效不跟着隐藏,反而是独立播放完自动回收。这样敌人池和特效池各司其职,不会互相卡住。
5. 用压力测试数据反推设计:帧率不是靠感觉调出来的
5.1 搭建一个一次性可复现的压力测试战场
在写正式玩法前,我建议先搭一个独立测试场景:地面用和正式地图一样的TileMap,玩家放在中心,四周放四个PathFollow2D或Marker2D生成点,用Timer控制生成间隔。场景里加一个白底Label,实时显示:
- 当前帧数
- 活动敌人数量
- 物理帧耗时
- 渲染帧耗时
可以用Engine.get_frames_per_second()看整体帧率,但更关键的是看Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS)和TIME_PROCESS的区别。有时候渲染能维持120,物理已经超了15毫秒,这种情况下画面依然会卡,因为物理步进的抖动会直接影响移动平滑度。
批量生成逻辑类似:
var spawn_count: int = 0 var max_spawn: int = 500 func _on_spawn_timer_timeout() -> void: if spawn_count >= max_spawn: return for i in 10: var enemy := enemy_pool.acquire(spawn_point.position) enemy.target = player spawn_count += 1每次生成10个,间隔0.2秒,可以观察生成瞬间的帧率波动,而不是一次生成500个直接把场景卡死。
5.2 我实际测试后的调参次序
假设测试结果是“100只稳定60帧,300只掉到45帧,500只物理耗时翻倍”,那就要分几步走:
优先排查physics_process里有没有多余的计算。最常见的是每个敌人都在_physics_process里调用move_toward(player)和find_path(),这两件事频率不同。移动频率必须保持在物理帧以保证流畅,但寻路可以经过设计降到每秒两次。把寻路频率降下来之后,300只的帧率一般就能回到55以上。
第二步,减少物理体的数量。每个CharacterBody2D都是一个物理对象,物理碰撞检测随着数量增长呈接近平方级增长。敌人之间如果不做真实碰撞,就把碰撞层去掉,物理性能会有明显提升。如果敌人之间有接触需求,用Area2D的body_entered信号做轻量检测,而不是让所有敌人维持完整物理响应。
第三步,对远离玩家的敌人做“休眠”。在生存游戏里,玩家的可见范围很有限,屏幕外敌人根本不需要完整AI。可以用一个简单方法:每隔0.5秒计算敌人和玩家之间的距离,大于800像素的敌人把状态切成SLEEP,停止_physics_process和粒子动画,只保留最基本的位置休眠。当玩家靠近时再唤醒。这个方案效果立竿见影,500只同屏时实际活动的可能只有100只。
5.3 移动放物理帧还是渲染帧,俯视角游戏里尤其要注意
Godot的_physics_process默认60Hz,_process的调用频率等于画刷新率。对敌人移动,我建议统一放物理帧,避免移动和碰撞检测脱节。如果你为了节省物理计算而把大部分敌人改成渲染帧驱动,它们会与玩家、障碍物产生滑步或穿透。
有一种折中做法:敌人AI决策(状态切换、寻路)放在每0.3秒的Timer里,但move_and_slide依然放在_physics_process中调用。Timer只负责改变state和更新velocity的方向,方向更新后物理帧内保持移动。这个分层既保证帧率,又不会让敌人动作分裂。
5.4 更新数值时的参数口径要统一
我给所有敌人的move_speed、attack_damage、attack_range都做成了@export,但attack_range受到的缩放因子常常被人忽略。如果你的玩家角色和敌人节点整体用了scale(比如2D摄像机缩放加UI缩放),Area2D的半径判定和其他代码里的距离判断会不一致。建议所有范围、距离、速度都以“世界坐标”为准,不在普通逻辑里读屏幕像素。否则你会在后续调平衡时发现“为什么玩家离敌人明明很近了,敌人还是不开枪”。
我把这个检查也放进了压力测试清单:每加一个新敌人类型,先看它在测试场景里同屏200只时的表现,再谈玩法。如果200只时一个新的远程敌人已经让它持续低于50帧,那说明它每帧的计算量超标,该砍的不是数值,而是逻辑本身。
回头看整个过程,最难的部分不是某个单独的技术点,而是如何把寻路、物理、视觉、对象池组合在一起还保持流畅。现在我的项目已经把敌人AI中的导航查询压到了每秒两次,对象池预创建了200个敌人,远离玩家的敌人会立刻休眠。实测500只同屏时帧率稳定在55左右,玩家贴近怪物堆时偶有波动,但整个方向盘没有出现“啪一下就卡死”的体验。
最后再分享一个个人习惯:每次写新的敌人类型之前,我会先在压力测试场景里把它的“性能预算”记下来——每只敌人每一帧允许用多少毫秒的物理耗时、多少次导航查询、多少个节点。这个预算一旦超了,就不会让它进正式玩法。生存游戏的火爆往往靠的是屏幕上密集的敌群,但能留住玩家的,永远是稳定不崩溃的帧率。你早晚会发现,在Godot里真正考验人的不是写下一个功能,而是让几十上百个功能在一个场景里彼此不拖后腿。