Unity游戏开发:从零实现有限状态机(FSM)框架与AI实战
2026/8/10 5:04:05 网站建设 项目流程

1. 项目概述:为什么你的Unity项目需要一个FSM?

在Unity里做项目,尤其是涉及到角色控制、UI流程、AI行为这些有明确“状态”切换逻辑的部分,你是不是经常写出这样的代码:一堆if-else或者switch-case,散落在Update里,判断条件越来越复杂,加一个新状态就得小心翼翼地在各个地方修改,生怕哪里漏了或者冲突了。代码越写越乱,逻辑越来越难维护,这就是典型的“面条式”状态管理。

FSM,有限状态机,就是来解决这个问题的。它不是什么高深莫测的黑科技,而是一种极其经典、有效的设计模式。你可以把它想象成一个智能的“状态切换器”。一个角色,比如游戏里的敌人,它有“巡逻”、“追击”、“攻击”、“死亡”这几个状态。FSM的核心思想就是:在任意时刻,这个敌人只处于一个确定的状态;每个状态都定义了在这个状态下能做什么(进入、更新、退出);状态之间的切换,则由明确的“条件”来触发

这次我们要做的,就是从零开始,在Unity里搭建一个轻量、清晰、易用的FSM框架。这个框架不依赖任何第三方库,完全由我们自己实现,目的是让你彻底理解FSM的运作原理,并能把它灵活地应用到你的项目中去,无论是控制一个Boss的复杂行为链,还是管理一个登录界面的弹窗流程,都能让代码结构变得清爽。

2. FSM核心架构设计与思路拆解

一个完整的FSM框架,核心就是三个部分:状态(State)状态机(StateMachine)状态转换条件(Transition)。我们的设计目标是:高内聚、低耦合、易扩展。

2.1 状态(State)基类设计

状态不应该只是一个枚举值,而应该是一个拥有行为的对象。我们定义一个抽象的StateBase基类,任何具体状态(如IdleState, RunState)都继承自它。

// StateBase.cs using UnityEngine; public abstract class StateBase { // 状态ID,用于唯一标识和快速比较,通常用枚举或字符串 public int StateID { get; protected set; } // 所属的状态机引用 protected StateMachine stateMachine; // 构造函数,注入状态机 public StateBase(int id, StateMachine machine) { StateID = id; stateMachine = machine; } // 当进入此状态时调用 public virtual void OnEnter() { } // 每帧更新时调用(相当于MonoBehaviour的Update) public virtual void OnUpdate(float deltaTime) { } // 当退出此状态时调用 public virtual void OnExit() { } // 可选:固定时间步更新(相当于FixedUpdate) public virtual void OnFixedUpdate() { } // 可选:后更新(相当于LateUpdate) public virtual void OnLateUpdate() { } }

设计思路解析

  • 虚方法(Virtual Methods)OnEnter,OnUpdate,OnExit等设计为虚方法,而不是抽象方法。这意味着具体状态类可以只重写它关心的方法。比如一个纯粹的过渡动画状态,可能只需要OnEnterOnExit,而不需要OnUpdate。这提供了更大的灵活性。
  • 状态机引用:每个状态都持有其所属状态机的引用。这很关键,因为状态在执行逻辑时,经常需要查询状态机管理的其他数据(如拥有者对象、共享参数),或者在条件满足时,请求状态机切换到另一个状态。
  • DeltaTimeOnUpdate方法传入deltaTime,这是为了与Unity的帧率解耦,确保状态逻辑在不同帧率下的表现一致。

2.2 状态机(StateMachine)控制器设计

状态机是FSM的大脑,它负责管理所有状态的注册、存储当前状态、驱动状态更新、并处理状态切换。

