游戏开发中的有限状态机:以三国杀回合流程为例

游戏开发中的有限状态机:以三国杀回合流程为例(封面)
封面:游戏开发中的有限状态机

三国杀里一个角色的回合被规则书切成六段:准备、判定、摸牌、出牌、弃牌、结束。每个阶段只允许做特定的事——判定阶段结算判定区的延时锦囊,摸牌阶段摸牌堆顶两张,出牌阶段才能用【杀】。第一次实现这个流程的人,多半会写一个巨大的 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 就是状态机的灵魂:合法转移全部声明在里面,非法转移(比如死亡状态下还能攻击)根本没有对应的键,编译器和运行时都拦得住。

有限状态机四要素:状态、事件、转移、动作
图 1:FSM 四要素,以及一个角色 AI 的最小例子

游戏里为什么到处都是状态机

凡是「一段时间内只能做一件事」的逻辑都适合用状态机表达:

  • 角色与怪物 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       --[回合结束]--> (下一名角色重新走一遍)

两个细节要注意:

  1. 判定阶段内部是一条循环:判定区有几张牌就判几张,每张判完产生「下一张」事件,全部清空才发「判定完成」。状态机不管阶段内部怎么循环,只关心「判完了没有」。
  2. 出牌阶段最长:玩家一直操作,直到点「结束出牌」或倒计时结束才发事件。阶段内部逻辑全部自持,状态机只负责流转。

每个阶段做成一个对象(状态模式):

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 摸一张牌。
三国杀回合阶段状态机:准备、判定、摸牌、出牌、弃牌、结束
图 2:三国杀回合状态机。金色虚线是「无判定牌直接跳过」的旁路,判定阶段上的自循环是逐张结算延时锦囊

状态机的三种进阶形态

基础 FSM 有三个痛点:状态一多转移边就爆炸;跨状态共享逻辑要复制粘贴;没法表达「嵌套」。业界对应的解法是三种进阶形态。

1. 分层状态机(HFSM)

一个状态内部再嵌一个状态机。角色 AI 外层是巡逻 / 追击 / 战斗,战斗状态内部是接近 / 攻击 / 闪避。进入「战斗」= 进入子状态机的初始状态;子状态机跑完 = 父状态机收到一次转移。

分层状态机:战斗状态内部嵌套子状态机
图 3:分层状态机。右侧虚线框「战斗」内部是一个完整的子状态机,整体作为父状态机的一个状态

Unity 生态的 UnityHFSM(GitHub 约 1.6k star)把「状态里嵌状态机」做成一等公民,还解决了三个基础 FSM 的实战痛点:

  • needsExitTime:状态不会瞬间被打断,而是等内部逻辑确认退出(比如攻击动画播完才能切状态);
  • CoState:用协程写状态内的延时逻辑(攻击后摇、技能前摇);
  • 零 GC 分配:状态切换不产生垃圾,避免移动端帧率尖峰。

2. 推入式状态机(状态栈)

暂停菜单是典型场景:游戏状态压栈,Pause 压上去;退出暂停弹栈,回到原状态。普通 FSM 没有记忆,做不了「记住我来自哪」;状态栈用 push/pop 天然支持。

3. 状态模式(每个状态一个类)

状态机强调转移表,状态模式强调状态的对象化。两者经常混用:外层转移用表声明,状态内部逻辑用类组织——上一节的 IPhase 就是例子。

状态机常见的坑

  1. 转移顺序:全局转移优先。血量归零 → 死亡必须最先检查,不能被当前状态的局部转移抢先。
  2. 用事件驱动,别每帧轮询。每帧扫一遍所有条件会退化成 if/else,状态机白做。
  3. 小心重入:转移动作里再触发转移会死循环。用一个转移队列,先排事件、后执行。
  4. 别在每个状态里 new 对象。复用实例,UnityHFSM 主打零 GC 就是冲这个问题来的。
  5. 打印转移日志。[TurnFSM] Play -> Discard (结束出牌) 一行日志,胜过大半天单步调试。

开源 FSM 库盘点

按 GitHub star 排序的主流库(2026 年 8 月数据):

平台 / 语言Stars特点
XStateJS / TS~21k状态图(statechart):层次、并行、历史状态,自带可视化调试
javascript-state-machineJS~8.8k声明式转移表,自动生成转移方法,轻量
machina.jsJS~1.9k事件驱动,支持层次化,经典老库
UnityHFSMC# / Unity~1.6k分层 FSM,零 GC,协程状态,needsExitTime
UniStateC# / Unity~240高性能,UniTask 异步,面向玩法逻辑
PlayMakerUnity 插件付费可视化拖拽 FSM,不写代码
MecanimUnity 内置动画状态机,游戏动画的默认答案

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 硬写,每一个新技能都是对旧代码的一次全面开火。

记住三句话:任何时刻只有一个状态;事件驱动转移;转移表即规则书。

滚动至顶部