作为一个常年跟Unity项目死磕的开发者,我对设计模式的态度经历了一个从“背概念”到“真香”的过程。尤其是状态模式,刚开始觉得它不过是把switch-case换了个写法,直到接手了几个被if-else塞满的角色控制器和NPC逻辑,才明白这玩意儿在游戏开发里有多重要。
这篇博文我就围绕Unity里的状态模式,从一个实际项目的演进过程出发,把它的原理、落地姿势、坑点,以及和Animator、异步逻辑的配合讲清楚。内容不整虚的,就是奔着能直接抄作业、能避开我踩过的坑去的。
先说清楚一个事情:状态模式解决的核心问题,不是“让代码看起来高级”,而是消灭那些随着状态数量增长而疯狂膨胀的if-else和switch-case。你用状态模式写一个玩家角色,跟用一堆boolean变量去控制行为,完全不是一回事。
1. 从一段到处是if的动作代码说起
1.1 一个经典的角色控制代码是怎么变成灾难的
假设你要做一个第三人称动作游戏,玩家的角色有这些状态:待机(Idle)、跑步(Run)、攻击(Attack)、翻滚(Roll)、死亡(Dead)。大部分新手写出来的代码长这样:
csharp复制public class PlayerController : MonoBehaviour
{
public float moveSpeed = 5f;
public float rollSpeed = 10f;
private Animator animator;
private bool isAttacking;
private bool isRolling;
private bool isDead;
private bool isMoving;
void Update()
{
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
Vector3 moveDir = new Vector3(h, 0, v).normalized;
// 判断能不能移动
if (!isAttacking && !isRolling && !isDead)
{
if (moveDir.magnitude > 0.1f)
{
isMoving = true;
transform.position += moveDir * moveSpeed * Time.deltaTime;
}
else
{
isMoving = false;
}
}
// 攻击
if (Input.GetKeyDown(KeyCode.J) && !isAttacking && !isRolling && !isDead)
{
isAttacking = true;
animator.SetTrigger("Attack");
}
// 翻滚
if (Input.GetKeyDown(KeyCode.K) && !isAttacking && !isRolling && !isDead)
{
isRolling = true;
animator.SetTrigger("Roll");
}
animator.SetBool("IsMoving", isMoving);
// 处理动画事件来结束攻击
// 处理动画事件来结束翻滚
}
// 这只是一个开始,后面还有受击、格挡、跳跃、下蹲……
}
这个版本大概只有十几个状态,看着还能忍。但如果你继续往里面加状态——受击硬直、格挡、跳跃、飞行、攀爬、游泳、复活,甚至加上AI敌人的巡逻、追击、攻击、逃跑——这个类会膨胀到什么程度?
你会发现,每一个新来的状态都要去修改Update()里的判断逻辑,而且状态的进入条件和退出条件混在一起,改一个地方容易引发其他状态的行为异常。
这就是所谓“状态地狱”的开端。
1.2 为什么状态机会比if-else好用
其实上面的代码里,你已经能看到状态机的雏形了:isAttacking、isRolling这些布尔变量,本质上就是“当前状态”的简陋表示。问题在于,这种表示法没有结构,没有约束。
而真正的状态模式,把“每个状态”抽成一个独立的类,每个类只管自己的行为、进入条件和退出条件。这样带来的直接好处:
- 新增状态时,不需要改动其他状态类的代码,只需要新增一个类,然后在状态入口处注册。
- 每个状态类的逻辑独立,互不干扰,调试时只需要关注当前状态。
- 状态的切换逻辑集中在一个地方(状态机Context),便于梳理和排查。
用一句话概括:状态模式,就是把一堆散落在各处的布尔变量和if判断,收拢成一组有明确边界的行为单元。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 状态模式的Unity实现套路
2.1 状态模式的四个核心角色
状态模式在GoF定义里包含三个核心角色,放到Unity场景里我会再加上一个,一共四个:
| 角色 | 作用 | 在Unity中的落地 |
|---|---|---|
| Context(上下文) | 持有当前状态对象,负责状态切换;对外暴露切换接口 | 通常是一个MonoBehaviour组件,比如PlayerController或EnemyAI |
| State(抽象状态) | 定义状态的行为接口 | 一个抽象类或接口,比如IState |
| ConcreteState(具体状态) | 实现具体的行为逻辑 | 例如IdleState、RunState、AttackState,都是普通C#类 |
| StateMachine(状态机,我加上的) | 管理状态对象,处理切换的边界条件 | 可以跟Context合并,也可以独立出来 |
一个重要的设计决策:状态类不需要继承MonoBehaviour。原因后面详细说,这里先记住,状态类是纯C#类。
2.2 一个干净的状态机框架代码
先给出基础的框架代码,后面会基于它继续演进。
csharp复制// 状态接口
public interface IState
{
void OnEnter();
void OnUpdate();
void OnFixedUpdate();
void OnExit();
}
// 状态机核心
public class StateMachine
{
private IState currentState;
public IState CurrentState => currentState;
public void ChangeState(IState newState)
{
if (newState == null)
{
Debug.LogError("不能切换到空状态");
return;
}
if (currentState == newState)
{
return;
}
currentState?.OnExit();
currentState = newState;
currentState.OnEnter();
}
public void Update()
{
currentState?.OnUpdate();
}
public void FixedUpdate()
{
currentState?.OnFixedUpdate();
}
}
// Context示例:一个可复用的状态机MonoBehaviour
public class PlayerController : MonoBehaviour
{
public StateMachine stateMachine = new StateMachine();
public Animator animator;
public CharacterController controller;
private void Start()
{
// 状态类通过构造函数接收需要的依赖
var idle = new IdleState(this);
var run = new RunState(this);
var attack = new AttackState(this);
stateMachine.ChangeState(idle);
}
private void Update()
{
stateMachine.Update();
}
private void FixedUpdate()
{
stateMachine.FixedUpdate();
}
}
你可能注意到了,我让状态机里的ChangeState做了一个判断:如果当前状态和要切到的状态相同,就直接返回。这是避免重复进入同一个状态导致初始化逻辑重复执行。
2.3 为什么状态类不继承MonoBehaviour
很多Unity新手会问:为什么状态类要搞成纯C#类,而不是直接继承MonoBehaviour然后挂到GameObject上?
原因至少有这几个:
- 性能开销:MonoBehaviour有生命周期回调,Unity引擎会为每个MonoBehaviour实例做管理和分发。状态对象是频繁创建和销毁的,用MonoBehaviour会给引擎增加无谓的负担。
- 实例化太麻烦:MonoBehaviour不能直接用
new创建,必须通过AddComponent()或者从Prefab实例化。状态类多的时候,这种写法会非常啰嗦。 - 状态切换不好管理:如果状态是MonoBehaviour,它天然挂在某个GameObject上,切换状态时要么Destroy、要么SetActive,逻辑会分散到GameObject生命周期里,状态机的统一管理优势就没了。
所以,状态类就用普通C#类,通过构造函数传入需要的依赖(比如Context引用、Animator、Rigidbody等)。这样状态类既轻量,又方便单元测试。
3. 一个实战案例:玩家角色的四状态切换
3.1 需求定义与状态划分
为了让你能落地,我用一个具体的例子走一遍完整流程。假设玩家角色有四个状态:
| 状态 | 进入条件 | 退出条件 |
|---|---|---|
| Idle(待机) | 没有任何移动输入 | 检测到移动输入 |
| Run(跑步) | 有移动输入且角色活着 | 移动输入归零 |
| Attack(攻击) | 按下攻击键且不在攻击中 | 攻击动画播完 |
| Dead(死亡) | 血量归零 | 不可退出 |
这个需求看起来简单,但里面已经有两个“典型状态机知识点”:
- 状态之间的互斥:攻击时不能移动,跑步时不能攻击,这是由状态的进入条件保证的。
- 状态切换依靠事件和条件:比如攻击动画播完后,要通过某种方式通知状态机“攻击结束,可以回待机了”。
3.2 具体状态类的实现
csharp复制public class IdleState : IState
{
private PlayerController player;
public IdleState(PlayerController player)
{
this.player = player;
}
public void OnEnter()
{
player.animator.SetBool("IsMoving", false);
}
public void OnUpdate()
{
// 检测移动输入,满足条件就切换到跑步状态
if (player.InputMoveDir.magnitude > 0.1f)
{
player.stateMachine.ChangeState(new RunState(player));
}
// 检测攻击输入
if (Input.GetKeyDown(KeyCode.J))
{
player.stateMachine.ChangeState(new AttackState(player));
}
// 检测死亡
if (player.IsDead)
{
player.stateMachine.ChangeState(new DeadState(player));
}
}
public void OnFixedUpdate()
{
}
public void OnExit()
{
}
}
public class RunState : IState
{
private PlayerController player;
public RunState(PlayerController player)
{
this.player = player;
}
public void OnEnter()
{
player.animator.SetBool("IsMoving", true);
}
public void OnUpdate()
{
// 处理移动
Vector3 moveDir = player.InputMoveDir;
if (moveDir.magnitude > 0.1f)
{
player.controller.Move(moveDir * player.moveSpeed * Time.deltaTime);
}
else
{
// 输入归零,切回待机
player.stateMachine.ChangeState(new IdleState(player));
}
if (Input.GetKeyDown(KeyCode.J))
{
player.stateMachine.ChangeState(new AttackState(player));
}
if (player.IsDead)
{
player.stateMachine.ChangeState(new DeadState(player));
}
}
public void OnFixedUpdate()
{
}
public void OnExit()
{
}
}
public class AttackState : IState
{
private PlayerController player;
private float attackDuration = 0.5f;
private float timer;
public AttackState(PlayerController player)
{
this.player = player;
}
public void OnEnter()
{
player.animator.SetTrigger("Attack");
timer = 0f;
}
public void OnUpdate()
{
timer += Time.deltaTime;
if (timer >= attackDuration)
{
player.stateMachine.ChangeState(new IdleState(player));
}
}
public void OnFixedUpdate()
{
}
public void OnExit()
{
}
}
public class DeadState : IState
{
private PlayerController player;
public DeadState(PlayerController player)
{
this.player = player;
}
public void OnEnter()
{
player.animator.SetBool("IsDead", true);
player.enabled = false; // 禁用玩家控制,防止后续输入
}
public void OnUpdate()
{
// 死亡状态下不做任何事
}
public void OnFixedUpdate()
{
}
public void OnExit()
{
}
}
3.3 对这段实现的点评:为什么这样写
这套代码有几个地方值得展开说:
每个状态内自行判断切换条件,而不是全部集中在Context里。 这是Game Programming Patterns里推荐的“状态自行决定下一个状态”的写法。它的好处是:状态的进出条件在状态类内部可见,看IdleState.OnUpdate就知道待机状态下能触发哪些行为。
但随之而来的问题是:ChangeState(new RunState(player))每次切换时都new了一个新对象。状态对象如果很轻量(只存一个引用),这样写性能上没问题;但如果状态对象内部有复杂的数据结构,频繁new会带来GC压力。
优化方案是状态对象复用:在Context里预先创建好所有状态实例,切换时直接传入实例而不是新建对象。
csharp复制public class PlayerController : MonoBehaviour
{
public StateMachine stateMachine = new StateMachine();
public IdleState idleState;
public RunState runState;
public AttackState attackState;
public DeadState deadState;
private void Awake()
{
idleState = new IdleState(this);
runState = new RunState(this);
attackState = new AttackState(this);
deadState = new DeadState(this);
}
private void Start()
{
stateMachine.ChangeState(idleState);
}
}
切换代码就变成:
csharp复制player.stateMachine.ChangeState(player.runState);
这样状态对象生命周期跟PlayerController一致,避免了反复创建和销毁。
另一个需要讨论的点:状态里使用Input.GetKeyDown直接判断输入,简单直接,但耦合了输入系统。如果后面想支持手柄、触屏、AI控制,就需要抽一层输入接口。比如在PlayerController里定义一个InputMoveDir属性和IsAttackPressed属性,由外部统一赋值,状态类只消费这些属性,这样状态类就不关心输入来自哪里。
改造后,PlayerController接收输入的代码大致长这样:
csharp复制private void Update()
{
// 从不同输入源收集输入,统一写到这里
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
InputMoveDir = new Vector3(h, 0, v).normalized;
IsAttackPressed = Input.GetKeyDown(KeyCode.J);
stateMachine.Update();
}
对应状态类里的判断就变成:
csharp复制public void OnUpdate()
{
if (player.InputMoveDir.magnitude > 0.1f)
{
player.stateMachine.ChangeState(player.runState);
}
if (player.IsAttackPressed)
{
player.stateMachine.ChangeState(player.attackState);
}
}
这样状态类纯粹依赖于外部提供的状态输入,从架构上讲更加清晰,也方便做AI(把AI决策结果填进这些属性即可)。
4. 状态模式在Unity里的几个进阶玩法
4.1 用枚举 + 状态工厂来管理大量状态
如果你的游戏有十几个状态,每个状态类自己判断切换条件的写法虽然不错,但状态多起来后,ChangeState调用会散布在各个状态类里,后期梳理整个状态流转图会变得困难。
一种常用的优化方案:状态类内部不直接传状态实例,而是传一个枚举值,由Context统一创建或者从字典里获取状态实例。
csharp复制public enum PlayerStateType
{
Idle,
Run,
Attack,
Roll,
Dead
}
public abstract class StateBase : IState
{
protected PlayerController player;
public abstract PlayerStateType StateType { get; }
protected StateBase(PlayerController player)
{
this.player = player;
}
public abstract void OnEnter();
public abstract void OnUpdate();
public abstract void OnFixedUpdate();
public abstract void OnExit();
}
public class PlayerController : MonoBehaviour
{
private Dictionary<PlayerStateType, StateBase> states = new Dictionary<PlayerStateType, StateBase>();
private StateMachine stateMachine = new StateMachine();
private void Awake()
{
states.Add(PlayerStateType.Idle, new IdleState(this));
states.Add(PlayerStateType.Run, new RunState(this));
states.Add(PlayerStateType.Attack, new AttackState(this));
states.Add(PlayerStateType.Roll, new RollState(this));
states.Add(PlayerStateType.Dead, new DeadState(this));
}
public void ChangeState(PlayerStateType newStateType)
{
if (states.TryGetValue(newStateType, out var newState))
{
stateMachine.ChangeState(newState);
}
}
}
这个方案的优点是切换时只需要一个枚举值,写起来简洁,也能通过字典快速查找。缺点是状态类不再能够直接“new”一个目标状态来切换,必须先通过Context暴露的ChangeState(枚举)方法。
从维护角度看,个人推荐的做法是:状态基类里不再持有具体状态依赖,而是让Context做状态的“唯一入口”。这样整个状态流转图,你只需要看Context和相关状态类的枚举定义,就能快速理清。
4.2 分层状态机(Hierarchical State Machine)的思路
状态模式有一个经典痛点:如果“跑步”和“攻击”都属于“生存状态”,而“死亡”完全独立,那么“生存状态下能不能攻击”“生存状态下能不能翻滚”这些逻辑,你怎么处理?
最简单的办法:在RunState里判断能不能攻击,在AttackState里判断能不能翻滚,但这样每个状态类都要复制一整套判断逻辑。
更优雅的方案是分层状态机:
- 顶层状态:Alive(存活)和Dead(死亡)。
- 存活状态下,再细分运动状态:Idle、Run、Attack、Roll。
- “是否存活”这个条件在最外层判断,内层状态不需要关心死亡逻辑。
实现上,可以让状态类有一个“父状态”的概念。状态机在切换子状态时,先确保父状态进入,再进入子状态;状态检查时,如果子状态不处理某个输入,就上抛给父状态。
不过说实话,在Unity日常项目里,分层状态机往往用不上。状态数量如果能控制在10个以内,用普通的状态机加规范的条件判断就够了。如果你发现状态逻辑复杂到必须分层,我建议先去审视一下是不是设计上出了问题,比如把太多不相干的行为塞进了同一个状态机里。
4.3 状态模式与Unity动画系统的配合细节
Unity Animator本身就是一个可视化状态机,它还有Sub-State Machine(子状态机)的概念。很多人有个疑问:既然Animator已经是状态机了,代码里还有必要再用状态模式吗?
我的回答是:有必要,而且它们分工不同。
- Animator管的是“动画播放”这一层:哪个状态播哪个Clip,动画之间的过渡和融合。
- 代码状态机管的是“游戏逻辑”这一层:角色能不能移动、能不能攻击、受击后怎么响应。
一个常见的落地模式是:代码状态机负责逻辑判断,然后通过Animator的SetBool、SetTrigger、SetFloat来驱动动画状态切换;动画播完后,通过Animation Event或者状态回调通知代码状态机“动画播完了”。
比如攻击状态的退出,上面代码用的是定时器(0.5秒后退出),但实际项目中,攻击动画时长可能不是固定的,还受攻速属性影响,定时器写死不合适。更好的做法是:
csharp复制// 在攻击动画的关键帧上挂Animation Event
public void OnAttackAnimationFinished()
{
player.stateMachine.ChangeState(player.idleState);
}
或者在Animator的StateBehaviour里,通过状态退出时机的回调来通知代码层:
csharp复制public class AttackStateBehaviour : StateMachineBehaviour
{
private PlayerController player;
public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
player = animator.GetComponent<PlayerController>();
}
public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
player?.OnAttackAnimationFinished();
}
}
用这套组合拳,动画和逻辑就解耦了。代码状态机不需要关心动画具体时长,动画播完的时机由动画事件通知代码。
4.4 状态类中如何使用协程
状态类不是MonoBehaviour,不能直接StartCoroutine。那如果你的状态类需要处理延时逻辑怎么办?比如受击后0.3秒无敌,或者攻击蓄力0.8秒后释放。
有几种可行方案:
方案一:Context暴露Coroutine接口,状态类通过构造函数拿到Context后调用。
csharp复制public class PlayerController : MonoBehaviour
{
public Coroutine StartStateCoroutine(IEnumerator routine)
{
return StartCoroutine(routine);
}
public void StopStateCoroutine(Coroutine coroutine)
{
StopCoroutine(coroutine);
}
}
public class HitState : StateBase
{
private Coroutine hitInvincibleCoroutine;
public override void OnEnter()
{
player.StartStateCoroutine(HitInvincible());
}
private IEnumerator HitInvincible()
{
player.isInvincible = true;
yield return new WaitForSeconds(0.3f);
player.isInvincible = false;
}
}
方案二:如果你的项目引用了UniTask,状态类可以直接用异步方法处理延时,配合CancellationToken在状态退出时取消。
csharp复制public class HitState : StateBase
{
private CancellationTokenSource cts;
public override void OnEnter()
{
cts = new CancellationTokenSource();
HandleHitInvincible(cts.Token);
}
private async UniTaskVoid HandleHitInvincible(CancellationToken token)
{
player.isInvincible = true;
await UniTask.Delay(300, cancellationToken: token);
player.isInvincible = false;
}
public override void OnExit()
{
cts?.Cancel();
}
}
方案二更现代,但需要引入UniTask库。如果你不想引入额外依赖,用方案一也完全够用。关键点是:协程必须能在状态退出时被正确停止,否则旧状态的协程会跑到新状态里,这是很隐蔽的bug。
5. 容易踩的坑和相应的修复策略
5.1 状态切换的“同帧重复切换”问题
场景:你在Update里先检测攻击键,ChangeState到攻击状态,然后又在同一帧的后续代码里检测到翻滚键,又ChangeState到翻滚状态。结果角色压根没进攻击状态,直接翻滚了。
解决方案有两个方向:
- 状态机内部加同帧切换标记:同一帧只允许一次切换,第二次切换请求记录到待处理列表,下一帧处理。
- 状态类里做最小间隔保护:
AttackState.OnEnter里设一个canInterrupt = false,只有在下一次ChangeState时,检查当前状态是否允许被中断。
我自己更常用第二种,因为是业务逻辑层面控制的,更精准。如果AttackState不允许被打断,就在ChangeState前加判断:
csharp复制public void ChangeState(IState newState, bool forceChange = false)
{
if (currentState is AttackState attackState && !attackState.CanBeInterrupted && !forceChange)
{
return; // 攻击中不可被打断
}
currentState?.OnExit();
currentState = newState;
currentState.OnEnter();
}
5.2 状态A引用状态B,状态B又引用状态A的循环依赖
在状态类自己决定切换状态的写法里,IdleState会引用RunState,RunState又会引用IdleState。如果状态数量多,这种相互引用会让类与类之间形成稠密的关系网,看着不舒服。
解决思路是前面提到的:状态类不直接持有目标状态实例,而是通过Context的ChangeState(枚举)方法切换。这样状态类之间没有直接依赖,只有Context对状态类有一对多依赖。
csharp复制public class IdleState : StateBase
{
public override void OnUpdate()
{
if (player.InputMoveDir.magnitude > 0.1f)
{
player.ChangeState(PlayerStateType.Run);
}
}
}
状态类里不再出现new RunState(player),所有切换都通过枚举和Context入口,整体依赖方向变得清晰。
5.3 状态类的Static数据串状态
如果你的状态类里有一些字段,比如记录当前状态持续时间的stateDuration,请务必保证它在OnEnter里被重置。
这个坑很难查,因为它不是每次必现。比如你从Attack切到Idle,Idle里有个idleDuration字段,如果它在OnEnter时没有清零,那么角色第二次进入闲置状态时,闲置计时会沿用上一次的值,可能导致动画或者某个逻辑提前或延后触发。
所以我的习惯是:状态类里所有运行期可变字段,必须在OnEnter里显式初始化,不依赖于构造函数。
5.4 状态类持有外部引用导致的内存泄漏
状态类如果引用了MonoBehaviour,并且这个MonoBehaviour被销毁了,但状态类还被某个静态集合持有,就会造成“僵尸状态”。
这种情况在UI面板和战斗系统切换时容易遇到。比如一个UI面板的状态机,状态类引用着面板上的按钮、Text组件,面板销毁后,状态类里的引用就悬空了。
我的建议:状态类不要用静态容器保存,也不要在状态类中直接订阅外部事件。如果确实需要事件驱动(比如“受到伤害”事件触发受击状态),就让Context在OnDestroy时反注册:
csharp复制private void OnDestroy()
{
if (health != null)
{
health.OnDamaged -= HandleDamaged;
}
}
状态类里持有的MonoBehaviour引用,也需要在OnExit或Context销毁时置空,不让悬空引用长期存在。
5.5 过度使用状态模式
状态模式也不是银弹。如果你的状态只有两个,比如“开”“关”,用状态模式反而多此一举。这时候一个bool变量加几个if判断,比什么抽象都直观。
我建议的判定标准是:
- 状态数量在3个及以下,且状态之间切换逻辑简单,直接用枚举加switch,别过度设计。
- 状态数量在4到10个左右,且每个状态有各自的行为逻辑,用状态模式价值明显。
- 状态数量超过10个,或者状态之间的切换关系复杂,建议考虑分层状态机或者行为树。
记住一个原则:设计模式的目标是降低复杂度,而非增加抽象层。 如果引入状态模式后,项目里多出几十个类,但代码总体清晰度没有提升,那就是过度设计。
6. 状态模式之外:一个值得尝试的调试工具方案
状态模式在Unity里用起来还有一个痛点:状态切换过程很难可视化。你只能靠日志输出或者断点来观察状态流转,但状态多的时候,日志刷屏根本看不过来。
我后来在项目里做了一个简单的可视化方案:在Context里维护一个状态历史栈,然后用OnGUI或者在Inspector里显示当前状态:
csharp复制public class PlayerController : MonoBehaviour
{
[SerializeField]
private string currentStateName;
private void Update()
{
stateMachine.Update();
currentStateName = stateMachine.CurrentState?.GetType().Name ?? "None";
}
}
这样在运行时可以直接在Inspector里看到当前状态名,排查“角色到底卡在哪个状态”的问题效率高很多。更进一步,你可以把所有状态切换记录按时间顺序输出到一个文件里,跑完一段流程后回看状态变迁记录。
我还尝试过把状态切换事件发到Unity的Profiler的Custom Sampler里,这样能在Profiler的Timeline视图上看到状态切换的时机和耗时。做法不算复杂,就是在ChangeState里加上一次采样标记:
csharp复制public void ChangeState(IState newState)
{
using (new ProfilerMarker("StateChange_" + newState.GetType().Name).Auto())
{
currentState?.OnExit();
currentState = newState;
currentState.OnEnter();
}
}
这个方案在排查“某帧卡顿是否由状态切换引起”时起到了很好的辅助作用。
7. 一个值得沉淀的架构整理:把状态机做成通用组件
如果你手里同时在做好几个项目,建议直接把状态机框架抽成一个通用组件,内置到你的工具库里。这个组件大概包含这些内容:
IState接口:OnEnter/OnUpdate/OnFixedUpdate/OnExitStateMachine类:状态切换、同状态保护、强制切换、当前状态查询StateBase抽象类:持有Context引用,提供便捷方法- 日志/可视化辅助:状态名显示、状态切换记录
- 扩展方法:配合UniTask的CancelToken支持
把这个框架固定下来之后,新项目里加一个新玩法、新AI,只需要写新的状态类,然后注册进Context即可,整个流程非常顺畅。
另外提醒一下,状态机的框架里一定要给ForceSwitch留一个接口。这种接口在处理“系统级状态”(比如玩家死亡、游戏暂停)时非常管用——不管当前角色在做什么动作,强制切到目标状态,再做一些清理逻辑。没有这个接口,你可能会为了一个“强制打断”的需求把状态机的保护逻辑绕得乱七八糟。
基于我个人经验,状态模式在Unity里的核心价值在于把一个复杂角色的行为拆成独立模块,每个模块有清晰的边界和切换逻辑。在实际项目中,它不仅能用在玩家角色上,NPC的AI决策、UI面板的页面流转、怪物Boss的技能阶段,甚至音频系统里的播放模式切换,都能用同一套状态机思想去组织。
写到最后,分享一个小技巧:写状态类的时候,尽量保证每个状态类的代码量控制在100行以内。如果一个状态的逻辑超过这个体量,考虑是否应该拆成更细的子状态,或者用状态内组合的方式(比如把移动、转向、攻击判定都抽成独立组件)去降低状态类的复杂度。状态模式原本的意图就是简化逻辑,可别把它写成了另一个大泥球。
