
三国杀里一个角色的回合被规则书切成六段:准备、判定、摸牌、出牌、弃牌、结束。每个阶段只允许做特定的事——判定阶段结算判定区的延时锦囊,摸牌阶段摸牌堆顶两张,出牌阶段才能用【杀】。第一次实现这个流程的人,多半会写一个巨大的 if/else,然后被观星、乐不思蜀、神速这些到处插队改流程的技能折磨到怀疑人生。
有限状态机(Finite State Machine,FSM)就是为这类问题准备的:一段明确的流程、若干固定的步骤、以及大量在步骤之间插入的例外。本文先用一个最小的例子讲清 FSM 是什么,再用三国杀的回合把完整实现走一遍,最后盘点游戏圈常用的开源状态机库。
什么是有限状态机
FSM 是一个形式化计算模型,游戏开发只用它的日常形态:任意时刻处于有限个状态中的一个,事件到来时按转移表决定要不要切换。四个要素:
- 状态(State):互斥的阶段,如「摸牌阶段」「攻击中」。
- 事件(Event):触发转移的输入,如「摸牌完成」「发现敌人」。
- 转移(Transition):某状态在某个事件下切换到哪个状态。
- 动作(Action):转移时执行的行为,如播放动画、结算技能。
形式化一点:FSM 是五元组 (S, Σ, δ, s₀, F)——S 是有限状态集,Σ 是事件集,δ: S × Σ → S 是转移函数,s₀ 是初始状态,F 是终态集。实践中游戏 FSM 很少定义终态,因为流程要么循环(回合制),要么常驻(角色 AI)。
最小的 JS 实现只有十几行:
class FSM {
constructor(initial, table) {
this.state = initial;
this.table = table; // { 状态: { 事件: { to, action } } }
}
send(event) {
const t = this.table[this.state]?.[event];
if (!t) return; // 未定义的事件直接忽略
t.action?.(event); // 先执行动作
this.state = t.to; // 再切换状态
}
}
table 就是状态机的灵魂:合法转移全部声明在里面,非法转移(比如死亡状态下还能攻击)根本没有对应的键,编译器和运行时都拦得住。

