博客开发指南 (七):游戏循环与状态机——街机厅的心跳

从 if-else 地狱到清晰的状态流转:解密咖啡街机厅六台游戏的共同骨架

Posted by LH on August 29, 2026

最近给博客的 Daily 页面做了一个「咖啡街机厅」:坦克大战、打砖块、Flappy 咖啡杯、下一百层、浓缩时序、小丑杯,六台复古机台全部手写在一个 480×360 的画布里。

写的过程中,”状态机”这个词在我的开发记录里出现了不下十次。这个听起来很学术的词,其实是零基础也能十分钟理解的东西——而且它正是这一系列游戏能保持代码不腐烂的核心。所以从这篇开始,我会用 5 篇的篇幅,把这个小街机厅从骨架到肌肉完整拆给你看:

  • (七)本篇:游戏循环与状态机——所有游戏的心跳
  • (八):一行注册一台机——插件式框架设计
  • (九):别让指针穿墙——碰撞检测入门
  • (十):随机不等于坑——洗牌、种子与”第 29 根管道”事故复盘
  • (十一):看不见的玩家——无头测试与遥测机器人

所有代码都来自真实的仓库文件(js/arcade.js 和 js/games/*.js),每一篇都可以跟着复现。

一、游戏为什么需要一个”循环”

先想一个问题:画面上一只咖啡杯在飞、管道在移动、分数在跳动——静态网页是怎么让它”动”起来的?

答案朴素得惊人:快速地反复擦掉重画。每秒重画 60 次,每次画的时候让所有东西的位置挪一点点,人眼就看到了连续的动画。这个”擦掉重画”的循环,就是游戏循环(Game Loop)。

街机厅的主循环在 js/arcade.js 里,长这样(节选):

function frame(t) {
  if (!rafId) return;
  var dt = Math.min(100, t - lastT || 16.7);  // 距离上一帧过了多少毫秒
  lastT = t;
  acc += dt;
  var n = 0;
  while (acc >= 16.667 && n < 4) {            // 攒够一个逻辑帧才更新,最多补 4 帧
    step();                                   // 更新游戏逻辑
    acc -= 16.667;
    n++;
  }
  if (acc > 200) acc = 0;                     // 防止后台切回来后疯狂追赶
  if (mode === 'game' && active && active.draw) active.draw();  // 重画
  rafId = requestAnimationFrame(frame);       // 预约下一帧
}
📌 知识点:requestAnimationFrame(简称 rAF)
它是浏览器提供的"下一帧请叫我"接口。你把一个函数交给它,浏览器会在每次重绘屏幕前调用这个函数。它比 setInterval 好在两点:屏幕刷新时才跑(不浪费)、页面切到后台时自动暂停(不会空转烧 CPU)。注意最后一句 rafId = requestAnimationFrame(frame)——循环能持续,靠的是每帧的"最后一步"重新预约自己;中间任何一步抛出异常,预约就不会执行,整个画面就冻结了。这是我们后面踩过的一个真实的坑。

逻辑 60Hz,渲染随缘

你可能注意到上面有个奇怪的 acc(累加器)。它的作用是:不管你的显示器是 60Hz 还是 144Hz,游戏逻辑永远按每秒 60 步走。

  • 屏幕跑得快(144Hz):rAF 每秒叫你 144 次,但 acc 攒不够 16.667 毫秒就不执行逻辑——游戏速度不变,只是画得更细腻。
  • 屏幕跑得慢或页面卡了:acc 攒了很多,最多补跑 4 帧逻辑(n < 4),防止一卡就”时间跳跃”。

这个模式有个名字叫固定时间步进(Fixed Timestep)。游戏逻辑(update)和画面渲染(draw)就此分离:

function step() {
  if (mode === 'game' && active && !paused && active.update) active.update();
}

每台游戏只需要暴露一个 update()(算状态)和一个 draw()(画画面),框架负责其余一切。

📌 知识点:为什么 update 和 draw 必须分开?
因为逻辑要求"稳定"(每秒必须正好 60 步,否则移动速度会漂移),而渲染要求"能省则省"(掉一两帧无所谓,画面不会坏)。混在一起写,游戏在快慢不同的电脑上速度会不一样。分离之后,逻辑在 10 年前的机器和最新的显示器上跑得一样快。

二、状态机:从 if-else 地狱里自救

循环解决的是”动起来”,接下来是”怎么动”。以浓缩时序(一台做咖啡的时机游戏)为例:一根针在判定条上扫动,玩家按键让它停进绿区。听起来简单,写起来你会立刻遇到一堆问题:

  • 现在按空格,到底是”停针”还是”跳过结算动画”还是”开始下一单”?
  • 结算动画播到一半,敌人子弹飞过来要不要判死?
  • 新订单刚出现,玩家还没反应,针就开始扫了?(这是真实的用户反馈,我们后来加了”起针准备态”)

如果用一个 phase 变量加上一堆 if-else 来写,很快就会变成没人敢动的面条。解决办法就是状态机(State Machine):

把游戏明确地划分成若干个”状态”,任何时刻只处于其中一个;规定好”在哪个状态下、发生什么事件、就切换到哪个状态”。其他一切输入,一律忽略。

浓缩时序的状态图长这样:

                新订单到达
                    │
                    ▼
              ┌──────────┐   玩家按键    ┌──────────┐
   出杯完成   │  ready   │──────────────▶│  sweep   │
   ┌─────────│ (起针等待) │   → 起针      │ (针扫动中) │
   │         └──────────┘               └────┬─────┘
   │              ▲                     按键停针│ 或针到头
   │              │ 耐心耗尽                  ▼
   │              │                   ┌──────────┐
   │         ┌────┴────┐              │ resolve  │
   │         │  ruin   │              │ (显示判定) │
   │         │ (糊单!)  │              └────┬─────┘
   │         └────┬────┘             得分│   │ 未达目标且手数用完
   │   糊满3单     │                     │   ▼
   └──────────────┘                  [下一阶段 / serve / ruin]

每个状态内部只关心自己的事:ready 里针一动不动、只等玩家起针;sweep 里针每帧前进、判定按键;resolve 里只管倒计时 30 帧然后切下一步。“在什么情况下会发生什么”从此一目了然。

📌 知识点:状态机为什么能防 bug?
因为它把"非法操作"变成了"默认忽略"。比如结算动画(resolve)期间玩家狂按空格——在 if-else 写法里这可能重复触发得分;在状态机里,press() 开头一句 if (state !== 'sweep') return; 就把所有非扫针状态的按键全部拒之门外。顺便,浏览器按住空格会连续发 keydown 事件(键盘自动重复),状态机的这个"门卫"天然把重复按键也挡掉了。

三、动手实现:一个状态机只要三样东西

看浓缩时序里最核心的三段真实代码。第一样,一个状态变量(字符串就够了,不必用类):

var state, stateT;   // 当前状态 + 当前状态已经持续了多少帧
// state 的取值:'ready' | 'sweep' | 'resolve' | 'serve' | 'ruin'

第二样,转移规则——集中在 press()(玩家按键)里:

function press() {
  if (over) return;
  if (state === 'ready') { state = 'sweep'; return; } // 起针这一下不作判定
  if (state !== 'sweep') return;                      // 其余状态一概不理
  var d = Math.abs(needle - zoneC);
  if (d <= 8) hit('perfect');
  else if (d <= zoneH) hit('good');
  else fail('糊 了 !');
}

第三样,状态内的每帧行为——集中在 update() 的分支里:

if (state === 'sweep') {
  patience--;                              // 只有扫针时才消耗耐心
  if (patience <= 0) { fail('超时了!'); return; }
  needle += needleSpeed();                 // 针前进
  if (needle >= BARX + BARW) { fail('错过了!'); return; }
} else if (state === 'resolve') {
  stateT--;
  if (stateT <= 0) { /* 进入下一阶段,或出杯 */ }
} else if (state === 'serve') {
  stateT--;
  if (stateT <= 0) newOrder();
}

