ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Godot 2D俯视角生存游戏:敌人系统性能优化,从状态机到对象池的实战指南

Godot 2D俯视角生存游戏:敌人系统性能优化,从状态机到对象池的实战指南 做2D俯视角射击生存游戏时最能让项目一夜之间从“能玩”变成“幻灯片”的就是敌人数量。拿Godot写敌人AI、批量刷新、伤害结算每一步看起来都有现成接口可真把几百个敌人同时丢进场景里才会意识到每个看似正常的决策背后都藏着性能开销。这篇系列二的第十五篇我想集中聊一聊2D俯视角生存类游戏中最核心的敌人系统状态机怎么拆、寻路到底要不要开、伤害结算为什么总把玩家秒死、以及怎么用对象池让同屏500只怪跑起来不卡。适合这篇文章的场景很明确你已经能用Godot做一个会移动、会射击的玩家角色如果还没有建议先回头把系列里移动和弹幕那几篇看一遍现在想让场上同时刷出大量敌人并且维持稳定的帧率。我会按自己实际调试的顺序来讲把每个坑、每段排查过程都展开方便你直接抄到自己的项目里。1. 敌人行为状态机别让每个敌人都顶着一整套“大脑”1.1 从三类基础敌人拆分设计俯视角生存游戏里的敌人虽然千奇百怪但底层行为基本能归纳成几类无脑冲向玩家的近战、保持距离点射的远程、以及血厚带技能的精英。不要一上来就写一个万能敌人脚本而是把公共逻辑抽到基类里再让子类覆盖移动和攻击策略。我自己常用的分层是这样的近战敌人Chaser速度中等碰撞伤害靠近玩家后扑咬。远程敌人Shooter与玩家保持固定距离间歇性射出子弹。精英敌人Elite血厚、攻击频次低但伤害高通常会带一个冲锋或范围爆发技能。先做一个enemy_base.gd把血量、移动速度、攻击冷却、当前状态都放进去。子类只需要改_setup_stats()和_attack_behavior()这两个函数剩下的状态切换逻辑复用同一套。这样后面新增敌人类型时不会把项目变成一锅粥。用一个表格来区分三种敌人的核心参数这样后续调数值时也方便类型速度血量攻击方式攻击范围特殊行为近战120~18030~50接触伤害32px无远程80~11020~30子弹300~400px保持距离精英90~130300~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设计参考如下:节点layermask说明玩家Hitbox53只检测敌人Hurtbox玩家Hurtbox23接收敌人Hitbox的信号敌人Hitbox32只检测玩家Hurtbox敌人Hurtbox45接收玩家子弹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里真正考验人的不是写下一个功能而是让几十上百个功能在一个场景里彼此不拖后腿。
返回列表