状态机管理的敌人从零搭建一套不打架的AI行为逻辑第一次在 Godot 里写敌人 AI很多人会直接往_physics_process里塞一堆if。一开始确实挺爽的敌人会跑、会追、会攻击感觉什么都做出来了。但等你想给敌人加一个受击硬直或者让它巡逻到墙角转身时麻烦就来了——每个if都在互相抢逻辑攻击还没结束就开始追击受伤硬直刚触发又被巡逻覆盖整个行为乱成一锅粥。这时候你需要的不是更多if而是状态机。先明确一下这篇文章的定位这是一篇面向已经有 Godot 基础操作经验、能写简单脚本、但还没系统接触过 AI 状态管理的读者的实战拆解。我会用一个人形敌人的实例从状态定义、代码结构、切换逻辑到调试技巧完整讲清楚怎么在 Godot 4 里用状态机把敌人 AI 管得服服帖帖。文章里用到的所有代码基于 Godot 4.x 的 GDScript。1. 状态机到底在解决什么问题你仔细观察一个敌人 AI会发现它的行为其实是分段的在安全距离内站着不动看到玩家后进入追击靠近后挥拳攻击被打中时产生硬直血量归零后倒地。每一段行为之间是有明确边界的边界条件就是玩家距离、自身血量这些可量化因素。状态机做的事情就是把这些一段段的行为变成状态然后规定状态之间哪些切换是合法的、哪些是禁止的。比如追击状态可以切换到攻击状态但不能直接跳到死亡状态——你得先经过受伤或者血量归零的检测。很多人觉得状态机是个复杂的概念其实它就是个带约束的 switch 语句。往大了说只要你把当前状态存在一个变量里然后根据这个变量决定执行哪段逻辑你就在用状态机。往小了说这正是敌人 AI 的骨架。我在实际项目里体会最深的一点是状态机不是用来让敌人变聪明的而是用来让敌人的行为可控。没有状态机的时候敌人显得特别神经质有了状态机之后哪怕逻辑写得粗糙一些行为至少是稳定的。对这个阶段的练习来说稳定比聪明重要得多。2. 敌人状态该怎么拆六状态模型在设计具体代码之前先花时间把状态梳理清楚。我见过太多人一上来就写代码结果写着写着发现少了一个状态又要重构。状态拆分的功夫花在前面后面能省下一整晚的调错时间。对一个普通的近战敌人我给出一套经过验证的六状态模型状态触发条件行为表现退出条件IDLE 待机出生、巡逻结束、丢失玩家站立观察、偶尔张望发现玩家或到达巡逻点PATROL 巡逻待机超时或按路线行动沿预设路径点移动发现玩家进入追击CHASE 追击发现玩家且在警戒范围内朝向玩家移动、保持距离丢失玩家超过X秒、到达攻击距离ATTACK 攻击追击进入攻击范围播放攻击动画、造成伤害攻击动作完成、玩家脱离范围HURT 受伤受到伤害且血量未归零播放受击动画、短暂硬直硬直动画结束DEATH 死亡血量归零播放死亡动画、禁用碰撞体无终态这套状态模型的核心思路是角色永远且只能处于一个状态。有人会问能不能同时追击和攻击在严格的状态机里不行你要么在追击、要么在攻击。但追击过程中可以攻击吗可以状态切过去就行。这是状态机的一个基本规则保证单一职责。需要提醒的是这里的状态数量不是固定的。你的敌人如果需要施法就用七个状态需要逃跑就合并进出。状态拆分的原则只有两条行为模式差异足够大时要拆开切换频率过快时要考虑合并。3. 实现方案选型枚举 switch 还是节点状态机网上搜 Godot 状态机会看到五花八门的做法。有用AnimationTree的状态机节点硬拗的有搞状态机插件的还有基于节点的状态机框架。选型的问题上我的经验很直接——先别管框架用最朴素的方式跑通等理解了再考虑封装。现阶段最适合练习和搞懂本质的是枚举 match 分支的方式。GDScript 里的match和C的switch类似结构清楚代码量适中调试时打日志也方便。等你的项目真的有十几个状态、状态逻辑重复度很高的时候再切到每个状态一个节点类的做法不迟。下面用代码说明枚举方式的写法。敌人根节点脚本暂定命名为enemy.gdextends CharacterBody2D enum State { IDLE, PATROL, CHASE, ATTACK, HURT, DEATH } var current_state: State State.IDLE然后在_physics_process里根据状态分支执行func _physics_process(delta): match current_state: State.IDLE: _process_idle(delta) State.PATROL: _process_patrol(delta) State.CHASE: _process_chase(delta) State.ATTACK: _process_attack(delta) State.HURT: _process_hurt(delta) State.DEATH: _process_death(delta)每个_process_xxx函数只负责处理自己这个状态下的行为逻辑。状态切换的地方单独做一个函数func change_state(new_state: State): # 退出旧状态 _exit_old_state(current_state) # 记录新状态 current_state new_state # 进入新状态 _enter_new_state(new_state)这个change_state函数是状态机的核心枢纽所有状态切换都必须经过它。这样做的好处是想查状态切换时的初始化逻辑只需要看这一个函数想打日志也只需要改这一个函数。4. 实操准备场景结构与基础参数搭建动手写代码前先搭一个干净的测试场景。新开一个场景根节点用CharacterBody2D挂一个CollisionShape2D作为身体碰撞一个Area2D作为攻击判定区一个Sprite2D显示敌人外观一个AnimationPlayer负责播放动画。这样列出来好像很多其实五到十分钟就能搭完。我给这个场景的节点命名建议是Enemy根节点挂enemy.gdBodyCollision身体碰撞AttackArea攻击判定区域Visual精灵或模型显示AnimPlayer动画播放器接下来是这个练习里需要用到的几个基础参数写在脚本顶部export var move_speed: float 100.0 export var attack_range: float 35.0 export var chase_speed: float 140.0 export var patrol_speed: float 60.0 export var max_health: float 50.0 var health: float max_health var player_ref: Node2D var patrol_points: Array[Vector2] [] var current_patrol_index: int 0 var lost_player_time: float 0.0 var attack_anim_finished: bool false var hurt_anim_finished: bool falseplayer_ref需要在启动时找到玩家。如果你项目里的玩家有一个可识别节点例如Player通过get_tree().get_first_node_in_group(player)或直接引用场景均可。这里用组的方式更干净在你的玩家根节点上添加一个player组就行。5. 逐个状态实现代码与动作细节现在进入核心实操环节。下面我按状态的顺序逐个拆解实现逻辑并给出可以直接抄的代码。每个状态我都会说明它的行为设计意图。5.1 待机状态 IDLE待机状态是敌人的默认姿态。待机时敌人不应该做多余的事只是站在原地偶尔看看周围。我这里用了一个简单计时器让敌人在待机idle_time秒后自动转入巡逻避免敌人像木桩一样一直站着。var idle_timer: float 2.0 func _process_idle(delta): # 待机时不移动 velocity Vector2.ZERO # 播放待机动画假设动画名为 idle if not $AnimPlayer.is_playing(): $AnimPlayer.play(idle) idle_timer - delta if idle_timer 0.0: idle_timer 2.0 change_state(State.PATROL)待机期间如果看见了玩家应该马上切到追击。这个看见的逻辑写在哪里我推荐写在统一的_check_conditions()函数里后续介绍。5.2 巡逻状态 PATROL巡逻需要敌人有一串路径点没有路径点就随机选个方向走。我在练习里用的是一维路径点做法定义一个Vector2数组巡逻目标就是数组元素。func _process_patrol(delta): if patrol_points.is_empty(): # 没有巡逻点就随机走 if not _is_moving: _start_random_direction() else: # 朝当前目标点移动 var target patrol_points[current_patrol_index] var dir (target - global_position).normalized() velocity dir * patrol_speed if global_position.distance_to(target) 5.0: current_patrol_index (current_patrol_index 1) % patrol_points.size()巡逻状态最关键的一点是当群体的巡逻速度比较低时转向要平滑。简单的做法是直接朝目标点线性移动等有经验了再用move_toward做渐提速。巡逻中看到玩家立刻切追击。注意追加一句看到了玩家就切否则敌人会像瞎子一样继续晃。5.3 追击状态 CHASE追击是整个状态机里最常用的状态逻辑也不复杂朝玩家位置移动如果距离小于攻击范围就切攻击如果距离超过警戒范围就开始计算丢玩计时。func _process_chase(delta): if not is_instance_valid(player_ref): change_state(State.IDLE) return var dist global_position.distance_to(player_ref.global_position) if dist attack_range: change_state(State.ATTACK) return # 玩家太远丢失计时 if dist detect_range: lost_player_time delta if lost_player_time 3.0: lost_player_time 0.0 change_state(State.IDLE) return else: lost_player_time 0.0 # 朝玩家移动 var dir (player_ref.global_position - global_position).normalized() velocity dir * chase_speed这里有两个细节值得注意。第一lost_player_time的计时逻辑放在追击里面意味着玩家不是瞬间敌人看不见而是有 3 秒丢失缓冲这个宽容度对游戏手感非常重要。第二追击状态播放的动画应该是跑和待机的站区分开否则玩家会以为这是 bug。5.4 攻击状态 ATTACK攻击状态稍特殊因为它涉及动画与伤害判定的配合。我的处理方式是攻击的伤害数字在动画的特定时间点触发比如动画中间帧攻击命中判定通过AttackArea区域检测实现。状态机只负责切换攻击的开始与结束。func _process_attack(delta): velocity Vector2.ZERO if not _attack_started: _attack_started true $AnimPlayer.play(attack) if attack_anim_finished: attack_anim_finished false _attack_started false # 攻击完成后检查是否继续追击 var dist global_position.distance_to(player_ref.global_position) if dist attack_range: change_state(State.CHASE) else: change_state(State.ATTACK) # 连击2秒一次你在自己项目里看到这段代码可能会疑惑为什么攻击完成后如果还在范围内又切回攻击状态这不是死循环吗从视觉角度这就是连击。实际游戏中通常攻击需要间隔所以我建议在切换回去之前加一个冷却计时器或者简单地用await get_tree().create_timer(0.6).timeout做短延迟。漏掉一个关键点攻击时的朝向问题。如果玩家跑到了敌人身后敌人应该转身再攻击。这个可以在攻击开始前做朝向修正# 在 attack 切换时添加 if player_ref.global_position.x global_position.x: $Visual.scale.x abs($Visual.scale.x) else: $Visual.scale.x -abs($Visual.scale.x)5.5 受伤状态 HURT受击状态的意义在于每次受击都给玩家一个短暂的窗口期让玩家感觉自己的攻击有响应而不是砍空气。这套设计在动作游戏里非常常见。给 HURT 状态设计 0.3 到 0.5 秒的硬直时间即可。var hurt_timer: float 0.0 func _process_hurt(delta): velocity Vector2.ZERO # 硬直计时 hurt_timer - delta if hurt_timer 0.0: # 硬直结束根据血量决定去向 if health 0.0: change_state(State.DEATH) else: change_state(State.CHASE)受伤动画如果做成一个立即播放的片段比如快速抖动要配合$AnimPlayer.play(hurt)在切换进入时播放。需要留意的是连续受击时敌人应该有打断效果只要新受了伤就算原本在攻击也要被切到 HURT。这个逻辑放在受到伤害的处理函数中。5.6 死亡状态 DEATH死亡是终态一旦进入就不再允许任何其他切换。这里强调禁止切换到其他状态是我在实际项目中踩过最大的坑死亡动画播放中如果还继续受到状态切换敌人会在倒地后突然站起来追玩家。死亡状态的实现归类为清理func _process_death(delta): # 播放一次死亡动画如果还没播 if not _death_played: _death_played true $AnimPlayer.play(death) # 关闭碰撞体避免阻挡 $BodyCollision.set_deferred(disabled, true) # 关闭攻击判定 $AttackArea.set_deferred(disabled, true) # 不再做任何其他逻辑 return用set_deferred是因为物理引擎禁止在物理回调中直接修改碰撞体状态这个细节很多新手会碰到报错。等不及学 deferred 的话也可以等物理帧结束再改但set_deferred是一行代码的事直接记住用这个。死亡状态不需要change_state的任何可能代码里也不要在_enter_new_state里给它设计出路。6. 状态切换枢纽与统一条件检测六个状态各自的逻辑写完了还需要一个重要的协调机制在每个状态的处理函数末尾都调用一次统一的条件检测函数_check_conditions()。这么做的好处是不管在哪个状态里玩家进入攻击范围、敌人受到伤害后血量归零这些跨状态的关键事件都能被第一时间捕获。func _check_conditions(): match current_state: State.IDLE, State.PATROL: if _can_see_player(): change_state(State.CHASE) State.CHASE: if global_position.distance_to(player_ref.global_position) attack_range: change_state(State.ATTACK) State.ATTACK: # 攻击动画播放后根据距离切回追击或保持连击 pass State.HURT: pass # hurt 内部处理 State.DEATH: pass_can_see_player()里最朴素的实现就是距离判断加视线遮挡检测func _can_see_player() - bool: if not is_instance_valid(player_ref): return false var dist global_position.distance_to(player_ref.global_position) if dist detect_range: return false # 可选用射线/空间中点检测遮挡 var space_state get_world_2d().direct_space_state var query PhysicsRayQueryParameters2D.create(global_position, player_ref.global_position) var result space_state.intersect_ray(query) if result.is_empty(): return true # 无遮挡 return result.collider player_ref这里的射线遮挡检测就是敌人的视线。如果你希望敌人无视遮挡、透视玩家可以省略射线部分只看距离。切换时的日志输出在change_state里加一行print([状态切换] , current_state, - , new_state)后面对排查会非常有用建议保留到项目后期再清理。7. 状态机运行时的完整代码骨架把上面这些代码汇总起来形成一个可以直接运行的脚本骨架。在项目里用这个结构做增量修改会省下很多时间。extends CharacterBody2D enum State { IDLE, PATROL, CHASE, ATTACK, HURT, DEATH } export var patrol_speed: float 60.0 export var chase_speed: float 140.0 export var detect_range: float 150.0 export var attack_range: float 30.0 export var max_health: float 50.0 var current_state: State State.IDLE var health: float var player_ref: Node2D var patrol_points: Array[Vector2] [] var patrol_index: int 0 var idle_timer: float 2.0 var lost_player_time: float 0.0 var hurt_timer: float 0.0 var attack_started: bool false var attack_anim_finished: bool false var death_played: bool false func _ready(): health max_health player_ref get_tree().get_first_node_in_group(player) func _physics_process(delta): match current_state: State.IDLE: _process_idle(delta) State.PATROL: _process_patrol(delta) State.CHASE: _process_chase(delta) State.ATTACK: _process_attack(delta) State.HURT: _process_hurt(delta) State.DEATH: _process_death(delta) _check_conditions() # ---------- 各状态处理函数 ---------- func _process_idle(delta): velocity Vector2.ZERO $AnimPlayer.play(idle) idle_timer - delta if idle_timer 0.0: idle_timer 2.0 change_state(State.PATROL) func _process_patrol(delta): if patrol_points.is_empty(): velocity Vector2.ZERO return var target patrol_points[patrol_index] var dir (target - global_position).normalized() velocity dir * patrol_speed $AnimPlayer.play(walk) if global_position.distance_to(target) 5.0: patrol_index (patrol_index 1) % patrol_points.size() func _process_chase(delta): if not is_instance_valid(player_ref): change_state(State.IDLE) return var dist global_position.distance_to(player_ref.global_position) if dist attack_range: change_state(State.ATTACK) return if dist detect_range: lost_player_time delta if lost_player_time 3.0: lost_player_time 0.0 change_state(State.IDLE) else: lost_player_time 0.0 var dir (player_ref.global_position - global_position).normalized() velocity dir * chase_speed $AnimPlayer.play(run) func _process_attack(delta): velocity Vector2.ZERO if not attack_started: attack_started true _face_player() $AnimPlayer.play(attack) func _process_hurt(delta): velocity Vector2.ZERO hurt_timer - delta if hurt_timer 0.0: if health 0.0: change_state(State.DEATH) else: change_state(State.CHASE) func _process_death(delta): if not death_played: death_played true $AnimPlayer.play(death) $BodyCollision.set_deferred(disabled, true) $AttackArea.set_deferred(disabled, true) # ---------- 辅助函数 ---------- func change_state(new_state: State): if current_state State.DEATH: return print(状态切换: , current_state, - , new_state) current_state new_state attack_started false attack_anim_finished false if current_state State.HURT: hurt_timer 0.4 func _face_player(): if player_ref and player_ref.global_position.x global_position.x: $Visual.scale.x -abs($Visual.scale.x) elif player_ref: $Visual.scale.x abs($Visual.scale.x) func take_damage(amount: float): if current_state State.DEATH: return health - amount if current_state ! State.HURT: change_state(State.HURT) # 受击中断追击/攻击 if current_state State.HURT: $AnimPlayer.play(hurt)需要说明的是change_state里的日志输出在实际项目中可能很吵但新手阶段强烈建议保留。你会在调试状态机时因为这段日志少走很多弯路。8. 状态机落地时的动画与物理配合要点状态机逻辑写完之后动画和物理层面的配合决定敌人看起来是否正常。我在测试这个练习项目时发现有四个细节经常让敌人显得不对劲。首先是动画播放的重复触发。看上面_process_idle里的写法和_process_chase里的写法都是每帧调用 $AnimPlayer.play()。如果动画已经在播放同名动画重复调用会不会打断GDScript 里重复播放相同动画不会重启动画所以一般安全。但保险起见可以加一个仅在当前动画名不同时才播放的判断if $AnimPlayer.current_animation ! run: $AnimPlayer.play(run)其次是物理移动和动画状态的同步。追击状态速度很快如果动画播放的是待机或者慢走视觉上会滑冰。最好让动画的播放速度与速度匹配比如追击时设置$AnimPlayer.speed_scale 1.5。这不是必须的但会让动作显得更有力。第三是攻击判定的时间点。攻击动画一般分前摇、伤害帧、后摇三个部分。伤害判定应该在伤害帧触发而不是动画开始时。我在练习项目里用了一个简单办法在attack动画的某个关键帧添加一个函数调用Godot 的 AnimationPlayer 支持关键帧调用方法在动画编辑器里右键轨迹可添加调用方法轨道。这个做法比用计时器要精准得多。第四是受击状态中的位移。有些游戏在受伤时会被击退一段距离这是受击反馈的一部分。如果要实现可以在 HURT 状态中加入velocity knockback_dir * knockback_force并在_process_hurt里做线性衰减velocity velocity.lerp(Vector2.ZERO, 1.0 - exp(-10 * delta))这样敌人的受击硬直就有了一点位移反馈手感会明显好一截。9. 状态机常见 Bug 与调试技巧实录状态机写完之后调试和排错才是重头戏。我把自己在这次练习中遇到的高频问题整理成一张速查表每条都是踩过的坑。现象可能原因解决方法敌人一直站着不动待机计时器是负数或 PATROL 没有巡逻点打印巡逻点数组确认启动时是否正确赋值敌人攻击后无限连击ATTACK 状态切换到自身没有间隔加攻击冷却计时器或等待动画完成后延迟切换敌人死亡后爬起来死亡动画播放期间被_check_conditions切回其他状态change_state开头写if current_state State.DEATH: return敌人受伤动画播不完HURT 切换过快或动画被重复覆盖检查hurt_timer确保硬直时间大于动画长度攻击判定没生效AttackArea 碰撞层未配置或玩家不在检测层在 Creator 中设置碰撞层和掩码攻击判定区域用Area2D敌人瞬间追踪玩家没有设置detect_range敌人全地图透视在_can_see_player里做距离判断和射线遮挡排查状态机失效问题我的独家技巧是在_physics_process开头打断点或打印状态编号。如果状态切换太快你会在输出面板看到整屏日志。我之前遇到过一次状态在 IDLE 和 PATROL 之间高频抖动原因就是待机计时器归零后立刻巡逻巡逻发现没巡逻点又切回待机形成死循环。这类问题看日志非常直观。还有一个小坑是export参数没保存。你在 Inspector 面板改了patrol_speed但没按回车确认运行后参数没生效或者你导出的是只读脚本变量运行时修改只在当前场景有效。我在练习时经常被这种低级错误耽误时间建议养成改完参数按回车然后再运行的习惯。10. 这次练习项目的扩展方向状态机管好一个敌人后后续扩展空间很大。如果你的练习进度走到了这里我建议尝试以下几个方向每一个都能把状态机用到新的深度。第一个方向是给敌人增加远程攻击模式。近战敌人只要一个 ATTACK 状态就够了但远程敌人可能需要蓄力、施法、弹道飞行等子状态。这可以榨出子状态机的需求比如 ATTACK 内部再分三段。想法很简单实现时用另一个枚举嵌套即可。第二个方向是用行为树替换状态机。行为树适合更复杂的分支逻辑比如优先攻击最弱的玩家、血量低了撤退找队友。状态机讲究的是当前做什么行为树讲究的是决策优先级两者各有所长。但这事的前提是先把状态机玩明白否则行为树的调试难度更高。第三个方向是把状态切换做成可视化面板。Godot 4 的自定义工具脚本能力很强可以把当前状态、切换记录绘制到调试界面上类似动画状态机的调试工具。这个方向对工程体验的提升非常明显调试效率高很多。我把这些方向列出来是为了给学有余力的读者留作业不是为了展开讲。目前这一节的核心目标还是先跑通状态机、让敌人的行为稳定可预测。11. 实测几轮之后的一些体会整个第 17 节练习做完回头就发现瓶颈从来不在写状态机的代码本身而在于把状态拆好、把切换条件定准。待机超时切巡逻、巡逻发现玩家切追击、追击贴脸切攻击、受击切硬直、血量归零切死亡——这些边界条件在你调试十几遍之后会自然而然地形成直觉。我的建议是第一次做的时候不要想太多直接用枚举和 match 实现一遍。哪怕写出来的代码有点幼稚、性能说不上好、扩展性也一般——但你已经理解了状态机管 AI 的核心思路行为和状态绑定状态切换经过统一枢纽条件检测。这个思路本身才是这节练习最值钱的部分。等你尝到了状态管理的甜头再往深度走。到时候甚至不用我教你自己就会琢磨怎么把状态之间的切换做得更优雅怎么让敌人的 AI 看起来各有性格。状态机是一座桥过了桥你会发现游戏 AI 的世界比自己想象的大得多。