注意 stateT(状态计时器)——很多状态是”待满 N 帧自动走”的,把它放在状态变量旁边一起管理,转移条件就只是简单的数字比较。

⚠️ 踩坑实录:别用 setTimeout 做状态转移
最开始我很容易写出 setTimeout(function(){ nextPhase(); }, 500) 这种代码。它的致命问题是不受暂停控制:玩家按 P 暂停,setTimeout 照样在后台倒数,恢复时游戏状态已经错乱。所有转移都放在 update() 里用帧数计数——暂停时 update 根本不被调用,游戏就真正"冻结"了。这条原则在这一系列游戏里无一例外。

四、可视化:亲手扳动一个状态机

下面是一个可以玩的小状态机(一个巡逻机器人的 AI 骨架,和街机厅里的写法同构)。点按钮给机器人发”事件”,观察它当前的状态和转移日志;试着在「待机」状态下点「抓到玩家」——你会发现事件被礼貌地拒绝了,这正是状态机的价值:

机器人当前状态:待机 已巡逻 0 帧
» 事件日志(被拒绝的事件也会记录)

transfer 规则全在那张 TRANS 表里——想加新状态?加一行。想改行为?改一行。这就是状态机真正的回报:变化被限制在一张表里。

📌 知识点:表驱动(Table-driven)
上面的 TRANS 叫"转移表":用数据(对象)描述规则,而不是用代码。它和街机厅里浓缩时序的 KINDS 表(五种工序的针速/区宽参数)、小丑杯的 JOKERBREW_JOKERS 表(16 个小丑的效果)是同一个思想——规则变成数据后,改规则就不用改逻辑。

