二、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 我踩过的坑——状态模式常见问题

最后分享几个我实际项目中踩过的坑:

  1. 状态对象重复创建:每次切换都 new 一个新状态,导致GC压力大。解决方案:用对象池或者缓存单例状态。
  2. 状态间数据传递混乱:状态A退出时想告诉状态B一些信息。我见过有人直接在状态里写全局变量。更好的做法:在上下文中加一个「共享数据区」,或者用事件系统。
  3. 状态爆炸:状态太多,管理困难。这时候要考虑是否可以用子状态机或者分层状态机来优化。

嗯,状态模式就讲到这里。说白了,它就是让每个状态各司其职,别把一堆逻辑塞在一个函数里。下一节咱们会聊决策树,那又是另一种思路了。