游戏里为什么到处都是状态机
凡是「一段时间内只能做一件事」的逻辑都适合用状态机表达:
- 角色与怪物 AI:巡逻 / 追击 / 攻击 / 逃跑,事件是发现敌人、丢失目标。
- 动画:Unity 的 Animator(Mecanim)本身就是一个可视化状态机,动画切换就是状态转移。
- UI 与流程:主菜单 / 对局中 / 结算,通常用状态机或状态栈。
- 回合制:回合阶段的流转,就是本文的例子。
不用状态机,用枚举 + switch 会怎样:
void UpdateAI() {
switch (state) {
case Patrol:
if (sawEnemy()) state = Chase;
break;
case Chase:
if (lostTarget()) state = Patrol;
break;
// 每个 switch 都要照顾所有状态,状态一多必漏
}
}
两个问题:转移规则散落在每个 case 里,看不到全局;状态变多时,每个 case 都要记得「这个状态下能不能转移」。状态机把这两件事集中声明,等于把规则书直接翻译成代码。
实战:把三国杀回合做成状态机
先定义状态。一个角色的完整回合有六个阶段:
enum Phase {
Prepare, // 准备阶段:技能触发点(观星、英魂)
Judgment, // 判定阶段:结算判定区的延时锦囊(乐不思蜀、闪电)
Draw, // 摸牌阶段:摸牌堆顶两张
Play, // 出牌阶段:使用手牌
Discard, // 弃牌阶段:手牌弃至体力值
End // 结束阶段:闭月、据守等
}
转移表(六条主转移 + 一条跳过旁路):
Prepare --[准备完成]--> Judgment
Judgment --[判定完成]--> Draw
Judgment --[无判定牌]--> Draw // 旁路:直接跳过判定阶段
Draw --[摸牌完成]--> Play
Play --[结束出牌]--> Discard
Discard --[弃牌完成]--> End
End --[回合结束]--> (下一名角色重新走一遍)
两个细节要注意:
- 判定阶段内部是一条循环:判定区有几张牌就判几张,每张判完产生「下一张」事件,全部清空才发「判定完成」。状态机不管阶段内部怎么循环,只关心「判完了没有」。
- 出牌阶段最长:玩家一直操作,直到点「结束出牌」或倒计时结束才发事件。阶段内部逻辑全部自持,状态机只负责流转。
每个阶段做成一个对象(状态模式):
interface IPhase {
void OnEnter(); // 进入阶段:发事件、触发技能
void OnUpdate(); // 阶段内逐帧逻辑
void OnExit(); // 退出阶段:清理
}
class PreparePhase : IPhase {
public void OnEnter() {
// 观星、英魂等「准备阶段」技能逐个触发
foreach (var skill in player.Skills)
if (skill.TriggerAt == Phase.Prepare)
skill.TryTrigger(player);
// 技能可能要求跳过本阶段(如神速),直接请求转移
if (player.SkipPrepare)
fsm.Request(Event.PrepareDone);
}
}
回合主循环:
var fsm = new TurnFSM(Phase.Prepare, turnTable);
while (game.IsRunning) {
fsm.Update(); // 驱动当前阶段的 OnUpdate
if (fsm.Current.Done) // 阶段内部自报完成
fsm.Advance(); // 沿当前状态的出口转移
}
卡牌游戏要状态机的真正原因是可扩展性:新武将 = 新技能 = 在转移路径上插钩子,改动被限制在单个阶段内部,不碰其他阶段和转移表。
- 神速:跳过判定阶段 → 判定阶段 OnEnter 里直接发「判定完成」。
- 乐不思蜀:判定阶段判出的牌不是红桃 → 出牌阶段被跳过(在出牌阶段入口直接发「结束出牌」)。
- 闭月:结束阶段 OnEnter 摸一张牌。

状态机的三种进阶形态
基础 FSM 有三个痛点:状态一多转移边就爆炸;跨状态共享逻辑要复制粘贴;没法表达「嵌套」。业界对应的解法是三种进阶形态。
1. 分层状态机(HFSM)
一个状态内部再嵌一个状态机。角色 AI 外层是巡逻 / 追击 / 战斗,战斗状态内部是接近 / 攻击 / 闪避。进入「战斗」= 进入子状态机的初始状态;子状态机跑完 = 父状态机收到一次转移。

