做游戏开发这几年,我越来越感觉到一个事实:大部分新手写敌人AI,都是从一长串if和else开始的。一开始确实爽,逻辑直接,跑起来也正常。但敌人一多、行为一多,这种写法就成了灾难现场——状态穿插、逻辑耦合、改一处崩三处。今天这篇练习笔记,就是拿我在Godot里做的一个“状态机管理的敌人”作为例子,把整套设计思路、代码结构、踩坑记录都摊开来说。这篇东西适合刚学Godot不久、想认真做敌人AI但不知道怎么组织的朋友,也适合那些已经写了几个敌人却总觉得代码乱成一团的开发者,希望能给你一点参考。
1. 为什么敌人AI要上状态机
1.1 先聊聊没有状态机的日子
我见过太多同学习惯用变量加判断控制敌人行为,比如搞一个is_attacking、is_patroling这种布尔值,然后每帧去判断“要不要切换行为”。说实话,敌人在只有两三个行为的时候,这种写法勉强能撑住,但一旦敌人有待机、巡逻、追击、攻击、受击、死亡这一整套行为,问题就全暴露出来了。
首先是代码耦合问题。所有行为逻辑全挤在一个_physics_process里,几百行代码堆一起,光是找“攻击判定写在哪”都要翻半天。其次是状态切换混乱。多个布尔值互相影响,随时可能同时出现“正在攻击但又在追击”这种逻辑错误。更麻烦的是,每个行为需要的“进入条件”和“退出条件”没有清晰的管理方式,改一个行为就要小心翼翼检查所有分支,生怕弄坏别的逻辑。
我之前做过一个简单的巡逻敌人,一开始只有巡逻和追击,写起来确实快。等后来加上了受击反馈和死亡流程,我发现原本两百行的脚本膨胀到了五百多行,而且几乎到了“不敢改”的地步。你改一个受击的位移参数,追击逻辑可能就跟着出问题;你想调整巡逻范围,攻击判定又意外受影响。整个代码像一堆纠缠不清的毛线球。
1.2 状态机到底解决了什么问题
状态机的核心思想很简单:把敌人的每一种行为定义为一个独立状态,并且规定好“什么条件下从哪个状态切换到哪个状态”。敌人的AI本质上是“当前处于什么行为状态”加上“当前状态在做什么”这两个问题的答案。状态机把这两个问题彻底解耦了。
具体来说,状态机带来了三个很实际的好处。
逻辑内聚。每个状态只需要关心自己内部的事,巡逻不用管攻击的伤害数值,攻击也不用管巡逻的路径计算。想调攻击判定,直接改攻击状态里的代码就行,其他状态完全不受影响。
切换清晰。状态之间只能通过明确定义的过渡条件相互转换,比如“玩家进入视野半径内”是追击的进入条件,“丢失玩家视野超过3秒”是追击的退出条件。这种写法人眼一看就懂,排查逻辑错误非常方便。
扩展方便。你想给敌人加一个新行为,比如“呼叫同伴”或者“狂暴化”,只需要新增一个状态类,再定义几条新过渡规则,完全不用动已有状态的逻辑。对于想长期迭代的项目来说,这是最值钱的好处。
1.3 什么时候不该用状态机
说句实在话,状态机不是银弹。如果敌人只有“站着不动”和“看见玩家就冲过来”两种行为,你上状态机反而有点杀鸡用牛刀。还有那种全局行为高度耦合的Boss,比如“血量低于50%时触发的全屏弹幕阶段”其实本身就带状态切换,用状态机管理反而很合适,但那种“每个技能都互相穿插、阶段之间层层叠叠”的战斗逻辑,单纯的状态机也不够,最好配合行为树或者任务队列。
我一直觉得,状态机最适合的是“行为边界清晰、过渡条件明确”的常规敌人,比如巡逻兵、近战怪、远程小怪这种。这也是我绝大多数项目的首选方案。如果你在做的是策略型AI,那可能行为树更适合;如果你做的是复杂Boss战,那可以考虑状态机加行为树的混用。但作为第一课,状态机是必须练扎实的基础功。
2. 状态机的几种实现方式,哪种适合你
2.1 最朴素的枚举分支法
很多人刚接触状态机,其实是这么写的:定义enum State { IDLE, PATROL, CHASE },然后_physics_process里套一个match或者switch,每个分支里写对应状态的逻辑,再加上一些条件判断做状态切换。
这种写法胜在直观、好理解,代码量也少。对于只有两三个状态的敌人,或者你想快速验证一个玩法原型,它确实够用。但问题也很明显:状态一多,match分支越来越长,状态间共享的数据和逻辑还是会挤在同一个脚本里。本质上只是把一堆if整理成了好看的match,耦合程度并没有降低多少。
我不会说这种写法一无是处,它的确可以让新手先理解“状态切换”是怎么回事。但作为长期项目的主力架构,我不推荐。因为迟早有一天你得把它重构掉,而重构总是伴随着风险。
2.2 用类对象封装状态
这是我个人比较推崇的方式。核心思路是:为每种状态创建一个类,比如IdleState、PatrolState、ChaseState,它们继承同一个基类EnemyState。基类里定义了生命周期方法,比如enter()、exit()、physics_update()。然后敌人本体作为上下文,保存一个“当前状态”的引用,每帧把控制权交给当前状态。
这种做法的好处是把每个状态彻底独立出来了。每个状态有自己的字段、自己的逻辑、自己的动画触发,互不干扰。而且基类保证了统一的接口,你不管新增多少状态,敌人本体的代码基本不用改,只要切换时传参正确就行。
代价是需要多写一些样板代码。一个状态一个脚本,文件数量会变多,刚开始可能觉得麻烦。但项目一大,你会发现这种“麻烦”是值得的。文件多不代表复杂,清晰的结构才是能撑住复杂逻辑的根基。
2.3 基于节点的状态机与图形化扩展
Godot本身有场景树结构,所以也有一种做法:把每个状态做成一个节点,父节点作为状态机容器,运行时通过添加/移除子节点来控制当前状态。这种方案能结合编辑器来可视化地查看状态切换关系,在调试时特别爽,你能直接在编辑器里看到当前激活的是哪个状态节点。
但我的经验是,如果你没有足够强的需求驱动,节点化状态机的维护成本反而更高。为了状态的通信,你得设置各种信号连接、节点路径引用,代码里到处都是get_parent()或者@export标记的节点引用,代码的健壮性和可读性都会打折扣。尤其是新手,很容易被节点搞晕,反而不如手动切换状态类来得直观。
我的建议是:先从类对象状态机入手,把基础概念吃透。等你对状态机已经很熟练,再根据需要决定是否上节点化或者用现成的状态机插件。这跟学编程一个道理,先手写排序,再用别人封装好的排序函数,你才能理解背后的设计权衡。
2.4 我做选择时的具体考量
在做这个第17节的敌人时,我最终选了类对象封装的方式,原因主要有三点。
第一,这个敌人虽然状态不算特别多,但每个状态内部的逻辑并不简单,比如巡逻要处理路径点往返,攻击要处理前摇和后摇,受击要处理硬直和击退。用独立类把逻辑隔离,每个文件的篇幅就很可控、美观。
第二,状态之间的过渡条件比较多,而且带参数。比如追击状态里有“丢失玩家后重置巡逻起始点”这种逻辑,用类可以很方便地传递数据。如果是枚举加match,这些参数基本上全得挂在敌人本体上,字段多了又会乱。
第三,我希望这套状态机代码能复用到后续其他敌人身上。用基类加派生类的结构,天然就适合做复用,不同敌人只需要调整参数和状态实现。你做一个独立的敌人花一小时,做五个不同敌人可能只需要两个小时,因为架构是通用的。
当然,我不否认其他方式的适用场景。枚举分支法适合超级小的原型,节点状态机适合团队协作、需要编辑器可视化调试的场景。但如果你只想把代码写得清爽、易维护,类对象封装是我比较推荐的平衡点。
3. 搭建敌人状态机的具体步骤
3.1 敌人控制器脚本怎么设计
既然决定用类对象状态机,第一步就是搭敌人本体的骨架。这个脚本不写具体行为逻辑,只负责三件事:持有当前状态、提供切换状态的方法、每帧把更新权交给当前状态。
我一般会这样设计:
extends CharacterBody2D var current_state: EnemyState func _physics_process(delta): if current_state: current_state.physics_update(delta) func change_state(new_state: EnemyState) -> void: if current_state: current_state.exit() current_state = new_state current_state.enter()这个写法看着简单,却是整套状态机能够运转的根基。physics_update是胜负手:它保证当前状态能每帧拿到delta并执行自己的逻辑。change_state负责规范状态切换的顺序,先退出旧状态,再进入新状态,顺序错了会引发各种奇怪的资源错误。
你可能会问,为什么不用_process而用_physics_process?因为敌人是CharacterBody2D,它的移动、碰撞、检测,都应该统一在物理帧里跑,才能和move_and_slide()之类的函数正确配合。如果用_process,帧率不稳定时敌人的移动判定会忽快忽慢,说实话这在敌人AI里是很膈应的体验。
3.2 状态基类的骨架代码
接下来是状态基类。这是我最看重的一个文件,因为所有状态的公共接口都在这里定义,它决定了你未来写状态时的舒适度。
class_name EnemyState extends RefCounted var enemy: CharacterBody2D var state_machine: Node func _init(enemy: CharacterBody2D, state_machine: Node) -> void: self.enemy = enemy self.state_machine = state_machine func enter() -> void: pass func exit() -> void: pass func physics_update(delta: float) -> void: pass我把状态基类设计成RefCounted(引用计数对象),而不是Node或者Resource。好处是创建和销毁都非常轻量,不需要进入场景树,内存管理也简单。敌人创建状态时直接IdleState.new(self, state_machine)就行,不需要add_child,也不用担心节点释放的问题。
为什么把enemy和state_machine都传进来?因为状态内部经常需要读取敌人属性,比如速度、朝向、动画状态,也需要通过状态机切换状态。把这两个引用在构造函数里传给每个状态,状态内部就能自由调用了。如果你在状态内部频繁get_parent(),多半说明你的状态机设计有问题。
3.3 定义敌人需要哪些状态
我这个敌人是典型的近战巡逻兵,一共设计了六个状态,你可以根据自己敌人的需求增删。
IdleState(待机):站在巡逻点不动,偶尔东张西望,看到玩家进入视野就转追击。PatrolState(巡逻):在设定的路径点之间来回走动,走到终点就停一下再回头。ChaseState(追击):发现玩家后开始追,追击过程中如果玩家跑远了,超时后回到巡逻。AttackState(攻击):进入攻击范围后发动近战攻击,有前摇和恢复,攻击完回到追击。HurtState(受击):被玩家击中后进入硬直,播放受击动画并受到小幅击退,硬直结束回追击。DeathState(死亡):血量归零后进入,播放死亡动画,禁用碰撞体,然后释放敌人节点。
这六个状态覆盖了一个常规敌人从出生到死亡的全过程。你别小看这种看似普通的划分,它其实是在“敌人行为的生命周期”这个维度上做了完整建模,每一个状态都对应敌人某个阶段的明确职责。
定义完状态,下一步就是写初始化函数,把初始状态设为IdleState,然后交给状态机循环驱动。注意,状态切换时机不一定都在physics_update内部,你完全可以在外部条件满足时直接调用change_state。关键是保证切换入口唯一,也就是统一走change_state,这样调试状态异常时你就只需要盯这一个入口。
3.4 初始化状态机和状态实例
在敌人的_ready里,我通常会这样初始化:
func _ready() -> void: state_machine = Node.new() state_machine.name = "StateMachine" add_child(state_machine) var states = { "idle": IdleState.new(self, state_machine), "patrol": PatrolState.new(self, state_machine), "chase": ChaseState.new(self, state_machine), "attack": AttackState.new(self, state_machine), "hurt": HurtState.new(self, state_machine), "death": DeathState.new(self, state_machine), } change_state(states["idle"])这里我用一个字典把状态实例都存起来,方便后续按名字取用。切换状态时就可以这样写:
state_machine.change_state(states["chase"])这样写的好处是,状态之间互相切换时,不需要知道对方的具体实例,只要知道字符串名字就行,代码可读性也高。你还能顺手在上面加一层日志,每次切换就打印一条带节点路径的状态变化,这对排查AI“为什么没反应”之类的问题特别有用。
4. 状态内部的实现细节
4.1 待机与巡逻状态怎么写
待机状态的逻辑很简单:进入时播放待机动画,设置速度为0,然后每帧检查视野范围内有没有玩家。注意,这种检查我建议用节点组加Area2D来做,而不是写代码遍历所有节点,性能更好也更容易调试。
我给你看一个带简单时间行为的待机参考:
func enter() -> void: enemy.animation_player.play("idle") enemy.velocity = Vector2.ZERO wait_time = randf_range(0.5, 2.0) func physics_update(delta: float) -> void: wait_time -= delta if wait_time <= 0: state_machine.change_state(states["patrol"])待机到巡逻,并不一定需要“看到玩家才走”。加一个随机等待时间,待机结束后自动恢复巡逻,会让敌人看起来更自然。你也可以在做待机时把敌人在原地旋转头部的动画播出来,视觉上更生动。
巡逻状态则是重点利用路径点移动,我的实现通常是:
func enter() -> void: enemy.animation_player.play("walk") next_point = patrol_points[0] func physics_update(delta: float) -> void: var direction = (next_point - enemy.global_position).normalized() enemy.velocity = direction * patrol_speed enemy.move_and_slide() if enemy.global_position.distance_to(next_point) < 0.5: index = wrapi(index + 1, 0, patrol_points.size()) next_point = patrol_points[index]这段代码有几个容易踩坑的地方。第一,路径点距离判断不要用相等判断,浮点数永远不会精准相等,必须用“小于一个阈值”来判断到达。第二,wrapi会自动把索引回卷,走到最后一个点后自动回到第一个点,省得自己写if index >= size。第三,敌人的朝向问题:如果只用velocity的方向控制,敌人很可能会贴脸走路时面朝跑偏,所以在移动一段后要更新敌人的朝向,比如enemy.sprite.flip_h或者用look_at配合角度限制。
你可能还关心一个细节:巡逻时进入追击的判定写在哪个状态里?我一般会写两个入口,一个是在敌人主脚本里挂一个Area2D的检测,玩家进入视野信号触发时,主脚本直接调用状态机切换到追击;另一个是巡逻状态每帧也检查一次,防止漏掉信号情况。这两者逻辑不要重复写,把“视野检测”抽成一个函数放在主脚本里,状态只负责调这个函数,这样信号驱动和轮询驱动就能统一了。
4.2 追击与攻击状态的战场核心
追击状态的难点在处理“什么时候结束追击”。如果玩家跑出了视野范围,或者距离超过一个最大值,并且持续了一段时间,就应该放弃追击退回巡逻。这个“持续一段时间”很重要,否则玩家在视野边界晃一下,敌人就会不断在追击和待机之间跳来跳去,看起来像个神经病。
追击状态需要比较稳定的转向逻辑。我这里用了lerp_angle来做角度的平滑转向:
func physics_update(delta: float) -> void: var direction_to_player = (player.global_position - enemy.global_position).normalized() var target_angle = direction_to_player.angle() enemy.rotation = lerp_angle(enemy.rotation, target_angle, delta * 5.0) enemy.velocity = direction_to_player * chase_speed enemy.move_and_slide() if enemy.global_position.distance_to(player.global_position) < attack_range: state_machine.change_state(states["attack"])注意lerp_angle的第三个参数是插值权重,一般不会直接用delta,而是delta * 某个系数。系数大小决定转向速度,太小显得敌人迟钝,太大会变成瞬转向。这个值需要你反复试手感,调整到自己满意的“敌味”。
追击结束时,需要处理一个实际问题:敌人放弃追击后,应该回到哪个巡逻点?如果回到原来的起始巡逻点,敌人会走回头路,这可能让玩家觉得AI太“死板”。我的经验是让敌人回到离自己最近的那个巡逻点。实现不复杂,遍历巡逻点列表找距离最小者作为目标即可,这样敌人的行为显得更自然。
攻击状态是敌人状态里最容易犯错的。攻击一般分前摇、判定、后摇三个阶段。如果把攻击做成一个“瞬发判定”,玩家会觉得很突兀,也感受不到被击中的反馈节奏。我会用一个简单的计时器控制三个阶段:
func enter() -> void: enemy.animation_player.play("attack_start") timer = 0.0 has_dealt_damage = false func physics_update(delta: float) -> void: timer += delta if timer >= 0.3 and not has_dealt_damage: apply_damage_to_player() has_dealt_damage = true enemy.animation_player.play("attack_end") elif timer >= 0.8: state_machine.change_state(states["chase"])前摇0.3秒,判定一次,后摇0.5秒,然后回追击。这只是参考数值,真正的节奏取决于你的攻击动画长度。你需要打开动画导入器,看看攻击动画实际多少帧,把前摇时长跟动画的关键帧对齐。不然会出现攻击动画还没挥到玩家,伤害就已经算了的尴尬场面。
4.3 受击与死亡状态怎么处理
受击状态的触发,一般是玩家攻击命中敌人时,由敌人主脚本接收信号,调用状态机的切换。受击状态的内部逻辑是:播放受击动画,给敌人一个击退向量,在硬直时间内降低速度或禁用移动,然后结束硬直返回追击。
这个状态有个容易被忽略的细节:硬直期间敌人绝对不能再被触发切换成追击或攻击,否则会吞掉受击反馈。你可以在受击状态里加一个can_be_interrupted的判断,或者干脆在受到伤害时先检查啊当前状态是不是受击/死亡,是的话就忽略新的伤害请求,避免多个伤害同时叠在敌人身上导致动画错乱。
死亡状态是整个状态机的终点。进入后要清理敌人逻辑,把速度置零、关闭碰撞检测、播放死亡动画,还要注意敌人死亡后不能脱离状态机控制,否则可能会出现“敌人死了还在移动”的诡异画面。通常我会在死亡动画播放完毕后发出一个信号,让敌人节点queue_free(),或者等几秒后淡出再释放。
写死亡状态时最烦的是“动画播完自动释放”这个事。我推荐用animation_player.animation_finished信号,不要在状态机里写死定时器。如果简单用计时器,死亡动画长度一变,你忘记改计时器,敌人就会出现“尸体站在原地反复播放最后一帧”的傻样。
5. 状态切来切去,踩过哪些坑
5.1 状态重复进入和退出
这是状态机最常见的问题。举个例子,追击状态下玩家进入攻击范围,你调用change_state(attack),但此时敌人的player_in_range信号又触发了一次,于是再次调用change_state(attack)。攻击状态的enter()被重复执行,动画被重新播放,计时器重置,敌人就会出现“一直重复前摇但永远不出伤害”的问题。
解决方式有两种。第一种是在change_state里加判断:如果新状态和当前状态相同,直接返回,不做切换。这个办法简单粗暴,能把大部分重复调用挡在外面。第二种是状态内部自己保证幂等,也就是同一个enter()不管调用几次,效果都一样。实践中我两个都会做,但第一种是兜底,第二种更像是代码习惯。
我实际写状态机时,还会在change_state里加一行调试输出,打印从A状态切换到B状态。当你看到日志里连续出现“从attack切换到attack”,那就说明有重复调用的问题,顺着信号连接查一遍就能找到来源。
5.2 动画状态和逻辑状态不对齐
动画播放和状态切换是两条线,理论上它们应该是同步的,但实际开发中经常会错位。比如你逻辑上已经切到追击状态,但追击的enter()里忘了播放动画,敌人就会保持待机姿势在地上滑行,非常出戏。
我的解决办法是,把“进入状态时播放的动画”强制写在enter()的开头几行,并加注释提醒自己。同时,每个状态内部我自己维护了一套检查,比如if enemy.animation_player.current_animation != "chase": play("chase"),防止动画被外部因素打断。
动画对齐还有一个特殊情况,就是动画播放速度跟逻辑时长不一致。比如受击硬直1秒,动画播放需要1.2秒,那敌人逻辑上已经恢复过来了,但看起来还在受击姿态。遇到这种情况,要么硬直时间等于动画长度,要么硬直结束后主动play("idle")来强制恢复默认姿态。选哪个取决于你想不想做复杂的动画融合,简单项目直接用后者就行。
5.3 状态切换时的数据交接
状态虽然各自独立,但它们之间经常需要传递数据。比如受击状态结束后,敌人需要回到追击状态,并且敌人当前的血量、朝向、惩罚状态都要保留。这些数据如果不提前规划,很容易被状态类的enter()重置掉。
我的做法是把“共享数据”放在敌人主脚本里,比如hp、speed_modifier、is_poisoned这些字段,状态类只读不写,或者通过敌人主脚本来修改。状态类内部的局部数据该重置就重置,但主脚本的数据要谨慎处理,不能在切换状态时误清空。
有个经典bug是:受击状态里把敌人速度设成了0,硬直结束后回到追击状态,追击的enter()忘了把速度重置成追击速度,于是敌人就站在原地“正确播放着追击动画”却不动。这类问题排查起来特别容易忽略,因为动画、状态都对,就是数字不对。我的习惯是每个状态的enter()都显式设置它需要的基础属性,比如enemy.velocity = patrol_speed,自己管自己的,不去依赖上个状态的残留值。
5.4 敌人状态机与物理碰撞的互相影响
CharacterBody2D有一个特性:move_and_slide()会根据碰撞结果调整velocity。如果你在状态切换的瞬间,恰好有物体碰撞了敌人,那么velocity可能会被引擎改动。如果状态机的代码里使用了velocity做条件判断,很容易在某个帧出现微妙的逻辑问题。
我的应对方案是:每个状态进入时,先强制重置enemy.velocity = Vector2.ZERO,再按状态需要设置速度。这样可以避免上一个物理帧残留的velocity影响当前状态的判断。同时,受击击退和普通移动的叠加也要注意,我一般给击退单独做一个计时器,在击退期间移动速度减半或者禁用,等击退结束后再恢复正常的移动逻辑,这样手感比较可控。
碰撞层设置也别忽略。敌人的视野检测 Area2D、攻击范围 Area2D、受击碰撞体,它们最好分布在不同的碰撞层,这样才不会互相干扰。我自己早期就遇到过,攻击范围的Area2D和视野范围的Area2D设在同一层,导致敌人刚进入视野范围就触发攻击判定,玩家还没靠近就被隔空打了一巴掌。
5.5 多个敌人实例时的状态冲突
当场景里有多个敌人,每个敌人都是同一个敌人场景的实例,它们各自拥有自己的状态机和状态对象。正常情况下互不干扰,但要注意不能把状态对象声明成脚本的静态变量或者场景的共享属性,否则一个敌人的状态切换会影响所有敌人。
如果你用了自定义资源存储状态数据,也要小心资源被多个敌人共享的情况。Godot的资源默认是共享的,如果状态里修改了资源的字段,其他敌人实例都会受到波及。我通常会为每个敌人实例在_ready里单独实例化一份状态资源,保证数据隔离。
有几回我遇到“两个敌人在同一帧一起切换状态,其中一只轨迹错乱”的怪问题,最后定位是它们共用了一个Resource里的速度字段。改成实例化后问题迎刃而解。这也算是Godot资源管理的一个经典坑,跟状态机不一定有关系,但遇到类似情况值得留意。
6. 把状态机抽成通用框架的经验
6.1 扩展方向:敌人变种和多状态
当你写完一个敌人,下一件事往往是做第二、第三个敌人。你自然会发现状态机的好处:你可以复制一套状态类,然后通过参数调整出不同的敌人。比如把“巡逻速度”“视野半径”“攻击伤害”改成@export字段,丢到编辑器里调,很快就能做出“速度慢但攻击高”的高防兵、“跑得快但血量低”的刺客兵。
状态类的通用性越强,复用的成本就越低。我建议把状态类里的硬编码数字全部改成用敌人主脚本的属性读取,比如:enemy.patrol_speed、enemy.attack_damage。主脚本里用@export暴露这些参数,这样同一个状态机代码,可以驱动多种不同性格的敌人,只要在编辑器里调整参数就行。
偷懒一点的做法是把状态类作为敌人场景子目录下的独立脚本文件,用 preload 引用。这样做的好处是,你改了一个状态类的代码,所有使用它的敌人场景都会自动生效,不用每个敌人场景手动同步。但也因为共享,你改成某个敌人的专属逻辑时,可能影响其他敌人。对此我的建议是:冲突的部分放到子类里覆写,不要在一个状态里塞满太多敌人特例。
6.2 加入动画与音效设计规范
状态机管住了逻辑,动画和音效的触发就应该有统一规范。我自己的习惯是,在enter()里统一触发进入动画和音效,在exit()里统一停止或者清理音效。这样动画、音效始终跟逻辑状态保持一致,不会出现“逻辑已经死了,音效还在喘气”的荒谬情况。
具体到音效,每个状态进入时可以播放一次入场音效,比如追击开始时吼一声,受击时闷哼一声,攻击挥击时擦一声。死亡时播放死亡音效后,记得把播放器断开,避免死亡后继续触发新的音效。
如果敌人有很多通用逻辑,比如闪白、受击特效、掉落物品,这些可以写成独立的辅助节点或者自动加载脚本,状态只负责调用,不要在状态类里写满特效代码。状态类的代码应该聚焦在“当前行为”上,而不是承担所有系统功能。
6.3 调试神器:可视化当前状态
状态机的一大痛点是你不知道此刻敌人到底处于什么状态,尤其是多个敌人一起跑的时候。我强烈建议你在敌人主脚本上挂一个自定义调试面板,或者干脆用一个简单的 UI 标签,实时显示当前状态名。Godot 的编辑器和远程调试列表都能看到这个标签,但你在运行时想快速查看每个敌人的状态,加一个 UI 显示是最省事的。
我在项目里会给每个敌人头顶加一个漂浮的Label3D,里面写当前状态名。虽然看起来不精致,但是调AI行为时极其好用,你能直接看到“这个敌人在 chase 却在往左走”这种异常,立刻猜到是逻辑问题。如果你觉得头顶挂标签太丑,也可以在编辑器里的“远程场景树”面板里挑中敌人节点,动态观察它的current_state属性,效果类似。
调试时输出状态切换日志也很重要。你可以做个通用工具函数,开关控制,比如:
func change_state(new_state: EnemyState) -> void: if debug_enabled: print("[%s] %s -> %s" % [enemy.name, current_state.name, new_state.name]) ...日志里的状态名必须是可读的字符串,别用枚举数字。每次状态切换都打印出来,配合Label3D一起看,大部分AI问题都能快速定位。
6.4 性能优化心得
状态机的性能开销主要来自状态实例的创建和销毁。我的建议是,在_ready时一次性创建所有状态实例,之后切换只更新引用,不反复new。这样最稳,也省掉了大量不必要的对象分配。
状态内部的physics_update每帧执行,注意不要在状态内部每帧重复计算不变化的量。比如玩家引用、路径点数组长度、碰撞层掩码,这些准备好后在enter()里缓存或者初始化一次就够了。如果你每个状态每帧都在找节点,性能会随场景复杂度直线下降。
对于很多同屏敌人,还可以考虑用状态机的“帧预算”来做简单的性能分流:距离玩家远的敌人降低逻辑频率,比如每隔几帧才执行一次physics_update。但这属于优化进阶,建议先把基础状态机跑通,确认没有逻辑问题后再上这些手段。
7. 复盘我的实践与经验教训
做这个第17节练习时,我最深的体会有三点:状态机的核心价值不在于“用了某种高级设计模式”,而在于帮你把“每帧在干嘛”这个问题拆解成清晰的状态转换。你用类、节点还是插件,都只是形式,思想的根本是“当前行为”和“行为切换”必须分开管理。
第二个体会是:状态切换时的数据交接,决定你后面要排查多少宽度问题。如果你在写第一个敌人时没有养成每个状态enter()里显式初始化基础属性的习惯,后续加状态越多,越容易遇到“上个状态残留数据污染下个状态”的诡异情况。这种bug耗时最长,因为你往往要么先怀疑动画、要么先怀疑物理,根本想不到是数据残留在作怪。
第三个体会是:调试工具要提前做。花半小时加一个状态显示标签和切换日志,能帮你省下后面很多个半小时的排查时间。我见过太多人状态机写完了,结果遇到敌人行为诡异,只能凭感觉猜是哪里的问题,效率极低。有个可视化调试界面,问题会变得非常直观。
最后,我建议你在做练习时,不要局限于我给的这六个状态。你可以给敌人加一个“警戒状态”,玩家进入视野但不追击,只是瞪着你;也可以加一个“呼叫状态”,敌人发现玩家后向附近的同伴发信号。扩展状态机这件事本身,就是理解状态机最好的方式。
实战是最好的老师,趁着手感还在,你大可以拿这个状态机框架去改造你的现有敌人,或者干脆重新写一个敌人,看看状态机帮你省下了多少功夫。做完你就知道,为什么那么多成熟游戏都离不开状态机。