五、跟着复现:20 行写出你的第一个游戏循环 + 状态机

把下面整个文件存成 my-game.html,双击用浏览器打开:

<!DOCTYPE html>
<canvas id="cv" width="300" height="200" style="background:#111"></canvas>
<script>
var ctx = document.getElementById('cv').getContext('2d');
var state = '左右走', x = 20, dir = 1, t = 0;   // 状态变量 + 一切数据
var STATES = { '左右走': ['跳跃'], '跳跃': ['左右走'] };  // 转移表

function update() {                              // 逻辑:每步只做当前状态的事
  t++;
  if (state === '左右走') { x += dir * 2; if (x < 10 || x > 280) dir *= -1; }
  else if (state === '跳跃') { x += dir * 2; if (t > 30) setState('左右走'); }
}
function setState(s) { console.log(state, '→', s); state = s; t = 0; }  // 转移统一入口
function draw() {                                // 渲染:把状态画出来
  ctx.fillStyle = '#111'; ctx.fillRect(0, 0, 300, 200);
  ctx.fillStyle = '#ffd166'; ctx.fillRect(x, state === '跳跃' ? 60 : 90, 20, 20);
  ctx.fillStyle = '#888'; ctx.fillText(state, 10, 20);
}
cv.onclick = function () {                       // 事件:按转移表决定能不能换状态
  var to = STATES[state][0];
  if (to) setState(to);
};
(function loop() { update(); draw(); requestAnimationFrame(loop); })();
</script>

你会得到一个黄色小方块:平时左右巡逻,点一下画布跳起来、半秒后落地。它已经有了一个游戏的全部骨架——状态、转移表、update、draw、事件入口。街机厅里那六台游戏,一万多行代码,拆开看全是这五样东西的放大版。

六、小结

  • 游戏循环 = rAF 驱动的”更新 + 重画”,固定时间步进让逻辑速度与设备无关
  • 状态机 = 状态变量 + 转移表 + 每帧行为,把”非法操作”变成”默认忽略”
  • 状态转移必须走 update() 的帧计数,绝不用 setTimeout(暂停会失控)
  • 表驱动:规则写成数据,改规则不动逻辑

下一篇我们看框架层:为什么 adding 第六台机器只花了一行注册代码,以及「约定优于配置」是如何让菜单、暂停、最高分自动长出来的。

本篇所属系列:博客开发指南 · 上一篇:(六)Tags 页面重构与信息归整 · 下一篇:(八)一行注册一台机——插件式框架设计