// StateMachine.cs using System.Collections.Generic; using UnityEngine; public class StateMachine { // 状态字典,以状态ID为键快速查找 private Dictionary<int, StateBase> _states = new Dictionary<int, StateBase>(); // 当前状态 private StateBase _currentState; public StateBase CurrentState => _currentState; // 状态机拥有者(例如一个PlayerController或EnemyAI组件) public GameObject Owner { get; private set; } // 一个共享的参数字典,用于在不同状态间传递数据 private Dictionary<string, object> _blackboard = new Dictionary<string, object>(); public StateMachine(GameObject owner) { Owner = owner; } // 注册一个状态到状态机 public void RegisterState(StateBase state) { if (_states.ContainsKey(state.StateID)) { Debug.LogWarning($"State {state.StateID} is already registered."); return; } _states.Add(state.StateID, state); } // 切换到指定状态 public void ChangeState(int stateID) { if (!_states.ContainsKey(stateID)) { Debug.LogError($"State {stateID} is not registered."); return; } StateBase targetState = _states[stateID]; // 如果目标状态就是当前状态,不做任何事(除非你希望重新进入) if (_currentState != null && _currentState.StateID == stateID) { // Debug.Log($"Already in state {stateID}."); return; } // 1. 退出当前状态 _currentState?.OnExit(); // 2. 切换状态引用 StateBase previousState = _currentState; _currentState = targetState; // 3. 进入新状态 _currentState.OnEnter(); // 可以在这里触发一个状态切换事件,方便其他系统监听 // OnStateChanged?.Invoke(previousState?.StateID, _currentState.StateID); } // 状态机更新驱动,需要在MonoBehaviour的Update中调用 public void OnUpdate(float deltaTime) { _currentState?.OnUpdate(deltaTime); } public void OnFixedUpdate() { _currentState?.OnFixedUpdate(); } public void OnLateUpdate() { _currentState?.OnLateUpdate(); } // 黑板(Blackboard)相关方法,用于状态间数据共享 public void SetBlackboardValue<T>(string key, T value) { _blackboard[key] = value; } public T GetBlackboardValue<T>(string key, T defaultValue = default) { if (_blackboard.TryGetValue(key, out object value) && value is T) { return (T)value; } return defaultValue; } public bool HasBlackboardValue(string key) { return _blackboard.ContainsKey(key); } }

设计思路解析

  • 字典存储状态:使用Dictionary<int, StateBase>来存储状态,以状态ID为键。这比用List遍历查找要高效得多,尤其是在状态数量较多时。
  • 明确的切换流程ChangeState方法严格遵循“退出旧状态 -> 切换引用 -> 进入新状态”的流程。这个顺序非常重要,确保了状态生命周期管理的正确性。
  • 黑板系统(Blackboard):这是一个非常实用的设计。状态之间经常需要通信,比如“巡逻状态”发现玩家后,需要告诉“追击状态”玩家的位置。通过一个共享的Dictionary,状态可以安全地读写数据,避免了状态类之间直接的、混乱的引用。这大大降低了耦合度。
  • 驱动更新:状态机本身不继承MonoBehaviour,它只是一个普通的C#类。它的更新需要外部的MonoBehaviour(比如一个StateMachineRunner组件)来驱动。这样设计更灵活,状态机可以存在于任何地方,而不必绑定在游戏对象上。

2.3 状态转换条件(Transition)的抽象

简单的状态机直接在状态类的OnUpdate里判断条件并调用ChangeState。但对于复杂的状态机,转换逻辑可能很复杂,且一个状态可能有多条转换路径。我们可以将“转换条件”也抽象出来。

// TransitionBase.cs public abstract class TransitionBase { public int FromStateID { get; protected set; } public int ToStateID { get; protected set; } protected StateMachine stateMachine; public TransitionBase(int fromState, int toState, StateMachine machine) { FromStateID = fromState; ToStateID = toState; stateMachine = machine; } // 核心方法:检查转换条件是否满足 public abstract bool CheckCondition(); // 当转换即将发生时调用(在OnExit之后,OnEnter之前) public virtual void OnTransition() { } } // 示例:一个基于距离的转换条件 public class DistanceTransition : TransitionBase { private Transform _ownerTransform; private Transform _targetTransform; private float _triggerDistance; private bool _greaterThan; // true表示大于阈值时触发,false表示小于时触发 public DistanceTransition(int fromState, int toState, StateMachine machine, Transform owner, Transform target, float distance, bool greaterThan = true) : base(fromState, toState, machine) { _ownerTransform = owner; _targetTransform = target; _triggerDistance = distance; _greaterThan = greaterThan; } public override bool CheckCondition() { if (_ownerTransform == null || _targetTransform == null) return false; float distance = Vector3.Distance(_ownerTransform.position, _targetTransform.position); if (_greaterThan) { return distance > _triggerDistance; } else { return distance <= _triggerDistance; } } }

