二、FSM设计模式:状态模式详解、上下文与状态解耦、状态机基类与接口设计
好,咱们进入第二个大块头。状态模式,说白了就是FSM在面向对象世界里的「官方代言人」。我刚开始学的时候,觉得这东西不就是个switch-case吗?后来在项目里被虐了几次,才明白状态模式到底解决了什么问题。
2.1 状态模式的核心思想
状态模式要解决的核心问题,就一个:把状态的行为从「大杂烩」里拆出来。
你想想看,传统的写法是什么?一个Update函数里,if-else或者switch-case堆了几十行。每个状态的处理逻辑混在一起,改一个状态可能影响其他状态。我见过一个项目,AI角色的状态逻辑写了800行,没人敢动——动一个case,怕炸了另外三个。
状态模式的做法是:每个状态自己管自己的事。状态本身变成一个对象,有自己的Enter、Update、Exit方法。上下文(Context)只负责持有当前状态,把请求转发给它。
核心三要素:
- 状态接口(IState):定义状态必须实现的方法
- 具体状态(ConcreteState):实现某个具体状态的行为
- 上下文(Context):持有当前状态,对外提供接口
2.2 上下文与状态解耦——为什么非拆不可?
我遇到过最典型的反面教材:一个BossAI类,里面塞了巡逻、追击、攻击、逃跑四个状态的全部逻辑。每次加一个新技能,就要在攻击状态里加一堆判断。后来需求变了,Boss要加一个「狂暴」状态,结果发现跟追击状态有冲突——因为两个状态都要修改移动速度,但代码写在不同的if分支里,根本没法统一管理。
解耦之后是什么样?上下文只做三件事:
- 保存当前状态的引用
- 提供一个切换状态的方法
- 把Update等请求转发给当前状态
状态自己决定:进入时做什么、每帧做什么、退出时做什么。上下文根本不需要知道状态内部怎么实现的。这就是「封装变化」——每个状态的变化被隔离在自己的类里。
我的经验:解耦之后,加一个新状态就像加一个新类。你不需要改任何现有状态的代码。这听起来简单,但在大型项目里,这能救你的命。
2.3 状态机基类与接口设计——动手写代码
好,咱们直接上代码。我习惯先定义接口,再写基类。
2.3.1 状态接口
// 状态接口
public interface IState
{
void Enter(); // 进入状态
void Update(float deltaTime); // 每帧更新
void Exit(); // 退出状态
string GetStateName(); // 获取状态名称(调试用)
}
嗯,这里要注意:GetStateName 是我个人习惯加的。调试的时候,你总得知道当前AI在哪个状态吧?我曾经在项目里没加这个,结果出bug时只能靠猜,浪费了一整天。
2.3.2 状态机基类
// 状态机基类
public class StateMachine
{
private IState _currentState;
private IState _previousState; // 记录上一个状态,方便回退
public IState CurrentState => _currentState;
// 切换状态
public void ChangeState(IState newState)
{
if (_currentState == newState)
return; // 同一个状态不重复切换
_previousState = _currentState;
// 退出当前状态
_currentState?.Exit();
// 进入新状态
_currentState = newState;
_currentState?.Enter();
// 我个人习惯在这里打日志
// Debug.Log($"[StateMachine] 切换到: {newState.GetStateName()}");
}
// 每帧更新
public void Update(float deltaTime)
{
_currentState?.Update(deltaTime);
}
// 回退到上一个状态
public void RevertToPreviousState()
{
if (_previousState != null)
{
ChangeState(_previousState);
}
}
}
避坑指南:我曾经在ChangeState里忘记判空,结果游戏启动时状态机还没初始化,Update先调用了,直接空指针崩溃。所以养成习惯:_currentState?.Exit() 和 _currentState?.Update() 一定要加问号。
2.3.3 具体状态示例
// 巡逻状态
public class PatrolState : IState
{
private Enemy _enemy; // 持有对上下文(敌人)的引用
public PatrolState(Enemy enemy)
{
_enemy = enemy;
}
public void Enter()
{
// 进入巡逻:设置巡逻路径、播放动画
_enemy.SetSpeed(2.0f);
_enemy.PlayAnimation("Walk");
}
public void Update(float deltaTime)
{
// 每帧沿着路径移动
_enemy.MoveAlongPath(deltaTime);
// 检测到玩家?切换追击状态
if (_enemy.IsPlayerInSight())
{
_enemy.StateMachine.ChangeState(new ChaseState(_enemy));
}
}
public void Exit()
{
// 退出巡逻:停止移动
_enemy.StopMoving();
}
public string GetStateName() => "Patrol";
}
你看,每个状态只关心自己的事。巡逻状态只管巡逻,追击状态只管追击。它们之间通过状态机来切换,互不干扰。
2.4 状态模式的设计要点总结
| 要点 | 说明 |
|---|---|
| 状态接口 | 定义 Enter、Update、Exit 三个核心方法 |
| 上下文解耦 | 上下文只转发请求,不关心状态内部实现 |
| 状态持有上下文引用 | 状态需要访问上下文的数据(如速度、动画) |
| 避免循环切换 | ChangeState 里判断新旧状态是否相同 |
| 记录上一个状态 | 方便回退,比如从攻击状态回到追击状态 |
核心原则:每个状态是一个独立的「小世界」。它知道自己什么时候进来、每帧做什么、什么时候离开。上下文只是一个「调度员」。
2.5 我踩过的坑——状态模式常见问题
最后分享几个我实际项目中踩过的坑:
- 状态对象重复创建:每次切换都 new 一个新状态,导致GC压力大。解决方案:用对象池或者缓存单例状态。
- 状态间数据传递混乱:状态A退出时想告诉状态B一些信息。我见过有人直接在状态里写全局变量。更好的做法:在上下文中加一个「共享数据区」,或者用事件系统。
- 状态爆炸:状态太多,管理困难。这时候要考虑是否可以用子状态机或者分层状态机来优化。
嗯,状态模式就讲到这里。说白了,它就是让每个状态各司其职,别把一堆逻辑塞在一个函数里。下一节咱们会聊决策树,那又是另一种思路了。