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个以内),这几乎是最快的实现。
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)
}
}
3.3 这个实现的优缺点分析
咱们不吹不黑,客观分析一下。
| 优点 | 缺点 |
|---|---|
| 代码量少,上手快 | 状态多了以后,switch-case会变得很长 |
| 性能极高,无虚函数开销 | 每个状态的行为逻辑都挤在一个方法里,不好维护 |
| 逻辑集中,容易调试 | 状态和行为的耦合度高,复用性差 |
| 适合状态数少于10个的场景 | 新增状态需要修改多处代码(违反开闭原则) |
说白了,这个实现适合原型阶段或者状态数量可控的小型项目。我当年做独立游戏时,一个角色的AI就用这个模式,跑了整个项目周期,一点问题没有。
3.4 避坑指南:我曾经踩过的三个坑
嗯,这里要注意,有几个坑是新人必踩的。
我曾经写过一个AI,在“攻击”状态下,如果敌人突然消失(比如隐身了),我没有定义对应的转换。结果AI就卡在攻击状态,一直对着空气输出。后来查了半天,才发现是少写了一行字典条目。
解决方案:在HandleInput里加一个else分支,记录日志。这样至少你能知道哪个转换没定义。
我见过有人把寻路、动画、音效全写在PatrolBehavior里。一个方法上千行。你想想看,这还怎么维护?
解决方案:每个Behavior方法只做“决策”,具体的执行逻辑调用其他模块。比如 PatrolBehavior 只负责“决定下一步往哪走”,真正的移动逻辑交给 MovementComponent。
有些新手会把“发现敌人”也定义成一种状态。结果状态机里既有 Patrol 又有 EnemySpotted,逻辑全乱套了。
解决方案:记住一个原则——状态是“是什么”,输入是“发生了什么”。两者严格分开。
3.5 什么时候该放弃这个实现?
你可能会问:“那我什么时候该换更高级的实现?”
我的经验是,当你出现以下任何一个信号时,就该考虑重构了:
- switch-case里的case超过了15个
- 你需要频繁地新增或删除状态
- 多个AI角色需要共享部分状态逻辑
- 你开始觉得“这个状态机好难改”
这时候,就该请出我们下一章要讲的——状态模式了。
3.6 小结
今天咱们用最朴素的方式,实现了一个完整的FSM。你想想看,是不是很简单?
- 用枚举定义状态和输入
- 用字典或switch-case定义转换表
- 在Update里根据当前状态执行对应行为
这个模式,我用了十年。虽然现在做大型项目会用更复杂的框架,但每次写原型,我还是会下意识地掏出枚举+switch-case。因为它足够简单,简单到不会出错。
下一章,咱们在这个基础上,加上状态进入/退出的回调,让状态机更“智能”一点。敬请期待。