Chancy game dev notes · 全栈工程师

我的状态机不会一直只有六个状态

从 bool 到 enum 到并发状态机到层次状态机:Nystrom 那条演化路径其实是一笔债的四种还法。

· 4 分钟读完

#architecture #engine

状态机出问题的信号很具体:当你的实体上出现三个以上互斥的 bool 标志时,就该用 enum 了。Robert Nystrom 在 State 模式那一章里就是这句话开头,我觉得它比后面所有关于模式的描述都有用,因为它给了一个可观察的判据。

但 bool 只是最早的账单。真正的账单是状态数。

n × m 爆炸

一个角色有两个正交维度:武器(3 种)× 姿态(4 种),朴素做法是 12 个状态,每个状态里都要写"如果我现在是这套组合该怎么办"。加第三种维度(比如"是否蓄力")就变 24 个。这不是估计,是乘法。

Nystrom 给的三种解法,对应三种不同的"你其实在骗自己什么":

  • 并发状态机:把维度拆开,3 + 4 个状态而不是 12 个。它假设维度真的独立 —— 一旦"只有持盾时才能滑步",你就在两个 FSM 之间加了耦合,而这个耦合在代码里是没有位置放的,只能以 if 的形式散落在两边。这是我见过最容易烂掉的一种。
  • 层次状态机:子状态不处理的事件沿超状态链上抛。它真正解决的是"低优先级公共行为"(任何地面状态都能被击飞),代价是事件处理顺序变成隐式的。
  • 下推自动机(栈):给 FSM 补上历史。Nystrom 的判断很直白 —— 普通 FSM 没有"历史"这个概念,所以"攻击 → 硬直 → 回到落地前的移动状态"这类需求只能靠手写额外字段。

顺带他说了句我认为该印在纸上的话:复杂分支 + 可变状态,是最容易出错的组合。这也是为什么这些模式全都指向同一件事:把状态从"字段"变成"结构"。

组件模式解决的是另一个问题

Component 的动机不是性能(这点下面那篇会补),Nystrom 给的理由是让一个实体横跨多个领域,同时不让这些领域互相耦合:玩法层的 Player 不该继承渲染层的 Sprite,因为那个 Boss 迟早想播一段过场动画。

他举的失败形态很形象:死亡钻石(Deadly Diamond) —— 多层继承在某个叶子节点上撞出语义冲突,你只能靠覆写去压平它,然后覆写链开始互相踩踏。同一章里还有一句关于组件的坦白:它会把数据打散,从而伤 cache locality。我认为这是全书最容易被误读的一句,下面那篇专门写它。

服务定位器:把依赖变隐形的代价

Service Locator 是"用集中注册表替掉硬引用的 Singleton",启动时注入实现。Nystrom 给的代价也很具体:依赖关系不再出现在签名里,于是初始化顺序变得脆弱 —— 他直接点了名字叫 temporal coupling(时间耦合)。缓解手段是 null service 兜底 + 运行时断言。

"一个只是拿个 Audio 的构造函数,最后把整个引擎拖进了单元测试",我觉得是这个模式最真实的抱怨。

玩法状态机不是动画状态机

这条我从 Godot 的 AnimationTree 文档里得到的启发比较大。它把"播放图"和"玩法"明确分开,而且限制写得很实:

  • AnimationTree 本身不存动画,clip 挂在外部 AnimationPlayer 上,图只是引用它 —— 也就是表现层的状态是图,数据源在别处。
  • transition 有三种切换语义(立即 / seek 同步 / 等播完),加上 crossfade 和 priority,这意味着同一个 play("attack") 请求可以有多达六种视觉结果。
  • blend tree 能做 1D/2D 插值,但循环同步要求所有混合点是有限长且不可变的动画节点;混合根节点旋转有 180° 的限制,需要显式的 RESET 轨道来定义基准姿势。

我现在的规矩是:玩法状态机只发事件,动画层自己决定怎么切。一旦玩法里写 if (animState == "land") —— 也就是前面说的"把渲染层状态当玩法真相"—— 上面这些限制会全部变成你调不通的原因。

一句话总结这四篇

模式不是在给你"更好的写法",是在给同一笔债换成另一种货币:bool 换成 enum 是省了分支、多了层数;并发 FSM 是省了状态数、多了隐式耦合;服务定位器是省了构造参数、多了初始化顺序的坑。选之前先看清你要欠哪一笔。

参考来源