设计思路解析

  • 分离关注点:将“状态行为”和“状态转换逻辑”分离。状态类只关心“我在这个状态下做什么”,而转换条件类只关心“什么时候从A状态切换到B状态”。这使得两者都可以独立变化和复用。
  • 可组合的复杂条件:你可以创建AndTransitionOrTransitionNotTransition这样的组合条件类,将简单的条件组合成复杂的逻辑树,极大地增强了表达能力。
  • 状态机集成:转换条件需要由状态机在每帧更新时进行检查。我们可以在状态机里维护一个List<TransitionBase>,然后在OnUpdate中遍历检查。如果某个条件的FromStateID匹配当前状态且CheckCondition()返回true,则执行状态切换。

3. 核心细节解析与实操要点

3.1 状态ID的定义与管理

状态ID是状态机的“钥匙”。如何定义它很有讲究。

方案一:使用枚举(Enum)这是最直观、类型安全的方式。

public enum EPlayerState { Idle = 0, Run, Jump, Attack, Hurt, Die }

优点:编译时检查,智能提示好,代码可读性高。缺点:枚举是值类型,在作为字典键时没问题,但如果你需要动态创建状态(比如从配置表读取),枚举就不太方便。而且枚举定义在一个地方,如果状态种类非常多(比如几十种),维护起来可能有点乱。

方案二:使用字符串(String)

string stateId = “Player_Idle”;

优点:非常灵活,可以动态生成,易于序列化和配置(比如从JSON读取)。缺点:容易拼写错误,且查找效率略低于整型(虽然对于游戏状态数量来说可以忽略不计)。需要自己管理命名规范,避免冲突。

方案三:使用整型常量(int Const)

public static class StateIDs { public const int Player_Idle = 1001; public const int Player_Run = 1002; // ... public const int Enemy_Patrol = 2001; public const int Enemy_Chase = 2002; }

优点:兼具枚举的类型安全(在常量层面)和整型的效率。可以通过数值范围对不同模块的状态进行分组(如1000系列是玩家,2000系列是敌人)。缺点:需要手动维护一个常量类,分配ID不能重复。

我的建议:对于中小型项目,优先使用枚举。它的清晰度和安全性是最高的。只有当你的状态需要高度动态化、可配置时,才考虑使用字符串或整型常量。

3.2 状态机的驱动与生命周期

状态机本身没有MonoBehaviour的生命周期,所以我们需要一个“跑步者(Runner)”来驱动它。通常,我们会创建一个StateMachineComponent脚本,挂载在需要使用状态机的游戏对象上。

// StateMachineComponent.cs using UnityEngine; public class StateMachineComponent : MonoBehaviour { public StateMachine StateMachine { get; private set; } void Awake() { // 初始化状态机,传入拥有者(这个GameObject) StateMachine = new StateMachine(gameObject); // 在这里注册所有状态 InitializeStates(); // 设置初始状态 StateMachine.ChangeState((int)EPlayerState.Idle); } void Update() { // 驱动状态机更新 StateMachine?.OnUpdate(Time.deltaTime); } void FixedUpdate() { StateMachine?.OnFixedUpdate(); } void LateUpdate() { StateMachine?.OnLateUpdate(); } void InitializeStates() { // 创建并注册状态实例 StateMachine.RegisterState(new PlayerIdleState((int)EPlayerState.Idle, StateMachine)); StateMachine.RegisterState(new PlayerRunState((int)EPlayerState.Run, StateMachine)); // ... 注册其他状态 } }

关键点

  • 驱动时机:一定要在Awake中初始化状态机和状态,而不是Start。因为Start的执行顺序可能晚于其他脚本的Update,如果其他脚本在Start里访问状态机,可能它还没初始化。
  • 初始状态:在Awake的最后或Start的开头调用ChangeState设置初始状态。确保所有状态注册完毕后再切换。
  • 多状态机:一个复杂的游戏对象(比如一个拥有技能、移动、情绪等多个维度的RPG角色)可能需要多个状态机来管理不同层面的逻辑。这时你可以创建多个StateMachine实例,分别由不同的StateMachineComponent或同一个组件中的不同字段来管理。

3.3 黑板(Blackboard)系统的深入使用

黑板是状态间通信的桥梁,用好它能极大提升框架的灵活性。

典型使用场景

