Unity游戏开发:从零实现面向对象有限状态机(FSM)

发布时间:2026/7/31 17:23:30
Unity游戏开发:从零实现面向对象有限状态机(FSM) 1. 项目概述为什么要在Unity里自己搓一个状态机在Unity里做游戏尤其是涉及到角色行为、UI流程或者任何有明确“状态”切换的逻辑时你肯定绕不开“状态机”这个概念。Animator Controller是Unity官方提供的、最强大的可视化状态机用来处理动画混合和过渡堪称一绝。但很多时候我们面对的逻辑状态远比动画状态要复杂和抽象。比如一个NPC的AI行为空闲、巡逻、追击、攻击、逃跑、死亡或者一个UI界面的生命周期加载中、显示中、隐藏中、已关闭。这时候如果强行把所有逻辑都塞进Animator里用动画参数和动画事件来驱动代码会变得极其晦涩难懂维护起来简直是噩梦。更常见的情况是我们需要的只是一个轻量级、纯逻辑的、易于理解和扩展的状态管理工具。这就是为什么很多有经验的Unity开发者会选择自己动手实现一个简单的有限状态机。这个“简单”是相对于那些功能完备、支持层级、并行状态的复杂状态机框架而言的。我们的目标是用最纯粹的C#面向对象思想构建一个核心代码可能不超过200行的状态机让它能清晰、安全地管理状态的进入、更新和退出。这不仅是解决眼前的问题更是深入理解状态设计模式的一次绝佳实践。对于面试中常被问到的“观察者模式”和“状态模式”的区别与应用场景亲手实现一遍比看十遍理论都管用。2. 核心设计思路面向对象的状态机在动手写代码之前我们先要确立设计哲学。一个易于理解和维护的状态机其核心在于“封装”和“解耦”。每个状态应该是一个独立、自包含的类它只关心自己这个状态下该做什么。状态机本身则像一个路由器它不关心具体业务逻辑只负责正确地切换状态并调用相应状态的接口方法。2.1 状态接口定义行为的契约首先我们定义一个所有状态类都必须遵守的契约也就是接口。这个接口规定了状态生命周期中必须实现的三个基本方法。/// summary /// 状态接口。所有具体状态类都需要实现这个接口。 /// /summary /// typeparam nameT状态机所有者的类型例如Player, Enemy, UIWindow。/typeparam public interface IStateT where T : class { /// summary /// 进入该状态时调用。 /// /summary /// param nameentity状态机所属的实体对象。/param void OnEnter(T entity); /// summary /// 处于该状态时每帧调用。 /// /summary /// param nameentity状态机所属的实体对象。/param void OnUpdate(T entity); /// summary /// 离开该状态时调用。 /// /summary /// param nameentity状态机所属的实体对象。/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 nameT状态机所有者的类型。/typeparam public class FiniteStateMachineT where T : class { // 状态字典通过状态类型Type快速查找状态实例。 private DictionarySystem.Type, IStateT _stateDictionary new DictionarySystem.Type, IStateT(); // 当前状态。 private IStateT _currentState; // 状态机所属的实体。 private T _owner; /// summary /// 构造函数。 /// /summary /// param nameowner状态机服务的实体对象。/param public FiniteStateMachine(T owner) { _owner owner; } /// summary /// 每帧更新驱动当前状态的OnUpdate逻辑。 /// /summary public void Update() { _currentState?.OnUpdate(_owner); } /// summary /// 注册一个状态到状态机中。 /// /summary /// param namestate要注册的状态实例。/param public void RegisterState(IStateT state) { var stateType state.GetType(); if (!_stateDictionary.ContainsKey(stateType)) { _stateDictionary.Add(stateType, state); } else { // 实践中这里可以Log一个警告避免重复注册。 } } /// summary /// 切换到指定类型的状态。 /// /summary /// typeparam nameTState要切换到的状态类型。/typeparam public void ChangeStateTState() where TState : class, IStateT { // 获取目标状态类型 var newStateType typeof(TState); // 从字典中获取状态实例 if (_stateDictionary.TryGetValue(newStateType, out IStateT 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(); }设计要点与避坑指南状态存储 使用DictionaryType, IStateT来存储状态实例。键是状态的System.Type值是状态对象本身。这样可以通过类型快速、安全地查找和切换状态比使用字符串或枚举更不容易出错并且有编译时类型检查。状态切换流程ChangeState方法是核心。务必遵循旧状态.OnExit - 切换引用 - 新状态.OnEnter的顺序。这个顺序至关重要它保证了状态清理和初始化的正确性。想象一下如果先OnEnter新状态新状态可能会依赖某些在旧状态OnExit中才会被重置的变量导致逻辑错误。空状态安全 在Update()和ChangeState()中使用_currentState?.OnUpdate(_owner)这样的空值传播操作符可以优雅地处理状态机初始化为空或状态意外为空的情况避免NullReferenceException。泛型约束where TState : class, IStateT 在ChangeStateTState()方法中这个约束确保了TState必须是一个类并且实现了IStateT接口提高了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 FiniteStateMachineEnemy _stateMachine; // 目标玩家引用实际项目中可能通过传感器获得 public Transform Target { get; set; } void Start() { // 初始化状态机所有者是自己this _stateMachine new FiniteStateMachineEnemy(this); // 创建并注册状态 _stateMachine.RegisterState(new IdleState()); _stateMachine.RegisterState(new PatrolState()); _stateMachine.RegisterState(new ChaseState()); _stateMachine.RegisterState(new AttackState()); // 初始状态设置为空闲 _stateMachine.ChangeStateIdleState(); } 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 : IStateEnemy { 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.GetComponentFiniteStateMachineEnemy().ChangeStatePatrolState(); return; } // 条件2发现目标切换到追击 if (enemy.Target ! null enemy.DistanceToTarget() enemy.chaseRange) { enemy.GetComponentFiniteStateMachineEnemy().ChangeStateChaseState(); return; } } public void OnExit(Enemy enemy) { Debug.Log(${enemy.name}: 离开空闲状态); } }PatrolState.cs (巡逻状态)public class PatrolState : IStateEnemy { 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.GetComponentFiniteStateMachineEnemy().ChangeStateChaseState(); return; } } public void OnExit(Enemy enemy) { Debug.Log(${enemy.name}: 离开巡逻状态); } }ChaseState.cs (追击状态)public class ChaseState : IStateEnemy { 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.GetComponentFiniteStateMachineEnemy().ChangeStateIdleState(); return; } // 向目标移动 enemy.MoveTowards(enemy.Target.position); // 条件1进入攻击范围切换到攻击状态 if (enemy.DistanceToTarget() enemy.attackRange) { enemy.GetComponentFiniteStateMachineEnemy().ChangeStateAttackState(); return; } // 条件2目标超出追击范围放弃追击回到空闲 if (enemy.DistanceToTarget() enemy.chaseRange * 1.2f) // 加入一点滞后防止在边界抖动 { enemy.GetComponentFiniteStateMachineEnemy().ChangeStateIdleState(); return; } } public void OnExit(Enemy enemy) { Debug.Log(${enemy.name}: 离开追击状态); // 恢复移动速度 // enemy.moveSpeed / 1.5f; } }AttackState.cs (攻击状态)public class AttackState : IStateEnemy { 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.GetComponentFiniteStateMachineEnemy().ChangeStateChaseState(); 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.GetComponentHealth().TakeDamage(10); } public void OnExit(Enemy enemy) { Debug.Log(${enemy.name}: 离开攻击状态); // 清理攻击状态例如重置动画触发器 // enemy.animator.ResetTrigger(Attack); } }状态实现的核心技巧与注意事项状态切换的发起者 注意状态切换的逻辑是在每个状态的OnUpdate中判断并执行的。状态自己决定什么时候该“让位”给另一个状态。这是一种非常清晰的设计符合“每个状态管理自己的生命周期”的原则。状态机只提供ChangeState这个工具。获取状态机引用 在状态类内部我们通过enemy.GetComponentFiniteStateMachineEnemy()来获取状态机并切换状态。这里假设状态机挂载在同一个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.ChangeStateDeathState(); 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,StartMovingAnimator接收指令并处理复杂的动画播放和混合。两者通过一个薄薄的适配层如EnemyAnimationController脚本进行通信。6. 从“简单”到“可用”一些实用的增强点我们实现的这个状态机是“简单”的但已经具备了核心功能。如果你想让它更加强大和实用可以考虑以下增强状态历史栈 添加一个StackIStateT来记录状态历史。实现一个PushState临时切换到新状态和PopState回到上一个状态的方法。这对于实现“打开子菜单”、“临时打断”等场景非常有用。状态参数传递 修改ChangeState方法允许传递一个object类型的参数到新状态的OnEnter中。例如从Idle切换到MoveToPoint时可以传递目标点的坐标。全局事件系统 引入一个简单的事件系统。状态机可以触发事件如OnStateChanged其他系统如UI、音效可以订阅这些事件实现更松散的耦合。可视化调试 在Unity编辑器的Inspector窗口或Game窗口中显示当前状态名称。这可以通过在Enemy类中添加一个[SerializeField] private string _currentStateName;并在状态切换时更新它来实现对于调试复杂AI至关重要。实现一个有限状态机远不止是写出那几十行核心代码。更重要的是通过这个过程你学会了如何用“状态”的思维来分析和设计复杂的游戏逻辑如何用面向对象的方法将庞大的、面条式的if-else或switch-case代码块重构为一个个职责单一、易于理解和维护的类。这种设计能力会随着你项目复杂度的提升而愈发珍贵。下次当你面对一堆控制角色行为的布尔变量和混乱的条件判断时不妨停下来想一想“是不是该用状态机来整理一下了”