平时做Unity开发,接触最多的设计模式之一就是状态模式。尤其在控制角色行为、敌人AI、UI界面切换时,状态管理一旦写不好,后面就是无尽的if-else地狱和Bug温床。网上讲状态模式的资料很多,但大多停留在理论UML和demo层面,真正能照着落地到Unity项目里的完整案例反而不多。这篇我结合自己做项目的经验,把状态模式从概念到实战完整拆解一遍,重点放在“为什么这么设计”和“Unity里怎么用才不别扭”上。
1. 认识状态模式:它到底解决了什么问题
先说个场景。你做一款2D横版动作游戏,玩家角色有Idle、Walk、Jump、Attack四个状态。用最朴素的方式写:
csharp复制public class PlayerController : MonoBehaviour
{
public enum PlayerState { Idle, Walk, Jump, Attack }
public PlayerState currentState;
void Update()
{
switch (currentState)
{
case PlayerState.Idle:
// 待机逻辑
break;
case PlayerState.Walk:
// 移动逻辑
break;
case PlayerState.Jump:
// 跳跃逻辑
break;
case PlayerState.Attack:
// 攻击逻辑
break;
}
}
}
这个写法在状态少时完全够用,甚至可以说是最直接的思维表达。但一旦状态多起来,比如加个Hurt(受击)、Dead(死亡)、Roll(翻滚)、Climb(攀爬),每个状态里还要处理动画切换、音效播放、输入响应、物理效果……这个Update函数会膨胀到几百行。而且最麻烦的是,状态之间的切换逻辑会散落在各个case分支里,状态越多,越难让人看懂整个行为的流转规则,改一个状态经常会影响另外几个状态。
状态模式的思路就是把这些“状态”变成一个个独立的类,每个状态自己负责自己的行为逻辑和切换条件。原来的MonoBehaviour只保留一个“当前状态”的引用,把行为和状态流转决策交给各个状态类自己处理。这样一来:
- 每个状态独立成类,逻辑隔离,新增状态不影响旧状态;
- 切换条件收敛在各个状态类内部,不会散落一处;
- 复合状态、层级状态、状态历史记录这些复杂需求都有扩展空间。
我在用的过程中最大的感触是:状态模式不是消灭了状态间的耦合,而是把耦合转移到了可以控制的地方。状态类之间不直接互相引用,而是通过上下文通知切换,依赖关系变得清晰。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 状态模式在Unity中的落地:一个可直接套用的战斗角色框架
先明确一个认知:Unity的Animator本身也内置了状态机,甚至叫“状态机”而不用“状态模式”——这是两回事。状态模式是代码层面的设计模式,Animator是资源层面的可视化状态机,两者并不冲突,而且实际项目中经常配合使用:Animator负责表现层(播放什么动画、动画过渡),状态模式负责逻辑层(当前处于什么行为状态、怎样切换)。这个后面单独说。
下面给出一个我在ARPG项目里实际用过的状态模式框架。核心由三部分组成:抽象状态类、具体状态类、状态持有者(上下文)。
2.1 定义抽象状态基类
状态基类的设计是整个框架的核心。我见过很多版本把状态方法抽成Init、OnEnter、OnUpdate、OnExit,我的做法是再加一个FixedUpdate,因为Unity中物理相关操作必须在FixedUpdate里做,比如移动、跳跃、受力。
csharp复制public abstract class PlayerStateBase
{
protected PlayerController player;
public PlayerStateBase(PlayerController player)
{
this.player = player;
}
/// <summary>
/// 进入状态时调用,一般用于初始化动画、播放音效
/// </summary>
public virtual void OnEnter() { }
/// <summary>
/// 状态内的帧更新逻辑
/// </summary>
public virtual void OnUpdate() { }
/// <summary>
/// 状态内的物理更新逻辑
/// </summary>
public virtual void OnFixedUpdate() { }
/// <summary>
/// 状态内的LateUpdate逻辑
/// </summary>
public virtual void OnLateUpdate() { }
/// <summary>
/// 离开状态时调用,用于清理资源、停止位移等
/// </summary>
public virtual void OnExit() { }
}
注意一点:为什么状态类里要持有PlayerController的引用?因为状态类需要访问玩家的输入、刚体、动画器等数据。直接持有外部引用是最快最简单的方式。等后续有更多复杂需求,可以再抽一个StateContext(上下文)来封装数据,但前期没必要过度设计。
2.2 实现具体状态类
以IdleState和WalkState为例:
csharp复制public class PlayerIdleState : PlayerStateBase
{
public PlayerIdleState(PlayerController player) : base(player) { }
public override void OnEnter()
{
player.Anim.SetBool("IsMove", false);
}
public override void OnUpdate()
{
// 检测输入,有输入则切换为移动
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
if (h != 0 || v != 0)
{
player.ChangeState(PlayerStates.Walk);
return;
}
// 检测跳跃
if (Input.GetKeyDown(KeyCode.Space))
{
player.ChangeState(PlayerStates.Jump);
}
}
}
public class PlayerWalkState : PlayerStateBase
{
public PlayerWalkState(PlayerController player) : base(player) { }
public override void OnEnter()
{
player.Anim.SetBool("IsMove", true);
}
public override void OnUpdate()
{
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
if (h == 0 && v == 0)
{
player.ChangeState(PlayerStates.Idle);
return;
}
// 跳跃切换
if (Input.GetKeyDown(KeyCode.Space))
{
player.ChangeState(PlayerStates.Jump);
}
}
public override void OnFixedUpdate()
{
// 基于输入移动角色
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
player.Move(h, v);
}
}
写到这里你可能发现了,状态类本质上就是在响应“什么时候该切换状态”这件事情。每个状态内部只关心自己该干什么、什么时候该走人,不管别人怎么样。
2.3 上下文类:PlayerController
PlayerController承担两个角色:一方面持有角色组件(Rigidbody、Animator等),另一方面管理和调度状态切换。
csharp复制public class PlayerController : MonoBehaviour
{
[Header("Movement Settings")]
public float moveSpeed = 5f;
public float jumpForce = 8f;
[Header("Components")]
public Animator Anim;
public Rigidbody Rb;
// 状态存储容器
private Dictionary<PlayerStates, PlayerStateBase> stateDic;
private PlayerStateBase currentState;
public enum PlayerStates
{
Idle,
Walk,
Jump,
Attack
}
private void Awake()
{
// 先获取组件
Anim = GetComponent<Animator>();
Rb = GetComponent<Rigidbody>();
// 初始化状态字典
stateDic = new Dictionary<PlayerStates, PlayerStateBase>
{
{ PlayerStates.Idle, new PlayerIdleState(this) },
{ PlayerStates.Walk, new PlayerWalkState(this) },
{ PlayerStates.Jump, new PlayerJumpState(this) },
{ PlayerStates.Attack, new PlayerAttackState(this) }
};
}
private void Start()
{
// 默认进入Idle状态
ChangeState(PlayerStates.Idle);
}
private void Update()
{
currentState?.OnUpdate();
}
private void FixedUpdate()
{
currentState?.OnFixedUpdate();
}
private void LateUpdate()
{
currentState?.OnLateUpdate();
}
/// <summary>
/// 统一的切换状态入口
/// </summary>
public void ChangeState(PlayerStates newState)
{
if (currentState != null)
{
currentState.OnExit();
}
currentState = stateDic[newState];
currentState.OnEnter();
}
}
这里有个看似微小的设计点:为什么用Dictionary存状态实例,而不是每次new一个?原因有两点。第一是状态的创建成本(虽然类实例化很便宜,但反复new出对象会产生GC,在移动端、密集战斗场景下能省就省);第二是状态类内部可能持有临时数据,比如连续攻击的连击计数、跳跃的滞空时间,频繁重建会丢失这些数据。
有时候需要在状态类里访问其他状态,或者在不改变当前状态的情况下获取状态信息,这时字典的value可以换成接口或基类引用,通过类型判断来获取,读者可以按需调整。
3. 状态切换的细节管理:什么时候不该直接切状态
状态模式的经典UML会告诉你状态类“持有”上下文,并且状态类调用上下文的SetState方法。实际项目中,状态切换还可能涉及打断条件、优先级、退出条件、状态互斥等,一个ChangeState方法往往不够,有几个经验值得分享。
3.1 受击、死亡这类高优打断状态
角色在攻击、跳跃或奔跑时被敌人击中,如果当前状态不允许被打断,应该怎么办?游戏里的做法通常是:受击和死亡拥有最高优先级,可以打断一切状态。所以ChangeState不能只是一句赋值,需要在里面加一些判断。
我常用的方法是给状态类加一个CanBreakLevel(打断等级)属性,上下文在切换时检查目标状态的打断等级是否高于当前状态,或者当前状态是否允许被这个目标打断:
csharp复制public abstract class PlayerStateBase
{
public virtual int InterruptPriority => 0; // 默认不被高优先级打断
// 表示当前状态是否允许被某个目标状态打断
public virtual bool AllowTransition(PlayerStates targetState)
{
return true;
}
}
public void ChangeState(PlayerStates newState, bool force = false)
{
if (!force && currentState != null)
{
// 是否允许切换
if (!currentState.AllowTransition(newState))
return;
}
// 通知原状态退出
currentState?.OnExit();
// 切换状态
currentState = stateDic[newState];
currentState.OnEnter();
}
比如攻击状态,一般来说,普通攻击的“起手帧”不允许被下一次攻击打断(防止连击输入导致攻击动画瞬切),但可以被受击打断。这个逻辑放在AttackState里最合适:
csharp复制public class PlayerAttackState : PlayerStateBase
{
private bool canInterrupt = false;
public override void OnEnter()
{
// 攻击动画播放中,前30%处于“不可取消”阶段
canInterrupt = false;
player.Anim.Play("Attack01");
player.Anim.SetBool("IsAttacking", true);
}
public override void OnUpdate()
{
// 动画播放进度超过一定比例后再允许打断
float progress = player.Anim.GetCurrentAnimatorStateInfo(0).normalizedTime;
if (progress > 0.3f) canInterrupt = true;
}
public override bool AllowTransition(PlayerStates targetState)
{
// 只有在canInterrupt后才允许切到其他状态
if (targetState == PlayerStates.Attack && !canInterrupt)
return false;
return true;
}
public override void OnExit()
{
player.Anim.SetBool("IsAttacking", false);
}
}
这种“状态内部自己决定是否允许被切换”的方式,比在PlayerController统一判断更合理,因为每个状态的行为属性只有自己最清楚。
3.2 状态切换时机的顺序问题
状态切换的时序其实分三段:先调用当前状态OnExit,再替换currentState,后调用新状态OnEnter。这段顺序千万别弄反。我踩过一次坑:在某个状态的OnExit里调用了ChangeState,结果新状态还没进来,就又开始切了,导致状态错乱。
正确做法:OnExit里只做清理,不做切换动作。切换全部由外部的输入或碰撞检测统一触发。如果你确实需要“某状态结束时自动切到另一个状态”,那应该放在OnUpdate里检测结束条件并调用ChangeState,而不是在OnExit里调用。
3.3 空状态与默认状态的处理
PlayerController的Start里要确保有默认状态,我的习惯是设一个初始状态Idle,同时打印Debug.Log确认状态被正常初始化。空状态会引发空引用,这是低级的错误,但在多人协作改代码时经常出现,所以我在ChangeState里做了安全校验:
csharp复制public void ChangeState(PlayerStates newState)
{
if (!stateDic.ContainsKey(newState))
{
Debug.LogError($"State {newState} not found!");
return;
}
// ...
}
4. 状态模式与Unity Animator的配合:逻辑状态机与动画状态机怎么协作
Unity的Animator是可视化的有限状态机,很适合做动画层的管理。日常开发中最常见的问题是:“动画我已经挂到Animator上了,逻辑状态机还有必要吗?”“两者怎么同步?”
我的结论:动画状态机只负责播放动画和动画过渡,逻辑状态机负责控制游戏逻辑。两者可以并行不悖。具体落地有两种常见做法。
4.1 以Animator为主,代码只切换Trigger和Bool
适合简单角色:通过SetBool、SetTrigger切换动画,行为本身差异不大。代码量少的场景,连状态模式都可以不用,直接在Update里控制Animator参数。
csharp复制// 简化版:直接控制Animator
void Update()
{
animator.SetFloat("Speed", inputMagnitude);
if (Input.GetKeyDown(KeyCode.Space))
{
animator.SetTrigger("Jump");
}
}
4.2 以状态模式为主,Animator只是“表现工具”
适合复杂角色:逻辑状态决定一切,Animator只负责配合播放对应动画。状态类的OnEnter里调用player.Anim.Play("StateName")或设置Bool,比如上面的PlayerIdleState就设置了IsMove。
需要额外提醒的是,使用Anim.Play()时要注意动画名称和层的问题。多层的Animator(比如Base Layer + Upper Body Layer)在调用Play时如果不指定层,默认在第一层播放,可能不是你想要的。一般我会用状态类的OnEnter里同时指定层号和动画名:
csharp复制public override void OnEnter()
{
int baseLayer = player.Anim.GetLayerIndex("Base Layer");
player.Anim.Play("Idle", baseLayer, 0f);
}
两者协同的另一个要点是:不要在两个系统里都维护状态。比如“是否在移动”这个信息,不要在状态模式里用isMoving标记,又在Animator里用IsMove的Bool来控制动画过渡,这样数据不一致时会极其难查。正确的是:逻辑状态是唯一事实源,动画参数跟随逻辑状态的变化统一写入。
5. 状态模式实战案例:一个完整的AI敌人状态机
上面写的是玩家控制类状态,下面写一个更典型的AI敌人状态机。AI状态机的状态通常包含:巡逻(Patrol)、警觉(Alert)、追逐(Chase)、攻击(Attack)、受击(Hurt)、死亡(Dead)。核心区别在于触发方式不同,玩家状态靠输入切换,敌人靠感知条件切换。
5.1 定义敌人状态
csharp复制public abstract class EnemyStateBase
{
protected EnemyController enemy;
public EnemyStateBase(EnemyController enemy)
{
this.enemy = enemy;
}
public virtual void OnEnter() { }
public virtual void OnUpdate() { }
public virtual void OnExit() { }
}
5.2 实现巡逻和追逐
csharp复制public class EnemyPatrolState : EnemyStateBase
{
private float waitTimer;
private Vector3 destination;
public EnemyPatrolState(EnemyController enemy) : base(enemy) { }
public override void OnEnter()
{
waitTimer = 0f;
destination = enemy.GetRandomPatrolPoint();
enemy.Agent.SetDestination(destination);
}
public override void OnUpdate()
{
// 检测玩家是否在视线范围内
if (enemy.CanSeePlayer())
{
enemy.ChangeState(EnemyStates.Chase);
return;
}
// 到达目的地,等待一段时间后再去下一个点
if (enemy.Agent.remainingDistance < 0.5f)
{
waitTimer += Time.deltaTime;
if (waitTimer >= enemy.patrolWaitTime)
{
waitTimer = 0f;
destination = enemy.GetRandomPatrolPoint();
enemy.Agent.SetDestination(destination);
}
}
}
}
public class EnemyChaseState : EnemyStateBase
{
public EnemyChaseState(EnemyController enemy) : base(enemy) { }
public override void OnEnter()
{
enemy.Agent.speed = enemy.chaseSpeed;
}
public override void OnUpdate()
{
if (!enemy.CanSeePlayer())
{
// 玩家丢失视野,回到巡逻
enemy.ChangeState(EnemyStates.Patrol);
return;
}
if (enemy.IsPlayerInAttackRange())
{
enemy.ChangeState(EnemyStates.Attack);
return;
}
enemy.Agent.SetDestination(enemy.Player.position);
}
public override void OnExit()
{
enemy.Agent.speed = enemy.patrolSpeed;
}
}
这里用到了NavMeshAgent,目的地更新的频率过高会导致性能问题。一般我隔几帧或者每0.2秒才更新一次目标点,而不是每帧都调用SetDestination。这个优化在敌人数量多时特别明显。
5.3 状态优先级与过渡条件
AI比玩家状态更复杂的地方在于,攻击、受击、死亡这些状态都可能打断当前行为。比如在追逐过程中被玩家反打,敌人应该立即进入受击动画。这时候就涉及状态打断优先级。简单实现可以在EnemyController里维护一张打断权限表:
csharp复制private Dictionary<EnemyStates, int> interruptPriority = new Dictionary<EnemyStates, int>()
{
{ EnemyStates.Patrol, 0 },
{ EnemyStates.Chase, 1 },
{ EnemyStates.Attack, 2 },
{ EnemyStates.Hurt, 3 },
{ EnemyStates.Dead, 4 }
};
在ChangeState时对比目标状态的优先级和当前状态的优先级:
csharp复制public void ChangeState(EnemyStates newState)
{
int currentPriority = currentState != null ? interruptPriority[currentStateType] : -1;
int newPriority = interruptPriority[newState];
if (newPriority < currentPriority)
return;
if (currentState != null)
currentState.OnExit();
currentStateType = newState;
currentState = stateDic[newState];
currentState.OnEnter();
}
这样死亡状态最高,任何情况下可以切换;受击次之,无法被巡逻这些低优状态打断;攻击只允许被受击和死亡打断。注意这个优先级表的设计是完全业务定的,你说“攻击中不能被受击打断”也可以,根据手感需求调整。
5.4 状态携带数据的生命周期
AI里有一种常见情况:从Chase切到Attack时,攻击目标的信息需要被携带过去。如果状态类是每次new的话,可以使用构造函数参数;如果用字典复用,则可以通过上下文额外传参。
我一般会保留一个简单的过渡数据袋:
csharp复制public class StateTransitionData
{
public Transform target;
public Vector3 hitPoint;
public float damage;
}
ChangeState方法可以带一个可选的transitionData,状态类里可以读取它:
csharp复制public void ChangeState(EnemyStates newState, StateTransitionData data = null)
{
currentState?.OnExit();
currentStateType = newState;
currentState = stateDic[newState];
currentState.ReceiveData(data);
currentState.OnEnter();
}
这种方式能很好地避免状态类之间互相引用造成的高耦合。状态类不需要知道是哪个状态切过来的,只需要拿到自己需要的数据即可。
6. 状态模式在UI界面管理中的妙用
除了角色和AI,状态模式在UI界面管理上也很好用。比如有主菜单(MainMenu)、设置(Settings)、加载(Loading)、游戏中(InGame)、暂停(Pause)这些界面状态,每个状态对应不同的界面显示和交互逻辑。
用状态模式管理UI有个好处:界面切换的上下文状态能很清晰地管理,而且不会出现A界面关闭B界面时忘了恢复某个按钮状态、忘了暂停音频之类的低级错误。
csharp复制public abstract class UIStateBase
{
protected UIController ui;
public UIStateBase(UIController ui) { this.ui = ui; }
public virtual void OnEnter() { }
public virtual void OnUpdate() { }
public virtual void OnExit() { }
}
public class UIInGameState : UIStateBase
{
public UIInGameState(UIController ui) : base(ui) { }
public override void OnEnter()
{
ui.InGamePanel.SetActive(true);
ui.SettingsPanel.SetActive(false);
Time.timeScale = 1f;
}
public override void OnExit()
{
ui.InGamePanel.SetActive(false);
}
}
public class UIPauseState : UIStateBase
{
public UIPauseState(UIController ui) : base(ui) { }
public override void OnEnter()
{
ui.PausePanel.SetActive(true);
Time.timeScale = 0f;
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
}
public override void OnExit()
{
ui.PausePanel.SetActive(false);
Time.timeScale = 1f;
}
}
UI场景里的状态切换要注意:频繁SetActive的GC开销问题不大,但要注意事件监听是否重复注册,每次OnEnter时如果做AddListener,OnExit时要记得RemoveListener,否则会出现事件叠加。这个坑在UI状态管理里特别常见。
7. 状态模式的进阶玩法:层级状态机、推栈状态机、异步状态机
状态模式基础版用熟之后,还能做几个升级改造来适配更复杂的项目需求。
7.1 层级状态机(HFSM)
有时候一个状态内部还可以继续细分。比如“移动”状态内部可以有“走路”和“跑步”两个子状态。层级状态机就是允许状态内嵌套状态。实现方式是在基类里增加子状态机的引用,父状态的OnUpdate里顺便驱动子状态:
csharp复制public class PlayerMoveState : PlayerStateBase
{
private PlayerStateBase subState;
public PlayerMoveState(PlayerController player) : base(player)
{
subState = new PlayerWalkState(player);
}
public override void OnUpdate()
{
// 父状态的Update
subState?.OnUpdate();
}
public override void OnFixedUpdate()
{
subState?.OnFixedUpdate();
}
}
好处是状态复用性高,坏处是逻辑层级变深,排查问题时要多一层心智负担。没有充足必要不建议一上来就上HFSM。
7.2 推栈状态机(Pushdown State Machine)
这个在解决“返回上一个状态”的场景特别有用。比如玩家在攻击状态时受到某种控制,进入眩晕,眩晕结束想回到攻击状态继续——用推栈状态机就可以:
csharp复制private Stack<PlayerStateBase> stateStack = new Stack<PlayerStateBase>();
public void PushState(PlayerStateBase newState)
{
currentState?.OnExit();
stateStack.Push(newState);
currentState = newState;
currentState.OnEnter();
}
public void PopState()
{
currentState.OnExit();
stateStack.Pop();
currentState = stateStack.Peek();
currentState.OnEnter();
}
游戏里的“暂停菜单叠加在游戏界面之上,关闭后回到游戏界面”,用推栈状态机非常直观。
7.3 异步状态:状态和协程结合
有些状态需要等待一段时间才能切换,比如攻击后摇0.5秒、死亡动画播完才销毁。这时在状态类里直接用协程很自然,但要注意状态退出的时候要把协程停掉,否则协程还在跑就会操作已经退出的状态。
csharp复制public class PlayerAttackState : PlayerStateBase
{
private Coroutine attackCoroutine;
public override void OnEnter()
{
attackCoroutine = player.StartCoroutine(AttackRoutine());
}
private IEnumerator AttackRoutine()
{
player.Anim.Play("Attack");
yield return new WaitForSeconds(0.4f);
// 产生伤害判定
player.ApplyDamageToTarget();
yield return new WaitForSeconds(0.2f);
player.ChangeState(PlayerStates.Idle);
}
public override void OnExit()
{
if (attackCoroutine != null)
{
player.StopCoroutine(attackCoroutine);
attackCoroutine = null;
}
}
}
协程放在PlayerController上还是状态类自身里都可以,但重点记住:状态退出时停止协程,这个细节能避免90%的“角色都死了攻击还在继续”的Bug。
8. 状态模式 vs 其他常见Unity状态管理写法
在Unity社区里,状态管理还有几种常见做法,也是面试里经常被拿来对比的,这里对比一下各自的适用场景。
8.1 switch-case/if-else
最简单的写法。适合状态数量极少(3个以内),状态内部逻辑简单,切换逻辑不复杂的场景。优点是直观、调试简单;缺点是状态多了以后维护成本指数上升。
8.2 Animator StateMachineBehaviour
Unity的Animator里可以挂StateMachineBehaviour脚本,在动画状态进入、退出时执行代码。这种做法把状态管理和动画绑定在一起,写简单AI非常方便,因为不用自己写状态切换,全都在Animator的Transition里配。
但它的局限性也很明显:StateMachineBehaviour的生命周期和动画状态绑定得太紧,逻辑状态和动画状态被强制耦合。如果逻辑状态和动画状态不对等,需要同时维护的对应关系就会变得很复杂。我的经验是:逻辑复杂时不要用StateMachineBehaviour做主要状态管理,它可以用来做一些“动画事件”层面的事,比如播放脚步声、播放入场特效。
8.3 FSM框架插件
像PlayMaker、X Node等可视化状态机插件。适合策划配置为主的项目,但代码控制力度弱、版本管理diff困难、性能开销也更大。用不用取决于团队构成和项目规模。
8.4 状态模式
适合逻辑复杂、状态多、需要长期迭代维护的项目。它本身不依赖Unity的特性,可以很好地配合MonoBehaviour、Job System、DOTS等一起使用。状态模式也不是万金油,状态类过多后管理成本会上来,但配合前面说的优化技巧(字典缓存、状态优先级、数据袋)完全可以应对绝大多数游戏场景。
9. 面试角度:状态模式的高频考点与回答思路
Unity面试里状态模式几乎是必问——因为游戏开发里状态管理实在太日常了。总结一下面试官通常关心的几个点。
9.1 状态模式的定义和UML结构
回答要点:状态模式允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。核心三个角色:Context(上下文)、State(抽象状态)、ConcreteState(具体状态)。Context持有State引用,State的抽象方法在ConcreteState中实现,状态切换由Context调用ChangeState完成。
9.2 状态模式与策略模式的区别
这题非常高频。状态模式和策略模式在类图上几乎一模一样,但语义完全不同:
- 策略模式:客户端主动选择算法(比如排序策略、移动策略),状态的选择由客户端决定,策略对象不关心彼此之间的切换;
- 状态模式:状态之间的切换是内部自动完成的(通过条件判断),上下文不感知切换逻辑,每个状态自己决定下一个状态是谁。
通俗说:策略是让程序员选算法,状态是让对象自己变行为。
9.3 状态模式在Unity里的具体应用场景
可以结合项目经历讲:角色控制、敌人AI、UI状态、场景加载状态、连击状态、技能释放状态等。能讲清楚怎么和Animator配合、怎么处理状态打断优先级、怎么解决状态切换时的GC问题,就会显得很接地气。
9.4 状态模式的优缺点
优点:
- 单一职责:每个状态类只管自己的逻辑;
- 开闭原则:新增状态不用改其他状态类;
- 消除大量if-else,切换逻辑更清晰;
- 状态行为可以被复用。
缺点:
- 状态多时类数量膨胀;
- 状态间的流转逻辑分散在各个状态类里,全局流转视图不容易一眼看清;
- 如果状态内部耦合了上下文大量数据,类结构会变得更复杂。
完整的答案最好再带上“如何在项目中规避缺点”——比如用Dictionary缓存状态实例、集中定义状态类型枚举、用状态优先级表控制打断等。
10. 状态模式的坑与避坑经验:从维护视角总结
最后分享一些我踩过的坑和长期维护状态模式项目的心得。
10.1 状态类的字段污染
状态类一旦复用了实例,它的内部字段就需要在OnEnter里重置。不然下一个状态对象可能带着上一个状态的残留数据。老手也容易在这个地方翻车。所以我的习惯是:所有状态内字段的初始化都放进OnEnter,而不是构造函数。
10.2 状态切换函数的多线程/Event调用问题
在Unity里,状态切换的触发可能来自各种时机:Update里、协程里、动画事件里、碰撞回调里。如果连续两帧触发多次ChangeState,但某个状态要求不能被重复进入,会导致行为异常。可以在ChangeState里判断重复状态:
csharp复制if (currentStateType == newState)
return;
但注意,有些情况下同一个状态是可以重复进入的,比如连续攻击时C1→C2→C3是三个不同的攻击状态,此时就不是同一个状态了,不影响。统一判断是否跳过相同状态时,要特定处理expose重复进入需求。
10.3 状态切换时组件引用的安全校验
状态类卸载或角色销毁时,如果还有状态协程在跑,访问player.Anim会抛MissingReferenceException。在OnExit里清掉协程和引用是个好习惯,另外在关键访问前加个if (player == null) return兜底。
10.4 调试工具怎么做
状态模式最大的劣势是状态流转过程肉眼不可见。我在项目里一定会做状态调试面板,在OnGUI或UGUI的Debug信息里显示当前状态名和状态切换次数:
csharp复制private void OnGUI()
{
GUILayout.Label($"Current State: {currentStateType}");
GUILayout.Label($"State Switch Count: {switchCount}");
}
更进阶一点可以把状态切换的历史记录存到List里,按时间倒序显示最近20次切换。这个调试功能在排查AI行为异常时价值极高,很多“看起来像随机Bug”的问题本质就是状态切换不符合预期。
10.5 别为简单的状态硬套状态模式
状态模式虽然好用,但也不是所有状态管理都适合。如果只有Idle和Run两个状态,用switch-case足够了。套用状态模式反而是架构过度设计——类文件多、上下文逻辑分散、可读性未必提升。我始终认为:设计模式是工具,不是教条。当你的状态复杂度和团队协作需求匹配时再上,状态模式的价值才能真正体现。
状态模式本身并不神秘,核心就是“把会变化的、分裂的每个状态封闭起来,让状态自己学会流转”。Unity项目里角色、AI、UI、技能系统都能应用,而且配合Animator能解决大部分游戏状态相关的复杂问题。希望这篇从代码到细节的拆解能帮到你,尤其是那些写了switch-case但总觉得难维护的同学——可以尝试用状态模式重构一次,你会发现代码结构清晰很多。
