单 Canvas 小游戏的骨架:固定步长、状态机、存档迁移
这个项目是一个纯前端的单机游戏,战斗、界面、存档全在浏览器里跑(外面套了个 WebView 壳)。代码量上来之后,真正决定它好不好改的不是玩法,而是几个底层结构。这三个是我改过最多次才对的地方。
一、主循环:固定步长
第一版很朴素,requestAnimationFrame 里直接用实际耗时更新:
function loop(now) {
const dt = now - last;
update(dt);
render();
last = now;
requestAnimationFrame(loop);
}
问题很快出现:帧率高的机器上战斗推进快,帧率低的机器上推进慢,同一个关卡在不同手机上难度不一样。更糟的是伤害计算用的是 dt 乘系数,机器一卡就出现"跳过一段",判定和表现对不上。
改成固定步长累加:
const STEP = 1000 / 60;
let acc = 0;
function loop(now) {
const delta = Math.min(now - last, 250); // 切后台回来别一次性补几百帧
last = now;
acc += delta;
let guard = 0;
while (acc >= STEP && guard++ < 5) { // 上限防止死循环
update(STEP);
acc -= STEP;
}
render(acc / STEP); // 剩余时间做插值
requestAnimationFrame(loop);
}
三个细节都很重要:
Math.min(delta, 250):页面被切到后台再回来,delta可能是几十秒,不夹住就会瞬间跑几百帧guard < 5:单帧最多补 5 步,超过就丢弃,避免卡顿雪崩- 渲染用
acc / STEP做插值,画面才不会在低帧率下抖动
二、状态机:把"现在该干什么"收敛到一个变量
早期代码里到处都是 if (inBattle && !paused && wave > 0) 这种组合判断,加一个新状态就要改十几处。
后来收敛成单一状态字段:
const STATE = {
IDLE: 'idle',
BATTLE: 'battle',
PAUSED: 'paused',
RESULT: 'result',
};
关键是状态切换只能走一个入口,并且入口里做"进入/退出"的清理:
function setState(next, payload) {
const prev = state;
if (prev === next) return;
leave(prev); // 收尾:停计时器、清临时实体
state = next;
enter(next, payload); // 准备:初始化波次、播放进场
}
好处不是代码变短,而是"退出时该清什么"有了唯一的落脚点。之前最常见的 bug 就是暂停后再恢复,残留的计时器又触发了一次攻击。
三、存档:版本号 + 迁移函数
存档放 localStorage,一开始直接把整个存档对象 JSON.stringify 丢进去。后来加字段、改数据结构,老存档一读就炸——线上表现是"打开就卡在加载页"。
现在的写法是存档自带版本,读的时候逐级迁移:
const SAVE_VERSION = 7;
function migrate(save) {
let v = save.v || 1;
if (v < 3) { /* 3: 阵容从数组改成对象 */ save.team = {}; v = 3; }
if (v < 5) { /* 5: 增加星級字段 */ save.stars = {}; v = 5; }
if (v < 7) { /* 7: 资源字段改名 */ save.gold = save.coin ?? 0; v = 7; }
save.v = SAVE_VERSION;
return save;
}
两条纪律:
- 永远不删旧字段,只加。 删一次就要多写一个迁移分支,而且老用户可能还带着它
- 迁移函数必须能重复执行。 因为它可能在每次启动都跑一遍(读到半旧不新的存档时)
另一个必须做的是读档失败要能兜底:
try {
save = migrate(JSON.parse(raw));
} catch (e) {
console.warn('存档损坏,使用新档', e);
save = newSave();
localStorage.setItem(KEY, JSON.stringify({ v: SAVE_VERSION, ...newSave() }));
}
宁可让人从新档开始,也不要卡在加载页。卡住是"这游戏坏了",重开是"我的存档没了"——后者用户还能接受。
四、渲染:静态部分别每帧重画
战斗界面里有些东西是几乎不动的(背景、格子、固定文字)。早期每帧全画一遍,中低端机上明显掉帧。
做法是把静态层画到离屏 canvas 上一次,之后每帧只 drawImage 一次:
const bg = document.createElement('canvas');
bg.width = W; bg.height = H;
drawStaticLayer(bg.getContext('2d'));
// 每帧
ctx.drawImage(bg, 0, 0);
drawDynamicLayer(ctx);
代价是窗口尺寸变化时要重建离屏画布。所以尺寸相关的绘制必须全在那个函数里,不能散在别处。
五、一个反直觉的取舍
我一开始想做分层的多个 canvas 叠在一起(背景层 / 实体层 / UI 层),觉得这样各层可以独立更新。
实际做下来发现:多个 canvas 的合成开销,在小游戏这个体量上比省下的重绘还大,而且坐标、事件映射、层级顺序都要处理两遍。
最后回到单 canvas + 离屏缓存的方案。分层是有意义的,但意义在于"哪些内容变化频率不同",而不在于"物理上分成几块"。用离屏 canvas 一样能达到这个目的,还少一堆坐标换算。
小结
三个决定,对应三个"以后会疼"的地方:
| 决定 | 不这么做的后果 |
|---|---|
| 固定步长 | 不同设备难度不一致,判定和表现脱节 |
| 单一状态入口 | 状态组合爆炸,清理逻辑散落各处漏掉 |
| 存档带版本 | 改一次结构,老用户全部卡在加载页 |
这三样在写第一版的时候都显得"过度设计",但它们是我后来改得最少的代码。