
1. 项目概述为什么我们需要一个“聪明”的敌人在游戏开发中尤其是动作、RPG或类银河恶魔城这类游戏里敌人NPC的AI行为是游戏体验的核心支柱之一。一个只会傻站着或者无脑冲锋的敌人很快就会让玩家感到乏味。而一个行为丰富、反应灵敏、状态切换流畅的敌人则能极大地提升游戏的挑战性和沉浸感。今天要聊的“敌人状态机”就是实现这种“聪明”敌人的核心技术骨架。简单来说状态机State Machine是一种编程模型它定义了一个对象在这里就是敌人在其生命周期内可能处于的几种“状态”以及这些状态之间相互转换的“条件”。想象一下一个站岗的士兵他大部分时间处于“巡逻”状态眼睛四处张望一旦发现可疑目标立即切换到“警戒”状态举起武器如果目标进入攻击范围则切换到“攻击”状态如果受伤过重可能会切换到“逃跑”或“死亡”状态。这个从“巡逻”到“警戒”再到“攻击”的过程就是状态机在驱动。在Unity中实现敌人状态机远不止是写一堆if-else判断那么简单。一个健壮、可维护的状态机架构需要清晰的层次划分、松耦合的设计以及高效的状态切换逻辑。它直接关系到后续添加新敌人类型、调试AI逻辑以及进行性能优化的效率。很多新手在实现敌人AI时容易把各种行为逻辑移动、攻击、寻路、动画全部塞进一个巨大的EnemyController脚本里导致代码臃肿不堪修改一个行为可能引发一连串难以预料的Bug。状态机正是为了解决这种混乱而生的设计模式。本教程将带你从零开始构建一个适用于大多数2D/3D动作游戏的、模块化的敌人状态机系统。我们将不仅实现“巡逻”、“追击”、“攻击”、“死亡”这些基础状态更会深入探讨状态机的设计哲学、如何与Unity的动画系统Animator、导航系统NavMeshAgent或自定义移动以及事件系统优雅地集成。无论你是刚刚入门Unity想为你的小游戏添加一些像样的敌人还是已经有一定经验希望重构自己混乱的AI代码这篇内容都将提供一套可直接“抄作业”的完整方案和背后的思考逻辑。2. 状态机核心架构设计告别“面条代码”在动手写代码之前我们必须先想清楚架构。一个好的架构能让后续开发事半功倍而一个糟糕的架构则会让你在添加第三个状态时就想重写。我们将采用基于“状态模式”的设计这是游戏开发中实现状态机最经典、最灵活的方式之一。2.1 状态模式与三层结构状态模式的核心思想是将每一个状态封装成一个独立的类。这个类负责管理该状态下的所有行为进入、更新、退出以及判断何时应该切换到其他状态。这样做的好处是极高的内聚性和极低的耦合性巡逻的逻辑全在PatrolState里攻击的逻辑全在AttackState里它们彼此不知道对方的存在只通过一个共同的“上下文”Context——也就是我们的敌人主体——来进行通信和切换。基于此我们设计一个三层结构上下文层Context这是状态机的“宿主”通常就是我们的敌人游戏对象GameObject上挂载的主控制器脚本比如EnemyController。它持有当前状态对象的引用并负责在每帧调用当前状态的更新逻辑。它还应该提供一些公共方法和属性如玩家的位置、自身的血量、导航组件引用等供各个状态类查询和调用。状态基类层Base Class定义一个抽象的EnemyState基类或接口。它规定了所有具体状态类必须实现的方法通常至少包括OnEnter(): 当进入该状态时调用。在这里进行初始化工作比如播放特定的动画、重置计时器、寻找路径点等。OnUpdate(): 在该状态的每一帧被调用。这里是状态核心逻辑发生的地方比如计算与玩家的距离、执行移动、检查攻击冷却等。OnExit(): 当退出该状态时调用。在这里进行清理工作比如停止动画、取消未完成的寻路请求等。具体状态层Concrete States继承自EnemyState基类的各个具体状态类如PatrolState、ChaseState、AttackState、HurtState、DeathState等。每个类只关心自己状态内的逻辑。2.2 关键组件与数据流除了代码结构我们还需要规划好状态机与Unity其他组件的交互。一个典型的敌人AI会涉及以下组件导航系统对于3D游戏或复杂的2D地形Unity的NavMeshAgent是首选。对于简单的2D平台游戏可能需要自己实现基于物理或Transform的移动。我们的状态机需要持有对这些移动组件的引用。动画系统状态切换往往伴随着动画切换。我们可以通过Animator Controller中的状态机来驱动也可以更直接地在代码状态机的OnEnter中触发Animator的SetTrigger或SetBool。建议将动画逻辑封装在状态类内部实现“状态-动画”的强对应。感知系统敌人如何发现玩家通常通过触发器Trigger Collider实现一个“视觉范围”和“攻击范围”。当玩家进入这些范围时触发对应的事件这些事件会通知状态机上下文进而可能触发状态切换。数据配置敌人的移动速度、巡逻点、攻击力、视野距离、攻击间隔等参数不应该硬编码在脚本里。我们可以创建一个EnemyData的ScriptableObject资产方便在编辑器中进行配置和批量调整。数据流的典型过程是EnemyController上下文在Update中调用CurrentState.OnUpdate()。CurrentState在执行逻辑过程中会根据感知到的信息如与玩家距离判断是否满足切换条件。如果满足它会调用上下文提供的SwitchState()方法传入下一个状态的类型。上下文负责销毁旧状态实例、创建新状态实例并调用新旧状态的OnExit和OnEnter。注意状态切换的时机。状态切换通常不应该在状态的OnUpdate中间直接发生因为这会打断当前帧的逻辑。更安全的做法是状态类只负责“建议”切换比如设置一个nextStateType标志由上下文在Update的最后统一处理切换。或者使用一个简单的状态切换请求队列。3. 核心状态类详解与实现理论讲完了我们开始动手实现几个最核心的状态类。我会以C#代码为例并附上关键逻辑的讲解。3.1 状态基类与上下文定义首先定义我们的状态基类和上下文接口。// EnemyState.cs public abstract class EnemyState { protected EnemyController context; // 持有上下文的引用 public EnemyState(EnemyController context) { this.context context; } // 状态生命周期方法 public virtual void OnEnter() { } public virtual void OnUpdate(float deltaTime) { } public virtual void OnExit() { } } // IEnemyContext.cs (接口定义上下文需要提供的能力) public interface IEnemyContext { Transform Transform { get; } Transform PlayerTransform { get; } NavMeshAgent Agent { get; } Animator Animator { get; } EnemyData Data { get; } float CurrentHealth { get; } void SwitchStateT() where T : EnemyState; bool IsPlayerInSight(); bool IsPlayerInAttackRange(); }EnemyController作为上下文需要实现这个接口并管理当前状态。// EnemyController.cs public class EnemyController : MonoBehaviour, IEnemyContext { [SerializeField] private EnemyData enemyData; [SerializeField] private Transform[] patrolPoints; private EnemyState currentState; private NavMeshAgent agent; private Animator animator; private float health; public Transform Transform transform; public Transform PlayerTransform { get; private set; } // 需要通过其他方式赋值如GameManager public NavMeshAgent Agent agent; public Animator Animator animator; public EnemyData Data enemyData; public float CurrentHealth health; void Start() { agent GetComponentNavMeshAgent(); animator GetComponentAnimator(); health enemyData.maxHealth; PlayerTransform GameObject.FindGameObjectWithTag(Player).transform; // 简单查找生产环境建议用更稳健的方式 // 初始状态巡逻 SwitchStatePatrolState(); } void Update() { if (currentState ! null) currentState.OnUpdate(Time.deltaTime); } public void SwitchStateT() where T : EnemyState { // 退出当前状态 currentState?.OnExit(); // 创建并进入新状态 currentState Activator.CreateInstance(typeof(T), this) as EnemyState; currentState?.OnEnter(); Debug.Log($[{gameObject.name}] 切换到状态: {typeof(T).Name}); } // 感知方法实现 public bool IsPlayerInSight() { if (PlayerTransform null) return false; float distance Vector3.Distance(transform.position, PlayerTransform.position); return distance enemyData.sightRange; } public bool IsPlayerInAttackRange() { if (PlayerTransform null) return false; float distance Vector3.Distance(transform.position, PlayerTransform.position); return distance enemyData.attackRange; } // 其他方法如受伤、死亡等 public void TakeDamage(float damage) { health - damage; if (health 0) { SwitchStateDeathState(); } else { // 可以切换到受伤状态或者只是播放受击动画 // SwitchStateHurtState(); } } }3.2 巡逻状态实现巡逻状态是敌人的默认状态。逻辑通常包括在预设的几个点之间循环移动到达一个点后等待片刻然后前往下一个点。同时需要持续检测玩家是否进入视野。// PatrolState.cs public class PatrolState : EnemyState { private int currentPatrolIndex 0; private float waitTimer 0f; private bool isWaiting false; public PatrolState(EnemyController context) : base(context) { } public override void OnEnter() { // 进入巡逻状态停止可能的追击或攻击动画播放行走动画 context.Animator.SetBool(IsWalking, true); context.Animator.SetBool(IsAttacking, false); // 设置导航代理参数 context.Agent.speed context.Data.patrolSpeed; context.Agent.stoppingDistance 0.1f; // 巡逻点可以停得很近 MoveToNextPatrolPoint(); } public override void OnUpdate(float deltaTime) { // 1. 首要检查玩家是否进入视野如果是切换到追击状态 if (context.IsPlayerInSight()) { context.SwitchStateChaseState(); return; } // 2. 处理巡逻逻辑 if (isWaiting) { waitTimer - deltaTime; if (waitTimer 0) { isWaiting false; MoveToNextPatrolPoint(); } } else { // 检查是否到达当前巡逻点 if (!context.Agent.pathPending context.Agent.remainingDistance context.Agent.stoppingDistance) { StartWaitingAtPoint(); } } } public override void OnExit() { // 退出时可以停止行走动画 context.Animator.SetBool(IsWalking, false); } private void MoveToNextPatrolPoint() { if (context.PatrolPoints null || context.PatrolPoints.Length 0) { // 如果没有巡逻点就在原地发呆 isWaiting true; waitTimer context.Data.patrolWaitTime; return; } // 设置目的地为下一个巡逻点 context.Agent.SetDestination(context.PatrolPoints[currentPatrolIndex].position); // 更新索引循环 currentPatrolIndex (currentPatrolIndex 1) % context.PatrolPoints.Length; } private void StartWaitingAtPoint() { isWaiting true; waitTimer context.Data.patrolWaitTime; context.Animator.SetBool(IsWalking, false); // 等待时停止行走动画 } }实操心得巡逻点的设置。巡逻点可以直接在EnemyController的Inspector面板中拖拽赋值。对于更动态的场景可以考虑在运行时通过代码生成巡逻点比如在敌人周围随机生成几个点。确保巡逻点在地面NavMesh上否则导航会失败。对于2D游戏你可能需要自己实现一个简单的“移动到位置”的逻辑而不是用NavMeshAgent。3.3 追击状态实现当敌人发现玩家后进入追击状态。核心逻辑是持续将玩家的当前位置设为自己的导航目标。// ChaseState.cs public class ChaseState : EnemyState { public ChaseState(EnemyController context) : base(context) { } public override void OnEnter() { // 播放奔跑或快速行走动画 context.Animator.SetBool(IsRunning, true); context.Animator.SetBool(IsWalking, false); context.Agent.speed context.Data.chaseSpeed; context.Agent.stoppingDistance context.Data.attackRange * 0.9f; // 在接近攻击范围时就开始准备攻击 } public override void OnUpdate(float deltaTime) { // 1. 检查玩家是否丢失如果玩家跑出视野范围可能返回巡逻状态。 // 这里实现一个简单的“丢失计时器”玩家离开视野一段时间后才丢失。 if (!context.IsPlayerInSight()) { // 简单处理直接回到巡逻 // context.SwitchStatePatrolState(); // 更高级可以加入一个“寻找”状态或者原地警戒几秒。 return; } // 2. 检查是否进入攻击范围如果是切换到攻击状态。 if (context.IsPlayerInAttackRange()) { context.SwitchStateAttackState(); return; } // 3. 持续追击玩家 if (context.PlayerTransform ! null) { context.Agent.SetDestination(context.PlayerTransform.position); } } public override void OnExit() { context.Animator.SetBool(IsRunning, false); // 可能还需要停止导航但通常由下一个状态来处理 // context.Agent.ResetPath(); } }注意事项追击的“粘性”。直接让敌人永远精确追踪玩家可能会让游戏体验很糟糕尤其是玩家想逃跑时。可以引入一些“不完美”的因素比如给导航代理一个较小的angularSpeed旋转速度和acceleration加速度让敌人转向和加速不那么灵敏或者在玩家离开视野后敌人只追到最后一个已知位置然后进入“搜寻”状态。这些细节能大大增加AI的真实感。3.4 攻击状态实现攻击状态通常是一个短暂的状态包含攻击动画的播放、伤害判定的时机以及攻击后的冷却或硬直。// AttackState.cs public class AttackState : EnemyState { private float attackTimer 0f; private bool hasDealtDamage false; public AttackState(EnemyController context) : base(context) { } public override void OnEnter() { // 停止移动播放攻击动画 context.Agent.isStopped true; // 暂停导航 context.Animator.SetTrigger(Attack); // 触发攻击动画 context.Animator.SetBool(IsRunning, false); attackTimer context.Data.attackDuration; // 攻击动作总时长 hasDealtDamage false; // 面向玩家 if (context.PlayerTransform ! null) { Vector3 lookDir context.PlayerTransform.position - context.Transform.position; lookDir.y 0; // 保持水平旋转 if (lookDir ! Vector3.zero) { context.Transform.rotation Quaternion.LookRotation(lookDir); } } } public override void OnUpdate(float deltaTime) { attackTimer - deltaTime; // 在动画的特定帧如武器挥到最高点时造成伤害。 // 这里用计时器简单模拟更精确的做法是使用动画事件Animation Event。 if (!hasDealtDamage attackTimer context.Data.attackDuration * 0.5f) // 假设在攻击动作进行到一半时造成伤害 { TryDealDamage(); hasDealtDamage true; } // 攻击动作结束后判断下一步 if (attackTimer 0) { // 检查玩家是否还在攻击范围内 if (context.IsPlayerInAttackRange()) { // 还在范围内可以继续攻击重置计时器或者进入一个短暂的“攻击后摇”状态 // 这里我们简单切换回攻击状态实现连击。实际可能需要一个攻击冷却。 context.SwitchStateAttackState(); } else if (context.IsPlayerInSight()) { // 玩家在视野内但不在攻击范围切回追击 context.SwitchStateChaseState(); } else { // 玩家丢失回到巡逻 context.SwitchStatePatrolState(); } } } public override void OnExit() { context.Agent.isStopped false; // 恢复导航 } private void TryDealDamage() { // 简单的球形检测判断玩家是否在攻击判定范围内 Collider[] hitColliders Physics.OverlapSphere(context.Transform.position context.Transform.forward * context.Data.attackRange * 0.5f, context.Data.attackRange * 0.5f); foreach (var hitCollider in hitColliders) { if (hitCollider.CompareTag(Player)) { // 假设玩家有一个PlayerHealth组件 var playerHealth hitCollider.GetComponentPlayerHealth(); if (playerHealth ! null) { playerHealth.TakeDamage(context.Data.attackDamage); } break; } } } }核心技巧伤害判定与动画同步。上面用计时器模拟伤害判定时机是非常粗糙的。最佳实践是使用动画事件。在Attack动画的特定帧上添加一个事件该事件会调用EnemyController的一个公共方法如OnAttackAnimationHit然后由控制器或状态机来处理伤害判定。这样能确保伤害判定与视觉表现完全同步无论帧率如何变化。在AttackState的OnEnter中可以订阅这个事件在OnExit中取消订阅。4. 状态机的扩展、调试与性能优化实现基础状态后一个可用的敌人AI已经成型。但要让它更强大、更易维护我们还需要考虑扩展性和工程化问题。4.1 扩展更多状态基于现有的架构添加新状态变得非常容易。例如添加一个HurtState受伤状态在EnemyController.TakeDamage方法中除了扣血可以调用SwitchStateHurtState()。创建HurtState类在OnEnter中播放受击动画并设置一个短暂的无敌时间或硬直时间。在OnUpdate中计时时间结束后根据情况切换回ChaseState或PatrolState。再比如添加一个FleeState逃跑状态当敌人血量很低时有一定概率会逃跑。只需要在HurtState或AttackState中检查血量并概率性地切换到FleeState即可。4.2 可视化调试与日志调试状态机时最怕的就是不知道敌人当前处于什么状态以及为什么切换。我们可以通过多种方式增强可调试性OnGUI 或 UI 文本在敌人头顶显示当前状态名。void OnGUI() { if (currentState ! null) { GUI.Label(new Rect(10, 10, 200, 20), $State: {currentState.GetType().Name}); } }Unity Editor 自定义编辑器为EnemyController编写一个自定义的Editor脚本在Inspector面板中实时显示当前状态、血量、到玩家的距离等信息。使用Debug.DrawRay或Gizmos在Scene视图中绘制敌人的视野范围一个扇形和攻击范围一个圆非常直观。void OnDrawGizmosSelected() { if (enemyData ! null) { Gizmos.color Color.yellow; Gizmos.DrawWireSphere(transform.position, enemyData.sightRange); Gizmos.color Color.red; Gizmos.DrawWireSphere(transform.position, enemyData.attackRange); } }结构化日志像上面代码中那样在SwitchState时输出带对象名的日志方便在Console窗口过滤查看。4.3 性能考量与优化建议当场景中有大量敌人时状态机的性能需要关注减少每帧的运算感知检测的频率不需要每帧都进行Physics.OverlapSphere或计算距离来检测玩家。可以每N帧例如5帧检测一次或者使用协程Coroutine每隔一段时间检测一次。对于“玩家是否在视野内”这种复杂检测可能包含射线遮挡判断频率应该更低。导航路径更新频率NavMeshAgent.SetDestination是有开销的。在ChaseState中不需要每帧都设置目标。可以判断玩家位置移动超过一定阈值后再更新路径。对象池与状态复用每次SwitchState都new一个新的状态对象如果状态切换非常频繁可能会产生GC垃圾回收压力。对于状态种类不多且无状态数据或可重置的情况可以考虑使用对象池来复用状态实例。在EnemyController中预先创建所有可能的状态对象切换时只是激活和禁用它们。使用更轻量的移动方案对于大量简单的2D敌人使用NavMeshAgent可能过重。可以考虑使用更简单的基于Rigidbody2D或直接修改Transform.position的移动方式并在状态机中自己处理寻路逻辑如A*算法或简单的朝向移动。动画状态机优化确保Animator Controller的布局简洁避免过多的过渡条件和层。可以考虑使用动画融合树Blend Trees来平滑处理移动速度变化。4.4 与Unity动画状态机的协作模式我们的代码状态机和Unity的Animator状态机是两套独立的状态机如何让它们协同工作有两种主流模式代码驱动动画这是我们上面采用的方式。在代码状态机的OnEnter和OnExit中通过Animator.SetBool/SetTrigger来驱动Animator中的状态切换。代码状态机是主控动画状态机是表现层。这种方式逻辑清晰耦合度低。动画驱动代码在Animator中设置状态机并在状态State或过渡Transition上添加动画事件这些事件调用EnemyController的方法来触发逻辑状态切换。这种方式更适合动画师主导的工作流但逻辑可能会分散在动画文件和代码中调试稍复杂。我个人更推荐代码驱动动画的模式因为游戏逻辑何时攻击、何时追击应该由代码决定动画只是视觉反馈。这符合“数据/逻辑层”与“表现层”分离的原则。5. 常见问题排查与进阶技巧即使按照教程搭建在实际项目中你还是会遇到各种问题。这里记录一些我踩过的坑和解决方案。5.1 状态切换混乱或卡死症状敌人在两个状态间快速来回切换或者卡在某个状态出不来了。排查检查切换条件确保你的条件判断是互斥且稳定的。例如IsPlayerInSight()和IsPlayerInAttackRange()的判断阈值要有明确的差值避免玩家刚好在边界时反复横跳。可以加入一个“状态保持时间”的最小阈值例如进入攻击状态后至少0.5秒内不允许切换出去。检查Update顺序确保状态切换的逻辑在OnUpdate中是“原子”的且切换后立即return避免同一帧执行了多个状态的逻辑。打印调试信息在每个状态的OnEnter和可能触发切换的地方打印日志观察切换流程。解决在EnemyController的SwitchState方法开头可以加入一个检查如果请求切换到的状态就是当前状态则直接忽略。5.2 导航代理行为异常症状敌人不移动、抖动、穿墙、卡在角落。排查NavMesh烘焙首先检查场景的NavMesh是否已正确烘焙并且敌人的位置在NavMesh之上。代理参数检查NavMeshAgent的radius、height、step height、slope limit是否设置合理。一个过大的radius可能导致在狭窄处无法通过。停止距离stoppingDistance很重要。在PatrolState中应该很小如0.1在ChaseState中应设为略小于攻击范围这样敌人在接近到攻击范围时就会停下并切换状态。路径状态在设置目的地前可以检查agent.isOnNavMesh。如果为false需要调用NavMeshAgent.Warp将其瞬移到最近的NavMesh上。解决对于卡住问题可以增加一个“解困”机制如果代理超过一定时间如3秒都没有移动或路径无效则强制停止当前路径并尝试一个随机方向的小幅度移动或直接切换到Idle状态。5.3 动画与逻辑不同步症状攻击动作播放了但伤害没有产生或者伤害产生了但动画还没播。排查与解决坚决使用动画事件这是解决此类问题的银弹。在动画编辑器中在准确的帧上添加事件调用一个定义好的方法。确保事件方法名在Animator和脚本中完全一致。状态机时序确保在AttackState的OnEnter中触发攻击动画然后在动画事件触发的方法中进行伤害判定。在OnExit中清理。这样逻辑和视觉就牢牢绑定在一起了。5.4 如何设计更有趣的AI行为基础状态机只是骨架要让敌人真正“活”起来需要注入一些行为逻辑引入随机性巡逻等待时间、攻击后的下一次攻击间隔、发现玩家后的反应延迟都可以加入随机值避免所有敌人行为完全一致。状态间共享数据有时一个状态需要知道另一个状态的信息。例如FleeState可能需要知道是从哪个状态逃过来的以便在安全后返回。可以通过上下文EnemyController来存储这些共享数据比如LastKnownPlayerPosition、LastStateBeforeFlee等。分层状态机当敌人行为复杂时比如一个Boss有多种攻击模式每种模式又包含多个阶段可以考虑使用分层状态机Hierarchical State Machine。Unity的Animator Controller本身就是一个分层状态机可以参考其思想或者使用像Stateless这样的第三方库。行为树对于极其复杂、需要大量条件分支和优先级选择的AI如RTS游戏中的单位状态机可能变得难以维护。这时可以考虑升级到行为树Behavior Tree。但在大多数动作游戏中精心设计的状态机已经完全够用且性能更好。构建一个稳健的敌人状态机是游戏开发中一项极具成就感的工作。它就像在赋予一堆多边形和贴图以生命和个性。从简单的巡逻追击到复杂的多阶段Boss战其底层逻辑都离不开状态机这一强大而优雅的设计模式。希望这套从架构到实现从原理到避坑的完整思路能帮助你打造出令玩家印象深刻、行为可信的敌人。记住最好的AI是让玩家感觉不到“规则”的存在而是觉得在与一个真实的对手交锋。