3. FSM实现(一):基于枚举与switch-case的简单状态机实现

好,咱们今天来点真家伙。

前面两章聊了FSM的理论和设计思路,说白了都是纸上谈兵。这一章,我们直接撸代码。我个人习惯,学任何设计模式,先看最朴素的实现,再看优雅的。所以今天的主角就是——枚举 + switch-case

你想想看,这是不是最符合直觉的方式?状态嘛,就是一堆常量;状态切换嘛,就是根据当前状态和输入,决定下一个状态。用switch-case来写,简直天经地义。

3.1 为什么先从枚举+switch-case开始?

我在项目中遇到过不少新人,一上来就问我:“老师,FSM是不是得用状态模式?是不是得用多态?”

我的回答是:先别急

枚举+switch-case虽然看起来“土”,但它有几个无可替代的好处:

  • 零依赖:不需要任何设计模式、不需要继承、不需要虚函数。纯C/C++/C#基础语法就能搞定。
  • 逻辑一目了然:整个状态机的流转,全在一个switch块里。你一眼就能看到“从A状态能去哪些状态”。
  • 调试极其方便:断点打在switch入口,每个case都能看到。我曾经用这种方式,半小时定位了一个隐藏了3天的状态跳转bug。
  • 性能极致:switch-case在底层就是跳转表,O(1)的时间复杂度。对于状态数量不多的AI(比如10个以内),这几乎是最快的实现。
核心观点:枚举+switch-case是FSM的“最小可行实现”。它让你先跑起来,再谈优化。

3.2 一个完整的例子:巡逻-追击-攻击

咱们拿一个游戏AI最常见的场景来演示:一个守卫的三种状态

  • 巡逻(Patrol):沿着固定路线走,发现敌人就切换。
  • 追击(Chase):追着敌人跑,距离够了就攻击,跟丢了就回去巡逻。
  • 攻击(Attack):在攻击范围内,持续输出。敌人跑远就切回追击。

嗯,很经典对吧?我当年做第一个塔防游戏时,用的就是这套逻辑。

先定义枚举:

// 状态枚举
public enum GuardState
{
    Patrol,  // 巡逻
    Chase,   // 追击
    Attack   // 攻击
}

// 触发条件(输入)
public enum GuardInput
{
    EnemySpotted,   // 发现敌人
    EnemyLost,      // 丢失敌人
    EnemyInRange,   // 敌人在攻击范围内
    EnemyOutOfRange // 敌人超出攻击范围
}

然后定义状态机核心类:

public class GuardFSM
{
    private GuardState currentState;
    private Dictionary<(GuardState, GuardInput), GuardState> transitionTable;

    public GuardFSM()
    {
        currentState = GuardState.Patrol;
        InitializeTransitionTable();
    }

    private void InitializeTransitionTable()
    {
        transitionTable = new Dictionary<(GuardState, GuardInput), GuardState>
        {
            // 巡逻状态下
            [(GuardState.Patrol, GuardInput.EnemySpotted)] = GuardState.Chase,
            
            // 追击状态下
            [(GuardState.Chase, GuardInput.EnemyInRange)] = GuardState.Attack,
            [(GuardState.Chase, GuardInput.EnemyLost)] = GuardState.Patrol,
            
            // 攻击状态下
            [(GuardState.Attack, GuardInput.EnemyOutOfRange)] = GuardState.Chase
        };
    }

    public void HandleInput(GuardInput input)
    {
        var key = (currentState, input);
        
        if (transitionTable.TryGetValue(key, out GuardState nextState))
        {
            // 这里可以加一个OnExit和OnEnter的调用
            // 后面章节会讲
            currentState = nextState;
            ExecuteState();
        }
        else
        {
            // 没有定义的状态转换,保持当前状态
            // 我曾经在这里踩过坑——忘记处理某些组合,导致AI卡死
            Debug.Log($"状态转换未定义:{currentState} + {input}");
        }
    }