  1. 传递目标信息:巡逻状态发现敌人后,将敌人的Transform或位置存入黑板(SetBlackboardValue(“TargetEnemy”, enemyTransform))。追击状态和攻击状态都可以读取这个值(GetBlackboardValue<Transform>(“TargetEnemy”))。
  2. 共享计时器:一个“冷却”状态结束后,在黑板设置一个“SkillACooldownOver”=true。技能释放状态在OnUpdate中检查这个标志,为真时才允许释放技能。
  3. 配置参数:可以从外部(如策划配置表)读取一些参数(移动速度、视野范围)存入黑板,所有状态都从这里读取,实现配置与逻辑分离。

注意事项

  • 键名管理:和状态ID一样,黑板键名也建议用常量或枚举来管理,避免魔法字符串。
public static class BlackboardKeys { public const string TargetPosition = “TargetPosition”; public const string IsPlayerInSight = “IsPlayerInSight”; }
  • 类型安全GetBlackboardValue<T>提供了泛型参数和默认值,使用起来比较安全。但要注意,如果你存入的是UnityEngine.Object类型(如GameObject,Transform),在对象被销毁后,取出来的值可能是一个“Missing”的引用,需要做空值判断。
  • 清理:当状态机重置或拥有者被销毁时,记得清空黑板(_blackboard.Clear()),防止旧数据干扰新的逻辑。

4. 实操过程与核心环节实现:一个敌人AI的完整示例

让我们用一个经典的敌人AI(巡逻 -> 追击 -> 攻击 -> 返回巡逻)来串联整个框架的使用。我们会定义状态、转换条件,并组装成一个可运行的状态机。

4.1 步骤一:定义状态枚举和黑板键

// EnemyState.cs public enum EEnemyState { Patrol = 0, Chase, Attack, Return, Hurt } public static class EnemyBlackboardKeys { public const string TargetPlayer = “TargetPlayer”; public const string LastPatrolPosition = “LastPatrolPosition”; public const string AttackCooldown = “AttackCooldown”; }

4.2 步骤二:实现具体状态类

PatrolState(巡逻状态)

public class EnemyPatrolState : StateBase { private Transform _enemyTransform; private float _patrolSpeed; private Vector3[] _waypoints; private int _currentWaypointIndex = 0; private float _waypointThreshold = 0.5f; public EnemyPatrolState(int id, StateMachine machine) : base(id, machine) { _enemyTransform = stateMachine.Owner.transform; // 可以从配置或黑板读取参数 _patrolSpeed = 3.0f; // 简单示例:设置两个巡逻点 _waypoints = new Vector3[] { new Vector3(-5, 0, 0), new Vector3(5, 0, 0) }; } public override void OnEnter() { Debug.Log($“Enemy [{stateMachine.Owner.name}] enters Patrol State.”); // 进入巡逻状态时,记录当前位置作为“返回点” stateMachine.SetBlackboardValue(EnemyBlackboardKeys.LastPatrolPosition, _enemyTransform.position); } public override void OnUpdate(float deltaTime) { // 1. 巡逻逻辑:向当前路点移动 Vector3 targetPos = _waypoints[_currentWaypointIndex]; Vector3 moveDir = (targetPos - _enemyTransform.position).normalized; _enemyTransform.position += moveDir * _patrolSpeed * deltaTime; // 检查是否到达路点 if (Vector3.Distance(_enemyTransform.position, targetPos) < _waypointThreshold) { _currentWaypointIndex = (_currentWaypointIndex + 1) % _waypoints.Length; } // 2. 转换条件检查:发现玩家? // 这里为了示例,我们简单用距离判断。实际项目中会用视野锥、射线检测等。 GameObject player = GameObject.FindGameObjectWithTag(“Player”); if (player != null) { float distToPlayer = Vector3.Distance(_enemyTransform.position, player.transform.position); if (distToPlayer < 10f) // 发现距离 { // 将玩家信息存入黑板 stateMachine.SetBlackboardValue(EnemyBlackboardKeys.TargetPlayer, player.transform); // 请求切换到追击状态 stateMachine.ChangeState((int)EEnemyState.Chase); return; // 注意:切换状态后,当前帧的OnUpdate应该立即停止执行 } } } public override void OnExit() { Debug.Log($“Enemy [{stateMachine.Owner.name}] exits Patrol State.”); } }

ChaseState(追击状态)

public class EnemyChaseState : StateBase { private Transform _enemyTransform; private float _chaseSpeed; private Transform _targetPlayer; public EnemyChaseState(int id, StateMachine machine) : base(id, machine) { _enemyTransform = stateMachine.Owner.transform; _chaseSpeed = 5.0f; } public override void OnEnter() { Debug.Log($“Enemy [{stateMachine.Owner.name}] enters Chase State.”); // 从黑板获取目标玩家 _targetPlayer = stateMachine.GetBlackboardValue<Transform>(EnemyBlackboardKeys.TargetPlayer); if (_targetPlayer == null) { // 如果没有目标,直接退回巡逻 stateMachine.ChangeState((int)EEnemyState.Patrol); } } public override void OnUpdate(float deltaTime) { if (_targetPlayer == null) { stateMachine.ChangeState((int)EEnemyState.Patrol); return; } // 向玩家移动 Vector3 moveDir = (_targetPlayer.position - _enemyTransform.position).normalized; _enemyTransform.position += moveDir * _chaseSpeed * deltaTime; // 检查转换条件 float distToPlayer = Vector3.Distance(_enemyTransform.position, _targetPlayer.position); // 条件1:进入攻击范围 -> 切换到攻击状态 if (distToPlayer < 2f) { stateMachine.ChangeState((int)EEnemyState.Attack); return; } // 条件2:玩家超出追击距离 -> 切换到返回状态 if (distToPlayer > 15f) { // 清除黑板中的目标,因为丢失了 stateMachine.SetBlackboardValue<Transform>(EnemyBlackboardKeys.TargetPlayer, null); stateMachine.ChangeState((int)EEnemyState.Return); return; } } }

AttackState(攻击状态)与 ReturnState(返回状态)的实现逻辑类似,分别是播放攻击动画/造成伤害,以及向LastPatrolPosition移动,到达后切回PatrolState。这里篇幅所限,不再赘述完整代码,但核心结构是一致的。

4.3 步骤三:创建敌人AI控制器并组装状态机

// EnemyAIController.cs using UnityEngine; public class EnemyAIController : MonoBehaviour { private StateMachine _stateMachine; void Awake() { _stateMachine = new StateMachine(gameObject); InitializeStates(); // 初始状态为巡逻 _stateMachine.ChangeState((int)EEnemyState.Patrol); } void Update() { _stateMachine.OnUpdate(Time.deltaTime); } void InitializeStates() { _stateMachine.RegisterState(new EnemyPatrolState((int)EEnemyState.Patrol, _stateMachine)); _stateMachine.RegisterState(new EnemyChaseState((int)EEnemyState.Chase, _stateMachine)); _stateMachine.RegisterState(new EnemyAttackState((int)EEnemyState.Attack, _stateMachine)); _stateMachine.RegisterState(new EnemyReturnState((int)EEnemyState.Return, _stateMachine)); // 可以继续注册 Hurt 状态等 } // 提供一个外部接口,例如被玩家攻击时调用,强制切换到受伤状态 public void TakeDamage() { _stateMachine.ChangeState((int)EEnemyState.Hurt); } }

将这个脚本挂载到一个敌人GameObject上,运行游戏,你就能看到一个具备基本AI行为的敌人在场景中运作。它会在两个点之间巡逻,发现玩家后追击,进入范围则攻击,玩家跑远则返回巡逻点。

5. 常见问题与排查技巧实录

在实际使用自建FSM框架时,你肯定会遇到一些坑。下面是我总结的几个最常见的问题和解决方法。

5.1 问题一:状态切换后,旧状态的逻辑还在执行

现象:从A状态切换到B状态后,控制台还在持续打印A状态的日志,或者A状态的移动等效果没有停止。

原因:这是最经典的问题。在状态A的OnUpdate方法中,你检查了切换条件并调用了stateMachine.ChangeState()。但是,ChangeState方法并不会立即中断当前OnUpdate方法的执行。状态机会在ChangeState内部先调用A状态的OnExit,然后切换当前状态引用为B,再调用B状态的OnEnter。但此时,A状态的OnUpdate方法还在继续执行它ChangeState调用之后的代码

解决方案:在状态类的OnUpdate中,每次调用ChangeState后,必须立即return

public override void OnUpdate(float deltaTime) { // ... 一些逻辑 if (someCondition) { stateMachine.ChangeState(nextStateId); return; // !!!关键:立即返回,避免执行后续代码 } // ... 其他逻辑(切换状态后不会执行到这里) }

5.2 问题二:状态机在切换状态时抛出空引用异常

现象:在ChangeState方法中,调用_currentState?.OnExit()时,如果_currentState为null,没问题。但如果在OnExitOnEnter方法内部,访问了状态机或拥有者的某些未初始化的组件,就会报错。

原因:状态的生命周期方法(OnEnter,OnExit)中,访问了不安全的资源。比如在OnEnter里试图获取一个可能还未被Awake初始化的组件。

解决方案

  1. 惰性初始化:在状态类中,对需要的组件采用GetComponent缓存,但做好空值检查。
    private Rigidbody _rb; private Rigidbody MyRigidbody { get { if (_rb == null) _rb = stateMachine.Owner.GetComponent<Rigidbody>(); return _rb; } } // 使用时:MyRigidbody.AddForce(...);
  2. 确保依赖就绪:在状态机初始化(Awake)时,就确保所有状态依赖的核心组件已经准备就绪。复杂的依赖可以考虑使用依赖注入框架,但对于简单的FSM,在状态构造函数或一个专门的Initialize方法里传递必要参数更直接。

5.3 问题三:多个转换条件同时满足,状态切换不稳定

现象:一帧内,有多个转换条件同时为真,状态可能发生多次切换,或者切换到非预期的状态。

原因:状态机在每帧遍历所有转换条件时,没有定义优先级,或者条件检查的顺序有误。

解决方案

  1. 定义转换优先级:在TransitionBase基类中增加一个Priority属性。状态机在检查转换时,先按优先级排序,只执行优先级最高且条件满足的转换。
  2. 顺序检查:在状态类内部管理转换时(比如在OnUpdate里写多个if),将最特殊、范围最小的条件放在前面判断。例如,“在攻击范围内”比“看到玩家”更特殊,应该先判断。如果先判断“看到玩家”就切到追击,那么即使进入了攻击范围,也因为已经不在巡逻状态的OnUpdate里而无法切换到攻击。
  3. 使用状态转换图工具:对于非常复杂的状态机,可以考虑在编辑器里绘制状态转换图,并明确每条边的优先级,然后通过工具生成代码。这能从根本上避免逻辑冲突。

5.4 问题四:黑板数据管理混乱

现象:不同的状态往黑板里存了同名但类型不同的数据,导致取用时类型转换失败;或者状态结束后忘记清理数据,干扰了后续逻辑。

解决方案

  1. 严格的键名管理:如前所述,使用静态类定义所有可能的键名。
  2. 类型安全的存取方法:我们的GetBlackboardValue<T>已经做了基础的类型检查。可以进一步加强,在Set时也记录类型,在Get时进行更严格的类型匹配。
  3. 状态生命周期绑定清理:可以考虑在状态的OnExit方法中,清理掉由本状态创建的黑板数据。但这需要谨慎设计,因为有些数据可能是为下一个状态准备的。一个更清晰的做法是:约定数据的生产者负责清理。或者,将黑板的生命周期与状态机绑定,状态机重置时清空所有数据。

5.5 性能优化小贴士

  • 避免每帧Find和GetComponent:在状态的OnEnter或构造函数中缓存需要的引用(Transform、Rigidbody、Animator等),而不是在OnUpdate里频繁调用GetComponentGameObject.Find
  • 转换条件检查的优化:不是所有转换条件都需要每帧检查。例如,“冷却时间是否结束”这种条件,可以用协程或计时器触发,而不是每帧去读Time.time。对于距离判断,可以每N帧检查一次,而不是每帧。
  • 状态对象的复用:如果状态是无状态的(即不保存随时间变化的成员变量),或者可以很容易地重置,那么可以考虑使用对象池来复用状态实例,避免频繁的GC Alloc。但对于大多数游戏,状态数量有限,这点优化收益不大,优先保证代码清晰。

从零搭建一个FSM框架的过程,本身就是对状态模式最深刻的学习。它强迫你去思考状态如何划分、如何通信、如何优雅地切换。当你亲手实现并调试通过后,再去使用Asset Store里那些功能强大的FSM插件(如PlayMaker、NodeCanvas)时,你会更清楚它们底层在做什么,也能更好地利用它们。这个自建的轻量框架,足以应对项目中80%的状态管理需求,并且因为完全受你控制,调试和扩展都极其方便。下次当你的Update里又开始出现一堆控制状态的bool标志时,不妨停下来,想想是不是该请出FSM这位老朋友了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询