
1. 项目概述为什么状态机是游戏AI的“定海神针”在Unity里做游戏尤其是涉及到敌人AI新手最容易掉进的坑就是“面条代码”。今天写个巡逻逻辑明天加个追击逻辑后天发现敌人被打时还能巡逻于是又加一堆if-else去判断。代码越堆越多状态切换的逻辑像一团乱麻最后连自己都看不懂更别提扩展了。这就是为什么我们需要状态机State Machine。它不是什么高深莫测的黑科技而是一种清晰、可靠、易于维护的编程思想是游戏AI逻辑的“定海神针”。简单来说状态机就是把一个复杂对象比如敌人的生命周期拆分成几个明确的“状态”State比如闲置、巡逻、追击、攻击、死亡。每个状态里对象只做特定的事情。状态之间通过明确的“条件”Condition进行切换比如“看到玩家”就从巡逻切换到追击“玩家跑远”就从追击切回巡逻。这种模式让逻辑变得一目了然无论是调试bug还是未来想增加一个“逃跑”或“召唤”状态都变得异常简单。今天要分享的就是一个在Unity中从零搭建、结构清晰、可随时扩展的简单敌人状态机实现方案。无论你是刚接触Unity的萌新还是想优化自己项目AI架构的老手这套思路都能让你事半功倍。2. 状态机核心设计思路告别“面条代码”在动手写代码之前我们必须把设计思路理清楚。一个健壮的状态机其核心在于“解耦”和“规范”。我们要确保状态逻辑、状态切换条件和状态管理三者分离各司其职。2.1 状态模式每个状态都是一个独立的类最核心的设计是采用“状态模式”。我们不为敌人编写一个庞大的、包含所有行为的EnemyController脚本而是为每一个具体状态创建一个独立的类。例如IdleState负责闲置时的发呆、四处张望等行为。PatrolState负责沿着预设路径点移动。ChaseState负责锁定玩家并持续移动接近。AttackState负责进入攻击范围后执行攻击动画和造成伤害。DeadState负责播放死亡动画、移除碰撞体等善后工作。每个状态类都继承自一个公共的抽象基类比如IEnemyState接口或EnemyStateBase抽象类。这个基类会定义几个关键方法OnEnter()当进入该状态时调用用于初始化比如播放特定动画、重置计时器。OnUpdate()在该状态的每一帧调用执行核心逻辑比如移动、检测。OnExit()当离开该状态时调用用于清理工作比如停止某个协程。这样做的好处是每个状态的逻辑都被封装在自己的“小房间”里互不干扰。修改巡逻逻辑时你完全不用担心会影响到攻击逻辑。代码的阅读和维护成本直线下降。2.2 状态切换由条件驱动而非硬编码状态之间不能随意切换必须由明确的逻辑条件驱动。我们将这些条件抽象出来。常见的条件包括视觉条件CanSeePlayer()敌人是否在视野内发现了玩家。距离条件IsPlayerInAttackRange()玩家是否进入了攻击范围。生命值条件IsHealthLow()敌人生命值是否低于某个阈值用于触发逃跑。时间条件HasPatrolTimeEnded()巡逻时间是否结束。这些条件判断不应该散落在各个状态的Update方法里而是应该集中管理或者由状态机在每帧统一评估。一种清晰的实践是在每个状态类的OnUpdate中只专注于执行该状态的行为而将“是否应该切换状态”的判断交给一个专门的StateTransition类或由状态机管理器来处理。但更简单直接的方式是在状态类的OnUpdate末尾检查切换条件并调用状态机的切换方法。无论哪种方式目标都是让状态切换的逻辑像流程图一样清晰可见。2.3 状态机管理器大脑中枢我们需要一个“大脑”来协调这一切这就是状态机管理器通常直接放在敌人GameObject的主控制器脚本里比如EnemyAI。这个管理器持有当前状态CurrentState的引用并在Update中调用当前状态的OnUpdate。它还需要提供一个公共方法比如ChangeState(EnemyStateBase newState)供各个状态调用以触发切换。在这个方法里它会先调用旧状态的OnExit()然后切换当前状态引用最后调用新状态的OnEnter()。注意状态切换通常发生在OnUpdate中而ChangeState方法可能会立即设置新的当前状态。要小心处理执行顺序确保旧状态的清理和新状态的初始化不会因为同一帧内的多次状态切换请求而产生冲突。一个稳妥的做法是将状态切换请求“延迟”到当前帧所有状态逻辑执行完毕后再处理但这在简单实现中可以先不做只需在编码时保持警惕。3. 从零搭建一个可扩展的敌人状态机框架理论说再多不如一行代码。下面我们就来一步步构建这个框架。我会用C#在Unity中实现确保每一行代码都有解释你可以直接复制到你的项目中使用。3.1 第一步定义状态基类与接口首先创建所有状态的共同契约。我们使用一个抽象基类因为它可以同时提供接口需要子类实现的方法和可能共用的默认实现。// EnemyStateBase.cs using UnityEngine; public abstract class EnemyStateBase { // 持有状态机管理器的引用方便状态访问敌人数据或触发状态切换 protected EnemyAI enemyAI; // 构造函数注入状态机管理器 public EnemyStateBase(EnemyAI ai) { this.enemyAI ai; } // 状态进入时调用 public virtual void OnEnter() { } // 状态每帧更新时调用 public virtual void OnUpdate() { } // 状态退出时调用 public virtual void OnExit() { } }这个基类非常简单。enemyAI字段让每个状态都能访问到敌人的生命值、玩家引用、导航组件等核心数据。virtual关键字意味着子类可以按需重写这些方法而不是必须实现。3.2 第二步实现状态机管理器EnemyAI接下来创建敌人的主AI脚本它同时也是状态机管理器。// EnemyAI.cs using UnityEngine; using UnityEngine.AI; // 假设我们使用Unity的导航系统 public class EnemyAI : MonoBehaviour { // 对玩家的引用可以通过标签查找或Inspector赋值 [SerializeField] private Transform playerTarget; // 导航组件 private NavMeshAgent navMeshAgent; // 当前状态 private EnemyStateBase currentState; // 各个状态实例的引用避免频繁创建销毁 private IdleState idleState; private PatrolState patrolState; private ChaseState chaseState; private AttackState attackState; // 一些公共属性供状态访问 public Transform PlayerTarget playerTarget; public NavMeshAgent Agent navMeshAgent; public float AttackRange 2.0f; public float SightRange 10.0f; void Start() { // 获取组件 navMeshAgent GetComponentNavMeshAgent(); if (playerTarget null) { // 简单起见假设玩家标签为Player playerTarget GameObject.FindGameObjectWithTag(Player).transform; } // 初始化各个状态实例 idleState new IdleState(this); patrolState new PatrolState(this); chaseState new ChaseState(this); attackState new AttackState(this); // 设置初始状态 ChangeState(idleState); } void Update() { // 每帧更新当前状态 currentState?.OnUpdate(); } // 状态切换的核心方法 public void ChangeState(EnemyStateBase newState) { // 退出旧状态 currentState?.OnExit(); // 切换状态 currentState newState; // 进入新状态 currentState?.OnEnter(); // 可选打印日志调试用 Debug.Log($[EnemyAI] 状态切换为: {newState.GetType().Name}); } // 公共方法提供给状态类检查条件 public bool CanSeePlayer() { if (playerTarget null) return false; float distanceToPlayer Vector3.Distance(transform.position, playerTarget.position); if (distanceToPlayer SightRange) return false; // 简单的射线检测排除墙壁遮挡 Vector3 directionToPlayer (playerTarget.position - transform.position).normalized; if (Physics.Raycast(transform.position, directionToPlayer, out RaycastHit hit, SightRange)) { return hit.transform playerTarget; } return false; } public bool IsPlayerInAttackRange() { if (playerTarget null) return false; return Vector3.Distance(transform.position, playerTarget.position) AttackRange; } }这个管理器做了几件关键事1) 初始化所有状态实例2) 在Update中驱动当前状态逻辑3) 提供了安全的ChangeState方法4) 封装了常用的条件判断如CanSeePlayer。3.3 第三步实现具体状态类以巡逻和追击为例现在让我们实现两个最典型的状态巡逻和追击。你会看到逻辑是多么清晰。PatrolState.cs (巡逻状态)public class PatrolState : EnemyStateBase { private Transform[] waypoints; // 巡逻路径点 private int currentWaypointIndex 0; private float waitTimer 0f; private bool isWaiting false; public PatrolState(EnemyAI ai) : base(ai) { } public override void OnEnter() { // 进入巡逻状态确保导航代理开启 enemyAI.Agent.isStopped false; // 这里假设路径点已经通过其他方式设置好了比如在EnemyAI中赋值 // 为了示例我们假设有预设的路径点或者动态查找 GameObject[] waypointObjs GameObject.FindGameObjectsWithTag(Waypoint); waypoints new Transform[waypointObjs.Length]; for (int i 0; i waypointObjs.Length; i) { waypoints[i] waypointObjs[i].transform; } // 如果没有路径点则直接切换到闲置 if (waypoints null || waypoints.Length 0) { Debug.LogWarning(未找到巡逻路径点); enemyAI.ChangeState(new IdleState(enemyAI)); return; } // 设置第一个目标点 MoveToNextWaypoint(); Debug.Log(敌人开始巡逻。); } public override void OnUpdate() { // 1. 执行巡逻逻辑 if (!isWaiting) { // 检查是否到达当前路径点 if (enemyAI.Agent.remainingDistance enemyAI.Agent.stoppingDistance) { // 到达开始等待 isWaiting true; waitTimer Random.Range(1f, 3f); // 等待1-3秒 } } else { // 等待倒计时 waitTimer - Time.deltaTime; if (waitTimer 0) { isWaiting false; MoveToNextWaypoint(); } } // 2. 检查状态切换条件是否看到玩家 if (enemyAI.CanSeePlayer()) { enemyAI.ChangeState(new ChaseState(enemyAI)); } } public override void OnExit() { // 退出时可以停止导航或重置数据 // enemyAI.Agent.isStopped true; // 通常由下一个状态控制 Debug.Log(敌人停止巡逻。); } private void MoveToNextWaypoint() { if (waypoints.Length 0) return; // 设置导航目标 enemyAI.Agent.SetDestination(waypoints[currentWaypointIndex].position); // 更新索引循环 currentWaypointIndex (currentWaypointIndex 1) % waypoints.Length; } }ChaseState.cs (追击状态)public class ChaseState : EnemyStateBase { public ChaseState(EnemyAI ai) : base(ai) { } public override void OnEnter() { enemyAI.Agent.isStopped false; Debug.Log(敌人发现目标开始追击); } public override void OnUpdate() { // 核心行为持续向玩家移动 if (enemyAI.PlayerTarget ! null) { enemyAI.Agent.SetDestination(enemyAI.PlayerTarget.position); } // 状态切换条件检查 // 条件1玩家进入攻击范围 - 切换至攻击状态 if (enemyAI.IsPlayerInAttackRange()) { enemyAI.ChangeState(new AttackState(enemyAI)); } // 条件2玩家跑出视野范围 - 切换回巡逻状态 (这里简化逻辑实际可能先寻找或返回上一个位置) else if (!enemyAI.CanSeePlayer()) { // 可以增加一个“丢失目标计时器”超过一段时间再返回巡逻体验更好 enemyAI.ChangeState(new PatrolState(enemyAI)); } } public override void OnExit() { // 追击状态退出可以停止移动由下一个状态决定是否停止 Debug.Log(敌人停止追击。); } }通过这两个状态类的实现你可以清晰地看到模式OnEnter做初始化OnUpdate执行核心逻辑并检查切换条件OnExit做清理。所有与“巡逻”相关的变量如路径点、等待计时器都封装在PatrolState内部不会污染其他状态或管理器。4. 扩展性实战如何轻松添加一个新状态框架的强大之处在于扩展极其简单。假设我们现在想增加一个“受伤硬直”状态HurtState当敌人受到攻击时会短暂僵直无法行动。第一步创建新的状态类HurtState.cspublic class HurtState : EnemyStateBase { private float stunDuration 0.5f; // 硬直时间 private float timer; public HurtState(EnemyAI ai) : base(ai) { } public override void OnEnter() { // 停止所有移动 enemyAI.Agent.isStopped true; // 播放受击动画假设有Animator组件 // enemyAI.GetComponentAnimator().SetTrigger(Hurt); // 重置计时器 timer stunDuration; Debug.Log(敌人进入硬直状态。); } public override void OnUpdate() { // 硬直倒计时 timer - Time.deltaTime; if (timer 0) { // 硬直结束根据情况切换状态 // 例如如果玩家还在攻击范围内则可能直接反击切到攻击状态 // 如果玩家在视野内但不在攻击范围则追击 // 这里简单切回追击状态 if (enemyAI.CanSeePlayer()) { enemyAI.ChangeState(new ChaseState(enemyAI)); } else { enemyAI.ChangeState(new PatrolState(enemyAI)); } } // 硬直期间不执行任何移动或攻击逻辑 } public override void OnExit() { // 恢复移动能力 enemyAI.Agent.isStopped false; Debug.Log(敌人硬直结束。); } }第二步在EnemyAI中初始化并暴露这个状态在EnemyAI类中添加一个HurtState的私有字段并在Start中初始化它就像其他状态一样。同时提供一个公共方法如OnDamageTaken供外部如子弹碰撞检测脚本调用来触发状态切换。// 在EnemyAI类内部添加 private HurtState hurtState; void Start() { // ... 其他初始化 ... hurtState new HurtState(this); // ... } // 一个公共方法供伤害系统调用 public void TakeDamage() { // 这里可以扣血等逻辑... // 切换到硬直状态 ChangeState(hurtState); }看添加一个新状态你只需要做两件事1) 创建一个继承自EnemyStateBase的新类实现其行为逻辑2) 在管理器中初始化它并在合适的时机如受到伤害时调用ChangeState。原有的巡逻、追击、攻击状态完全不需要修改。这就是高扩展性和低耦合度的威力。5. 性能优化与高级技巧基础框架跑起来后我们还需要关注一些细节让状态机更健壮、更高效。5.1 状态实例管理对象池思想在上述示例中每次切换状态我们都使用了new关键字创建新的状态实例如new ChaseState(enemyAI)。对于频繁切换的状态这会产生一些不必要的垃圾。更优的做法是在EnemyAI的Start中预先创建所有状态实例并缓存起来切换状态时只是切换引用。// 在EnemyAI中 private DictionarySystem.Type, EnemyStateBase stateCache; void Start() { stateCache new DictionarySystem.Type, EnemyStateBase { { typeof(IdleState), new IdleState(this) }, { typeof(PatrolState), new PatrolState(this) }, // ... 其他状态 }; ChangeStateToIdleState(); // 使用泛型方法切换 } public void ChangeStateToT() where T : EnemyStateBase { if (stateCache.TryGetValue(typeof(T), out EnemyStateBase newState)) { ChangeState(newState); } }这样整个游戏生命周期内每个状态类只有一个实例避免了GC垃圾回收压力。5.2 使用枚举或ScriptableObject定义状态切换条件当状态和条件变得复杂时在OnUpdate里写一堆if-else又会变得混乱。我们可以引入更结构化的方式。例如为每个状态定义一个ListStateTransition其中StateTransition包含一个条件可以是一个委托Funcbool和一个目标状态类型。状态机每帧遍历当前状态的所有Transition检查条件满足则切换。这种方式将条件配置化更清晰。更进一步可以使用Unity的ScriptableObject来创建“状态”和“过渡”资产在编辑器中进行可视化配置这对于复杂的行为树或AI系统是常见的做法。但对于我们“简单可扩展”的目标前一种基于类的设计已经足够强大和灵活。5.3 与动画系统的无缝集成敌人的状态往往伴随着不同的动画Idle, Walk, Run, Attack, Die。我们的状态机可以很好地与Unity的Animator控制器配合。在每个状态的OnEnter方法中设置Animator的对应Trigger或Bool参数。// 在EnemyAI中 public Animator Animator; // 在某个状态中例如ChaseState的OnEnter public override void OnEnter() { enemyAI.Animator.SetBool(IsRunning, true); }确保动画状态机Animator Controller中的过渡条件与代码逻辑同步这样视觉和逻辑就完美结合了。6. 常见问题与调试技巧实录在实际使用中你肯定会遇到一些问题。下面是我踩过的一些坑和解决方案。问题1状态切换时出现“鬼畜”或逻辑错误。原因通常是因为状态切换条件在单帧内被多次满足导致状态在A和B之间快速来回切换。例如追击状态中IsPlayerInAttackRange()和!CanSeePlayer()两个条件可能在同一帧都为真边缘情况。解决为状态切换增加优先级或增加延迟。例如在ChaseState中优先检查攻击条件再检查丢失视野条件。或者对于“丢失视野”这种条件引入一个“确认时间”比如玩家离开视野2秒后才切换回巡逻而不是立刻切换。问题2导航代理NavMeshAgent在状态切换时卡住或不听指挥。原因多个状态都在OnUpdate中设置Agent.SetDestination()或者某个状态在OnExit时错误地停止了代理(isStopped true)而下一个状态的OnEnter没有重新启动它。解决遵循“谁启用谁管理”的原则。移动状态如Patrol, Chase的OnEnter中确保Agent.isStopped false而非移动或需要停止的状态如Attack, Hurt的OnEnter中设置Agent.isStopped true。在OnExit中一般不要修改代理状态由下一个状态的OnEnter来负责初始化。问题3想查看敌人当前状态如何调试技巧在EnemyAI.ChangeState方法中添加Debug.Log打印新旧状态名。或者在EnemyAI的OnGUI或Update中将currentState.GetType().Name显示在屏幕上使用GUI.Label或Unity的UI Text。Unity编辑器的“Debug”模式也可以在Inspector中查看私有变量你可以将currentState临时改为public进行调试。问题4如何为不同的敌人类型复用状态逻辑方案将状态类设计得更通用。例如PatrolState的巡逻速度、等待时间ChaseState的追击速度都可以作为参数从EnemyAI中传递或者通过状态类的构造函数传入。这样一个FastEnemyAI和一个SlowEnemyAI可以共享相同的状态类但拥有不同的参数配置。更进一步可以创建敌人的基础数据ScriptableObject所有参数都在其中配置。问题5状态很多时EnemyAI的Update里调用currentState.OnUpdate()会不会性能很差分析不会。这只是一个虚函数调用开销极小。真正的性能瓶颈在于状态OnUpdate内部执行的逻辑比如物理检测Physics.OverlapSphere、复杂的距离计算等。优化点应该放在这些具体逻辑上例如使用Coroutine间隔检查而非每帧检查或者使用空间划分算法优化距离查询。这套简单的敌人状态机实现其价值不在于代码本身有多复杂而在于它引入了一种清晰、模块化的思维方式。它将一个看似复杂的AI问题分解成了一个个独立、可管理、可复用的“状态”单元。当你下次在Unity中面对需要复杂行为的游戏对象时无论是敌人、NPC甚至是一个有多个交互状态的机关门不妨先问问自己“它的行为可以分成哪几个状态” 从这个问题开始你的代码就已经走在了一条更优雅、更健壮的道路上。