Unity 生态的 UnityHFSM(GitHub 约 1.6k star)把「状态里嵌状态机」做成一等公民,还解决了三个基础 FSM 的实战痛点:
- needsExitTime:状态不会瞬间被打断,而是等内部逻辑确认退出(比如攻击动画播完才能切状态);
- CoState:用协程写状态内的延时逻辑(攻击后摇、技能前摇);
- 零 GC 分配:状态切换不产生垃圾,避免移动端帧率尖峰。
2. 推入式状态机(状态栈)
暂停菜单是典型场景:游戏状态压栈,Pause 压上去;退出暂停弹栈,回到原状态。普通 FSM 没有记忆,做不了「记住我来自哪」;状态栈用 push/pop 天然支持。
3. 状态模式(每个状态一个类)
状态机强调转移表,状态模式强调状态的对象化。两者经常混用:外层转移用表声明,状态内部逻辑用类组织——上一节的 IPhase 就是例子。
状态机常见的坑
- 转移顺序:全局转移优先。血量归零 → 死亡必须最先检查,不能被当前状态的局部转移抢先。
- 用事件驱动,别每帧轮询。每帧扫一遍所有条件会退化成 if/else,状态机白做。
- 小心重入:转移动作里再触发转移会死循环。用一个转移队列,先排事件、后执行。
- 别在每个状态里 new 对象。复用实例,UnityHFSM 主打零 GC 就是冲这个问题来的。
- 打印转移日志。[TurnFSM] Play -> Discard (结束出牌) 一行日志,胜过大半天单步调试。
开源 FSM 库盘点
按 GitHub star 排序的主流库(2026 年 8 月数据):
| 库 | 平台 / 语言 | Stars | 特点 |
|---|---|---|---|
| XState | JS / TS | ~21k | 状态图(statechart):层次、并行、历史状态,自带可视化调试 |
| javascript-state-machine | JS | ~8.8k | 声明式转移表,自动生成转移方法,轻量 |
| machina.js | JS | ~1.9k | 事件驱动,支持层次化,经典老库 |
| UnityHFSM | C# / Unity | ~1.6k | 分层 FSM,零 GC,协程状态,needsExitTime |
| UniState | C# / Unity | ~240 | 高性能,UniTask 异步,面向玩法逻辑 |
| PlayMaker | Unity 插件 | 付费 | 可视化拖拽 FSM,不写代码 |
| Mecanim | Unity 内置 | — | 动画状态机,游戏动画的默认答案 |
XState 写三国杀回合:
import { createMachine } from 'xstate';
const turnMachine = createMachine({
id: 'turn',
initial: 'prepare',
states: {
prepare: { on: { DONE: 'judgment' } },
judgment: { on: { DONE: 'draw', SKIP: 'draw' } }, // 无判定牌,跳过
draw: { on: { DONE: 'play' } },
play: { on: { FINISH: 'discard' } },
discard: { on: { DONE: 'end' } },
end: { on: { NEXT: 'prepare' } }, // 下一名玩家
},
});
javascript-state-machine 同款:
const turn = new StateMachine({
init: 'prepare',
transitions: [
{ name: 'step', from: 'prepare', to: 'judgment' },
{ name: 'judge', from: 'judgment', to: 'draw' },
{ name: 'draw', from: 'draw', to: 'play' },
{ name: 'finish', from: 'play', to: 'discard' },
{ name: 'discard', from: 'discard', to: 'end' },
],
methods: {
onStep: () => console.log('准备完成'),
onJudge: () => console.log('判定完成'),
},
});
turn.step(); // 转移方法由配置自动生成
UnityHFSM 同款(C#):
var turn = new StateMachine<Phase, string>();
turn.AddState(Phase.Prepare, onEnter: _ => TriggerPrepareSkills());
turn.AddState(Phase.Judgment, onEnter: _ => ResolveJudgments());
turn.AddState(Phase.Draw, onEnter: _ => DrawCards(2));
turn.AddState(Phase.Play, onLogic: _ => HandlePlayerInput());
turn.AddState(Phase.Discard, onEnter: _ => DiscardToHandLimit());
turn.AddState(Phase.End, onEnter: _ => ResolveEndSkills());
turn.AddTransition(Phase.Prepare, Phase.Judgment);
turn.AddTransition(Phase.Judgment, Phase.Draw);
turn.AddTransition(Phase.Draw, Phase.Play);
turn.AddTransition(Phase.Play, Phase.Discard);
turn.AddTransition(Phase.Discard, Phase.End);
turn.Init();
选型建议:纯前端或工具链用 XState;Unity 玩法逻辑首选 UnityHFSM(免费开源,另一个选择是付费的 PlayMaker,适合给美术策划拖拽);只是要一个轻量流程状态机,javascript-state-machine 就够了。
总结
状态机是把「流程」从「逻辑」里剥出来的工具。三国杀这类回合制游戏,规则书本身就是一张状态转移图:阶段是状态,操作是事件,技能是边上的钩子。把这张图翻译成代码,新武将只是往图里加一条边;用 if/else 硬写,每一个新技能都是对旧代码的一次全面开火。
记住三句话:任何时刻只有一个状态;事件驱动转移;转移表即规则书。


