← 返回文章归档

画面刷新和游戏时间分开走:Starbreak 的固定步长模拟

用一个 Canvas 太空生存游戏,理解渲染频率、120 Hz 模拟步长、长帧截断、边沿输入,以及怎样让游戏规则脱离浏览器测试。

不能假设所有屏幕都每秒刷新六十次

Starbreak 是一个原生 JavaScript 的太空生存游戏:Canvas 负责画面,Web Audio 合成音效,游戏素材由代码绘制。做这个小项目时,一个很直观的问题是,不同刷新率下飞船应当保持相近的移动速度。

如果每绘制一帧就把位置加上固定像素,高刷新率屏幕上的飞船就会更快。requestAnimationFrame 的回调节奏通常跟随显示刷新率,而且后台标签页可能暂停回调,相关行为见 MDN 文档。

因此,绘制多少次与游戏世界前进多少时间,需要分开处理。

用累积时间驱动固定步长

dist/engine.js 把模拟步长设为 1 / 120 秒。每帧将经过时间加入累积器,够一个步长就执行一次 _step,直到剩余时间不足一步。可以把思路简写为:

accumulator += Math.min(frameSeconds, 0.1);
while (accumulator >= stepSeconds) {
  simulate(stepSeconds, input);
  accumulator -= stepSeconds;
}

这是解释思路的简化代码;实际实现还处理浮点误差、运行状态和输入。一次渲染之间可能执行零次、一次或多次模拟,渲染本身则继续由浏览器调度。

固定的小步长让移动、计时与碰撞检查使用统一尺度。但它不能保证任意速度下都不会穿透,也不等同于连续碰撞检测。

长时间没有更新时,别直接快进死亡

页面切到后台、设备休眠或主线程被阻塞后,下一次回调可能出现很大的时间差。若把这些时间全部补算,玩家刚切回来就可能已经被连续命中。

当前引擎把单次接收的间隔截断到 0.1 秒;页面在隐藏时也会清理输入并暂停。这是当前单机游戏的体验选择:舍弃过长的空白时间,而不是强行追上墙上时钟。

对于网络对战或需要严格按真实时间结算的系统,这个策略不能直接照搬。选固定步长以后,仍要说明遇到积压时是补算、丢弃还是追赶。

按住一个键,不应触发无数次冲刺

飞船冲刺有冷却时间。引擎不仅看当前 dash 是不是 true,还跟踪上一轮是否已经按住,并把新的按下边沿排队给模拟步骤。

这样渲染与模拟频率不同,或者某一帧尚未累积够一步时,新按键也不至于直接丢失;持续按住同一个键,也不会每个模拟步都当作一次全新操作。

这类输入状态与键盘事件不同。浏览器事件负责告诉应用发生了什么,引擎需要把它转换成可以按模拟时间消费的意图。

把规则从 Canvas 中拆出来

引擎维护飞船、子弹、陨石、分数、连击和波次等状态;game.js 负责绘制、音效、DOM 控件与存储。测试因此可以直接创建引擎、传入一串时间间隔和输入,检查状态,而不用先启动浏览器并比较每个像素。

随机源也可以从构造函数注入。给测试使用固定随机序列,更容易复现某种刷怪与碰撞场景。不过想得到严格确定性,还需要控制输入时间、更新顺序、环境和随机调用顺序;仅仅固定步长并不够。

浏览器最高分与声音偏好使用本地存储。存储不可用时,游戏仍能继续,纪录退化为当前会话中的状态。保存失败不应该拖住最核心的游玩循环。

这个项目让我重新理解“职责分开”的实际收益:绘制可以换,规则可以单测,输入可以回放。无论是在游戏引擎、Mini Agent 工具循环还是后台任务里,把核心状态变化做成可观察的过程,排错都会轻松一些。

✳

感谢阅读,下一篇再见。

回到文章开头 ↑