不能假设所有屏幕都每秒刷新六十次
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 工具循环还是后台任务里,把核心状态变化做成可观察的过程,排错都会轻松一些。