MMHXYZ 技术笔记

单 Canvas 小游戏的骨架:固定步长、状态机、存档迁移

2026-10-24 · 游戏开发 · 约 7 分钟

这个项目是一个纯前端的单机游戏,战斗、界面、存档全在浏览器里跑(外面套了个 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 一样能达到这个目的,还少一堆坐标换算。

小结

三个决定,对应三个"以后会疼"的地方:

决定不这么做的后果
固定步长不同设备难度不一致,判定和表现脱节
单一状态入口状态组合爆炸,清理逻辑散落各处漏掉
存档带版本改一次结构,老用户全部卡在加载页

这三样在写第一版的时候都显得"过度设计",但它们是我后来改得最少的代码。

← 返回全部笔记