1. 项目概述:为什么要在Unity里自己搓一个状态机?
在Unity里做游戏,尤其是涉及到角色行为、UI流程或者任何有明确“状态”切换的逻辑时,你肯定绕不开“状态机”这个概念。Animator Controller是Unity官方提供的、最强大的可视化状态机,用来处理动画混合和过渡堪称一绝。但很多时候,我们面对的逻辑状态,远比动画状态要复杂和抽象。比如一个NPC的AI行为:空闲、巡逻、追击、攻击、逃跑、死亡;或者一个UI界面的生命周期:加载中、显示中、隐藏中、已关闭。
这时候,如果强行把所有逻辑都塞进Animator里,用动画参数和动画事件来驱动,代码会变得极其晦涩难懂,维护起来简直是噩梦。更常见的情况是,我们需要的只是一个轻量级、纯逻辑的、易于理解和扩展的状态管理工具。这就是为什么很多有经验的Unity开发者,会选择自己动手实现一个简单的有限状态机。
这个“简单”是相对于那些功能完备、支持层级、并行状态的复杂状态机框架而言的。我们的目标是:用最纯粹的C#面向对象思想,构建一个核心代码可能不超过200行的状态机,让它能清晰、安全地管理状态的进入、更新和退出。这不仅是解决眼前的问题,更是深入理解状态设计模式的一次绝佳实践。对于面试中常被问到的“观察者模式”和“状态模式”的区别与应用场景,亲手实现一遍比看十遍理论都管用。
2. 核心设计思路:面向对象的状态机
在动手写代码之前,我们先要确立设计哲学。一个易于理解和维护的状态机,其核心在于“封装”和“解耦”。每个状态应该是一个独立、自包含的类,它只关心自己这个状态下该做什么。状态机本身则像一个路由器,它不关心具体业务逻辑,只负责正确地切换状态,并调用相应状态的接口方法。
2.1 状态接口:定义行为的契约
首先,我们定义一个所有状态类都必须遵守的契约,也就是接口。这个接口规定了状态生命周期中必须实现的三个基本方法。
/// <summary> /// 状态接口。所有具体状态类都需要实现这个接口。 /// </summary> /// <typeparam name="T">状态机所有者的类型(例如:Player, Enemy, UIWindow)。</typeparam> public interface IState<T> where T : class { /// <summary> /// 进入该状态时调用。 /// </summary> /// <param name="entity">状态机所属的实体对象。</param> void OnEnter(T entity); /// <summary> /// 处于该状态时,每帧调用。 /// </summary> /// <param name="entity">状态机所属的实体对象。</param> void OnUpdate(T entity); /// <summary> /// 离开该状态时调用。 /// </summary> /// <param name="entity">状态机所属的实体对象。</param> void OnExit(T entity); }为什么这么设计?
- 泛型
<T>: 这使得我们的状态机可以服务于任何类型的对象(Player, Enemy, GameManager等),提高了复用性。where T : class约束确保T是引用类型,避免值类型可能带来的意外行为。 - 三个生命周期方法: 这是状态模式的核心。
OnEnter用于状态初始化(如播放动画、重置计时器),OnUpdate用于处理该状态下的持续逻辑(如移动、检测),OnExit用于状态清理(如停止音效、保存数据)。清晰的分离使得每个状态的职责单一。 - 传入
entity: 状态对象通常需要操作其所属的实体。通过参数传入,避免了状态类与具体实体类的强耦合。状态类只需要通过接口或基类方法来操作实体,符合依赖倒置原则。
2.2 状态机类:核心调度器
状态机类是整个架构的大脑。它持有当前状态,并负责状态的存储、切换和生命周期方法的调用。
using System.Collections.Generic; /// <summary> /// 一个简单的有限状态机。 /// </summary> /// <typeparam name="T">状态机所有者的类型。</typeparam> public class FiniteStateMachine<T> where T : class { // 状态字典:通过状态类型(Type)快速查找状态实例。 private Dictionary<System.Type, IState<T>> _stateDictionary = new Dictionary<System.Type, IState<T>>(); // 当前状态。 private IState<T> _currentState; // 状态机所属的实体。 private T _owner; /// <summary> /// 构造函数。 /// </summary> /// <param name="owner">状态机服务的实体对象。</param> public FiniteStateMachine(T owner) { _owner = owner; } /// <summary> /// 每帧更新,驱动当前状态的OnUpdate逻辑。 /// </summary> public void Update() { _currentState?.OnUpdate(_owner); } /// <summary> /// 注册一个状态到状态机中。 /// </summary> /// <param name="state">要注册的状态实例。</param> public void RegisterState(IState<T> state) { var stateType = state.GetType(); if (!_stateDictionary.ContainsKey(stateType)) { _stateDictionary.Add(stateType, state); } else { // 实践中这里可以Log一个警告,避免重复注册。 } } /// <summary> /// 切换到指定类型的状态。 /// </summary> /// <typeparam name="TState">要切换到的状态类型。</typeparam> public void ChangeState<TState>() where TState : class, IState<T> { // 获取目标状态类型 var newStateType = typeof(TState); // 从字典中获取状态实例 if (_stateDictionary.TryGetValue(newStateType, out IState<T> newState)) { // 调用旧状态的退出方法 _currentState?.OnExit(_owner); // 更新当前状态引用 _currentState = newState; // 调用新状态的进入方法 _currentState.OnEnter(_owner); } else { // 状态未注册,可以抛出异常或记录错误 throw new System.Exception($"State {newStateType.Name} is not registered in the state machine."); } } /// <summary> /// 获取当前状态的类型。 /// </summary> public System.Type CurrentStateType => _currentState?.GetType(); }设计要点与避坑指南:
- 状态存储: 使用
Dictionary<Type, IState<T>>来存储状态实例。键是状态的System.Type,值是状态对象本身。这样可以通过类型快速、安全地查找和切换状态,比使用字符串或枚举更不容易出错,并且有编译时类型检查。 - 状态切换流程:
ChangeState方法是核心。务必遵循旧状态.OnExit -> 切换引用 -> 新状态.OnEnter的顺序。这个顺序至关重要,它保证了状态清理和初始化的正确性。想象一下,如果先OnEnter新状态,新状态可能会依赖某些在旧状态OnExit中才会被重置的变量,导致逻辑错误。 - 空状态安全: 在
Update()和ChangeState()中,使用_currentState?.OnUpdate(_owner)这样的空值传播操作符,可以优雅地处理状态机初始化为空或状态意外为空的情况,避免NullReferenceException。 - 泛型约束
where TState : class, IState<T>: 在ChangeState<TState>()方法中,这个约束确保了TState必须是一个类并且实现了IState<T>接口,提高了API的健壮性和易用性。
3. 实战演练:为游戏敌人构建AI状态机
理论说再多不如实际操练一遍。我们假设要为一个简单的巡逻敌人实现AI。这个敌人有四个状态:IdleState(空闲)、PatrolState(巡逻)、ChaseState(追击)和AttackState(攻击)。
3.1 定义敌人实体类
首先,我们需要一个敌人类,它持有状态机实例,并提供状态机所需的数据和方法。
using UnityEngine; public class Enemy : MonoBehaviour { // 公开一些属性供状态类访问 public float moveSpeed = 3f; public float chaseRange = 10f; public float attackRange = 2f; public Transform[] patrolPoints; private int _currentPatrolIndex = 0; // 状态机实例 private FiniteStateMachine<Enemy> _stateMachine; // 目标(玩家)引用,实际项目中可能通过传感器获得 public Transform Target { get; set; } void Start() { // 初始化状态机,所有者是自己(this) _stateMachine = new FiniteStateMachine<Enemy>(this); // 创建并注册状态 _stateMachine.RegisterState(new IdleState()); _stateMachine.RegisterState(new PatrolState()); _stateMachine.RegisterState(new ChaseState()); _stateMachine.RegisterState(new AttackState()); // 初始状态设置为空闲 _stateMachine.ChangeState<IdleState>(); } void Update() { // 每帧更新状态机 _stateMachine.Update(); // 这里可以模拟一个简单的目标检测(仅为示例) // 实际项目中,这部分逻辑可能放在一个专门的“感知系统”或某个状态中 Target = GameObject.FindGameObjectWithTag("Player")?.transform; } // 提供给PatrolState使用的方法 public Vector3 GetCurrentPatrolPoint() { if (patrolPoints == null || patrolPoints.Length == 0) return transform.position; return patrolPoints[_currentPatrolIndex].position; } public void MoveToNextPatrolPoint() { if (patrolPoints == null || patrolPoints.Length == 0) return; _currentPatrolIndex = (_currentPatrolIndex + 1) % patrolPoints.Length; } // 提供给状态类计算距离的辅助方法 public float DistanceToTarget() { if (Target == null) return Mathf.Infinity; return Vector3.Distance(transform.position, Target.position); } // 简单的移动方法,状态类可以调用 public void MoveTowards(Vector3 position) { Vector3 direction = (position - transform.position).normalized; transform.position += direction * moveSpeed * Time.deltaTime; // 简单转向 if (direction.sqrMagnitude > 0.01f) { transform.forward = direction; } } }实操心得:
- 将状态机作为实体类的一个成员变量是标准做法。实体类充当了状态机与游戏世界(Unity的GameObject、Transform等)之间的桥梁。
- 实体类需要暴露必要的属性和方法(如
moveSpeed,MoveTowards)给状态类使用,但状态类不应该直接操作GameObject或Transform,而应通过实体类提供的接口。这保持了良好的封装性。 Target的查找放在Enemy.Update中只是一个简化示例。在更复杂的AI中,感知(看见、听到)应该是一个独立的系统或组件,状态机根据感知系统的输出来决定状态切换。
3.2 实现具体状态类
现在,我们来逐一实现四个状态。每个状态都是一个独立的类。
IdleState.cs (空闲状态)
public class IdleState : IState<Enemy> { private float _idleTimer; private float _idleDuration = 2f; // 空闲2秒后去巡逻 public void OnEnter(Enemy enemy) { Debug.Log($"{enemy.name}: 进入空闲状态"); _idleTimer = 0f; // 可以在这里播放空闲动画 // enemy.animator.Play("Idle"); } public void OnUpdate(Enemy enemy) { _idleTimer += Time.deltaTime; // 条件1:空闲时间结束,切换到巡逻 if (_idleTimer >= _idleDuration) { enemy.GetComponent<FiniteStateMachine<Enemy>>().ChangeState<PatrolState>(); return; } // 条件2:发现目标,切换到追击 if (enemy.Target != null && enemy.DistanceToTarget() < enemy.chaseRange) { enemy.GetComponent<FiniteStateMachine<Enemy>>().ChangeState<ChaseState>(); return; } } public void OnExit(Enemy enemy) { Debug.Log($"{enemy.name}: 离开空闲状态"); } }PatrolState.cs (巡逻状态)
public class PatrolState : IState<Enemy> { public void OnEnter(Enemy enemy) { Debug.Log($"{enemy.name}: 进入巡逻状态"); // 播放行走动画 // enemy.animator.Play("Walk"); } public void OnUpdate(Enemy enemy) { // 获取当前巡逻点并移动过去 Vector3 targetPoint = enemy.GetCurrentPatrolPoint(); enemy.MoveTowards(targetPoint); // 判断是否到达巡逻点(简单距离判断) if (Vector3.Distance(enemy.transform.position, targetPoint) < 0.5f) { enemy.MoveToNextPatrolPoint(); } // 条件:发现目标,切换到追击 if (enemy.Target != null && enemy.DistanceToTarget() < enemy.chaseRange) { enemy.GetComponent<FiniteStateMachine<Enemy>>().ChangeState<ChaseState>(); return; } } public void OnExit(Enemy enemy) { Debug.Log($"{enemy.name}: 离开巡逻状态"); } }ChaseState.cs (追击状态)
public class ChaseState : IState<Enemy> { public void OnEnter(Enemy enemy) { Debug.Log($"{enemy.name}: 进入追击状态!"); // 播放奔跑动画或提高移动速度 // enemy.animator.Play("Run"); // enemy.moveSpeed *= 1.5f; } public void OnUpdate(Enemy enemy) { // 如果目标丢失,回到空闲状态 if (enemy.Target == null) { enemy.GetComponent<FiniteStateMachine<Enemy>>().ChangeState<IdleState>(); return; } // 向目标移动 enemy.MoveTowards(enemy.Target.position); // 条件1:进入攻击范围,切换到攻击状态 if (enemy.DistanceToTarget() <= enemy.attackRange) { enemy.GetComponent<FiniteStateMachine<Enemy>>().ChangeState<AttackState>(); return; } // 条件2:目标超出追击范围,放弃追击,回到空闲 if (enemy.DistanceToTarget() > enemy.chaseRange * 1.2f) // 加入一点滞后,防止在边界抖动 { enemy.GetComponent<FiniteStateMachine<Enemy>>().ChangeState<IdleState>(); return; } } public void OnExit(Enemy enemy) { Debug.Log($"{enemy.name}: 离开追击状态"); // 恢复移动速度 // enemy.moveSpeed /= 1.5f; } }AttackState.cs (攻击状态)
public class AttackState : IState<Enemy> { private float _attackCooldown = 1f; private float _lastAttackTime = -Mathf.Infinity; // 确保第一次能立即攻击 public void OnEnter(Enemy enemy) { Debug.Log($"{enemy.name}: 进入攻击状态!准备攻击!"); // 播放攻击预备动画 // enemy.animator.Play("AttackReady"); } public void OnUpdate(Enemy enemy) { // 如果目标丢失或跑出攻击范围,切回追击状态 if (enemy.Target == null || enemy.DistanceToTarget() > enemy.attackRange) { enemy.GetComponent<FiniteStateMachine<Enemy>>().ChangeState<ChaseState>(); return; } // 攻击冷却判断 if (Time.time >= _lastAttackTime + _attackCooldown) { PerformAttack(enemy); _lastAttackTime = Time.time; } // 注意:攻击状态通常不需要移动,但可以加入转向面对目标的逻辑 if (enemy.Target != null) { Vector3 lookDir = (enemy.Target.position - enemy.transform.position).normalized; lookDir.y = 0; if (lookDir.sqrMagnitude > 0.01f) { enemy.transform.forward = lookDir; } } } private void PerformAttack(Enemy enemy) { Debug.Log($"{enemy.name}: 发动攻击!"); // 这里实现攻击逻辑:播放攻击动画、产生攻击判定、造成伤害等 // enemy.animator.SetTrigger("Attack"); // if (Physics.Raycast(...)) { // 检测命中 } // Target.GetComponent<Health>().TakeDamage(10); } public void OnExit(Enemy enemy) { Debug.Log($"{enemy.name}: 离开攻击状态"); // 清理攻击状态,例如重置动画触发器 // enemy.animator.ResetTrigger("Attack"); } }状态实现的核心技巧与注意事项:
- 状态切换的发起者: 注意,状态切换的逻辑是在每个状态的
OnUpdate中判断并执行的。状态自己决定什么时候该“让位”给另一个状态。这是一种非常清晰的设计,符合“每个状态管理自己的生命周期”的原则。状态机只提供ChangeState这个工具。 - 获取状态机引用: 在状态类内部,我们通过
enemy.GetComponent<FiniteStateMachine<Enemy>>()来获取状态机并切换状态。这里假设状态机挂载在同一个GameObject上。你也可以在实体类初始化状态时,将状态机引用传递给每个状态实例(例如在状态类的构造函数中传入),这样效率更高,但会稍微增加耦合。第一种方式更通用,第二种方式更高效,可根据项目规模选择。 - 状态数据: 像
_idleTimer,_attackCooldown,_lastAttackTime这样的变量,它们只属于某个特定状态,因此应该作为该状态类的私有成员变量。这避免了在实体类中定义一大堆全局变量,导致命名空间污染和难以管理。 - 动画与逻辑分离: 代码中注释掉的动画调用部分,展示了状态机如何与Animator协同工作。状态机负责逻辑决策(何时移动、何时攻击),而Animator负责视觉表现(播放哪个动画)。两者通过状态类的
OnEnter/OnExit和实体类提供的方法进行通信,这是一种非常高效的架构。
4. 高级技巧与常见问题排查
一个基础的状态机跑起来后,我们会遇到一些更实际的问题。下面分享一些进阶技巧和踩坑记录。
4.1 状态切换的“抖动”问题
在ChaseState中,我们判断if (enemy.DistanceToTarget() > enemy.chaseRange)就切回IdleState。想象一下,玩家刚好在chaseRange边界来回移动,敌人就会在ChaseState和IdleState之间高频切换,每帧都在OnEnter和OnExit,这就是“状态抖动”。
解决方案:引入滞后阈值我们在ChaseState的代码中已经使用了这个技巧:if (enemy.DistanceToTarget() > enemy.chaseRange * 1.2f)。退出追击的范围比进入追击的范围更大。同理,在AttackState中,退出攻击的范围也可以略大于进入攻击的范围。这为状态切换提供了一个“缓冲区”,有效避免了边界抖动。
4.2 全局状态与状态间通信
有时,多个状态需要共享一些信息或执行相同的检查。例如,所有状态都需要检查敌人是否死亡,如果死亡则立即切换到DeathState。在每个状态的OnUpdate里都写一遍死亡检查,显然很冗余。
解决方案1:在实体类的Update中做全局检查在Enemy.Update方法中,在调用_stateMachine.Update()之前,先检查全局条件。
void Update() { // 全局条件检查优先于状态逻辑 if (currentHealth <= 0 && _stateMachine.CurrentStateType != typeof(DeathState)) { _stateMachine.ChangeState<DeathState>(); return; // 直接返回,不再执行当前状态的逻辑 } // 如果没死,再正常更新状态机 _stateMachine.Update(); // ... 其他逻辑 }解决方案2:创建一个“全局状态”或“条件判断器”定义一个专门的类或方法,集中管理所有全局过渡条件。状态机每帧更新时,先咨询这个“条件判断器”,如果有满足的全局条件,则强制执行状态切换。这种方式更模块化,适合复杂AI。
4.3 状态机与Unity生命周期和性能
在FixedUpdate还是Update中调用?这取决于你的游戏逻辑。如果你的状态逻辑主要涉及物理移动(使用Rigidbody),那么应该在FixedUpdate中调用_stateMachine.Update()。如果主要是动画、输入响应和基于每帧时间的逻辑,则在Update中调用。我们的示例基于Transform移动,所以在Update中。
状态对象的创建与管理我们的示例中,状态实例是在Start时创建并注册的。对于大多数情况,这足够了。但如果状态非常多(比如几十个),或者状态对象本身很重(持有大量资源),你可能需要考虑对象池模式来复用状态实例,避免频繁的GC。不过,对于简单的逻辑状态,创建开销通常可以忽略不计。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
| 状态不切换 | 1. 状态未正确注册。 2. ChangeState调用条件从未满足。3. 在 OnEnter中错误地又切换了状态。 | 1. 检查RegisterState是否被调用,字典_stateDictionary里是否有目标状态。2. 在状态 OnUpdate中添加Debug.Log打印条件判断的变量值。3. 确保 OnEnter只做初始化,不要包含可能立即触发状态切换的逻辑。 |
| 状态切换时出现空引用 | 1. 状态机的_owner为 null。2. 状态类的 OnEnter/Update/Exit方法中错误地访问了未初始化的enemy属性。 | 1. 确保在构造函数中传入了有效的owner。2. 在状态方法开头检查 enemy参数是否为 null,并添加安全的默认处理。 |
| 逻辑表现与预期不符(如移动速度异常) | 1. 状态OnExit中没有正确清理。2. 多个状态同时修改同一个实体属性(如 moveSpeed)。 | 1.黄金法则:在OnEnter中设置的状态属性,必须在OnExit中还原。例如ChaseState中提速,退出时必须减速。2. 考虑将这类属性改为状态私有,或通过实体类的方法间接修改,避免冲突。 |
| 大量状态导致代码冗长 | 每个状态一个类,状态多了文件也多。 | 这是状态模式的固有特点。可以通过将简单状态合并、使用状态数据表(ScriptableObject)驱动、或采用更高级的状态机框架(如行为树)来管理超复杂AI。对于中小型项目,清晰的类分离利大于弊。 |
5. 对比与扩展:何时该用Animator?何时该用代码状态机?
这是新手最容易困惑的点。简单来说:
使用Unity Animator Controller:
- 核心用途是动画混合与过渡。你有一个角色,他有Idle, Walk, Run, Jump, Attack等动画,并且这些动画之间需要平滑过渡、分层、混合(比如上半身攻击,下半身跑步)。Animator是为此而生的,它的可视化编辑器和状态过渡条件非常强大。
- 可以配合Animator的Parameters和脚本(
Animator.SetTrigger)来驱动简单的逻辑状态,但仅限于与动画强相关的逻辑。一旦逻辑变得复杂(比如需要计时器、复杂距离判断、访问多个外部系统),用Animator就会非常别扭。
使用自制的代码状态机(如本文实现的):
- 核心用途是管理纯游戏逻辑状态。例如:敌人的AI行为(巡逻、追击、攻击)、游戏流程(开始菜单、游戏中、暂停、游戏结束)、UI窗口状态(打开、激活、隐藏、关闭)、网络连接状态(连接中、已连接、断线重连中)。
- 逻辑清晰,易于调试(可以在每个状态
OnEnter/Exit打Log),易于进行单元测试。 - 与动画系统解耦。你的代码状态机决定“现在应该攻击”,然后通过调用
animator.SetTrigger("Attack")来通知Animator播放攻击动画。职责分离,架构更干净。
最佳实践是结合两者:用代码状态机做“大脑”(决策层),用Animator做“小脑”(表现层)。代码状态机输出逻辑指令(如StartAttack,StartMoving),Animator接收指令并处理复杂的动画播放和混合。两者通过一个薄薄的适配层(如EnemyAnimationController脚本)进行通信。
6. 从“简单”到“可用”:一些实用的增强点
我们实现的这个状态机是“简单”的,但已经具备了核心功能。如果你想让它更加强大和实用,可以考虑以下增强:
- 状态历史栈: 添加一个
Stack<IState<T>>来记录状态历史。实现一个PushState(临时切换到新状态)和PopState(回到上一个状态)的方法。这对于实现“打开子菜单”、“临时打断”等场景非常有用。 - 状态参数传递: 修改
ChangeState方法,允许传递一个object类型的参数到新状态的OnEnter中。例如,从Idle切换到MoveToPoint时,可以传递目标点的坐标。 - 全局事件系统: 引入一个简单的事件系统。状态机可以触发事件(如
OnStateChanged),其他系统(如UI、音效)可以订阅这些事件,实现更松散的耦合。 - 可视化调试: 在Unity编辑器的Inspector窗口或Game窗口中,显示当前状态名称。这可以通过在
Enemy类中添加一个[SerializeField] private string _currentStateName;并在状态切换时更新它来实现,对于调试复杂AI至关重要。
实现一个有限状态机,远不止是写出那几十行核心代码。更重要的是通过这个过程,你学会了如何用“状态”的思维来分析和设计复杂的游戏逻辑,如何用面向对象的方法将庞大的、面条式的if-else或switch-case代码块,重构为一个个职责单一、易于理解和维护的类。这种设计能力,会随着你项目复杂度的提升而愈发珍贵。下次当你面对一堆控制角色行为的布尔变量和混乱的条件判断时,不妨停下来想一想:“是不是该用状态机来整理一下了?”