    private void ExecuteState()
    {
        switch (currentState)
        {
            case GuardState.Patrol:
                PatrolBehavior();
                break;
            case GuardState.Chase:
                ChaseBehavior();
                break;
            case GuardState.Attack:
                AttackBehavior();
                break;
        }
    }

    private void PatrolBehavior()
    {
        // 巡逻逻辑:沿路走,检测敌人
        // 如果发现敌人,调用 HandleInput(GuardInput.EnemySpotted)
    }

    private void ChaseBehavior()
    {
        // 追击逻辑:朝敌人移动
        // 如果进入攻击范围,调用 HandleInput(GuardInput.EnemyInRange)
        // 如果丢失视野,调用 HandleInput(GuardInput.EnemyLost)
    }

    private void AttackBehavior()
    {
        // 攻击逻辑:执行攻击动作
        // 如果敌人跑远,调用 HandleInput(GuardInput.EnemyOutOfRange)
    }
}
我的小技巧:把状态转换表单独抽出来,用字典存。这样你改状态逻辑时,不用动switch-case里的代码。我后来做大型项目,都是这么干的。

3.3 这个实现的优缺点分析

咱们不吹不黑,客观分析一下。

优点 缺点
代码量少,上手快 状态多了以后,switch-case会变得很长
性能极高,无虚函数开销 每个状态的行为逻辑都挤在一个方法里,不好维护
逻辑集中,容易调试 状态和行为的耦合度高,复用性差
适合状态数少于10个的场景 新增状态需要修改多处代码(违反开闭原则)

说白了,这个实现适合原型阶段或者状态数量可控的小型项目。我当年做独立游戏时,一个角色的AI就用这个模式,跑了整个项目周期,一点问题没有。

3.4 避坑指南:我曾经踩过的三个坑

嗯,这里要注意,有几个坑是新人必踩的。

坑一:忘记处理所有状态转换组合

我曾经写过一个AI,在“攻击”状态下,如果敌人突然消失(比如隐身了),我没有定义对应的转换。结果AI就卡在攻击状态,一直对着空气输出。后来查了半天,才发现是少写了一行字典条目。

解决方案:在HandleInput里加一个else分支,记录日志。这样至少你能知道哪个转换没定义。

坑二:在switch-case里写太多逻辑

我见过有人把寻路、动画、音效全写在PatrolBehavior里。一个方法上千行。你想想看,这还怎么维护?

解决方案:每个Behavior方法只做“决策”,具体的执行逻辑调用其他模块。比如 PatrolBehavior 只负责“决定下一步往哪走”,真正的移动逻辑交给 MovementComponent。

坑三:状态枚举和输入枚举混在一起

有些新手会把“发现敌人”也定义成一种状态。结果状态机里既有 Patrol 又有 EnemySpotted,逻辑全乱套了。

解决方案:记住一个原则——状态是“是什么”,输入是“发生了什么”。两者严格分开。

3.5 什么时候该放弃这个实现?

你可能会问:“那我什么时候该换更高级的实现?”

我的经验是,当你出现以下任何一个信号时,就该考虑重构了:

  • switch-case里的case超过了15个
  • 你需要频繁地新增或删除状态
  • 多个AI角色需要共享部分状态逻辑
  • 你开始觉得“这个状态机好难改”

这时候,就该请出我们下一章要讲的——状态模式了。

3.6 小结

今天咱们用最朴素的方式,实现了一个完整的FSM。你想想看,是不是很简单?

  • 用枚举定义状态和输入
  • 用字典或switch-case定义转换表
  • 在Update里根据当前状态执行对应行为

这个模式,我用了十年。虽然现在做大型项目会用更复杂的框架,但每次写原型,我还是会下意识地掏出枚举+switch-case。因为它足够简单,简单到不会出错。

下一章,咱们在这个基础上,加上状态进入/退出的回调,让状态机更“智能”一点。敬请期待。