5.3 暂停、切后台与恢复

暂停容易,恢复难:切后台十分钟再回来,若把十分钟按固定步长如实补回,一帧要跑三万七千步,设备直接卡死。本节用封顶补偿把离线时长折算成有上限的固定步数,并把整条「暂停 → 切后台 → 恢复」路径放进单文件 HTML 原型,用 Playwright 无头断言加截图验收。

上一节把场景的状态机立了起来,其中 pause / resume 两个动作只做了一件事:改状态。这一节要回答那个被推迟的问题——暂停期间的时间去哪了。暂停本身不难,难的是恢复:切后台十分钟再回来,这十分钟算不算数?如果把手机锁屏的十分钟都当作「游戏时间」如实补上,玩家回来会发现自己离线期间被别的玩家打了十次——这显然不对;可如果一分钟都不补,又和「放置」类玩法的期待矛盾。恢复策略的选择,直接决定了游戏「离线时世界动不动」。

恢复的三种策略

策略恢复时做什么代价
冻结一步不补,回到暂停那一刻离线收益为零,放置玩法失效
全补把离线时长全部按固定步补上长时间离线一帧跑几万步,卡死
封顶补补到上限为止,其余丢弃需要定义「上限」并接受截断

《合成小岛》是放置向的,冻结不行;而全补会踩上上一节讲的死亡螺旋的远房亲戚——不是每帧补一点累积成螺旋,而是单帧一次性补一个巨大的步数。算一下:离线 10 分钟 = 600000ms,按 16ms 一步就是 37500 步。哪怕一步只要 0.1ms,一帧也要跑 3.75 秒,玩家切回来先黑屏几秒。离线一小时呢?22.5 万步,直接当成卡死处理。所以答案是封顶补:离线时长照算,但折算出的步数封一个上限,多出来的部分明确丢弃。本节配置里 maxCatchUpSteps = 300、minCatchUpMs = 32,也就是说单次恢复最多补 300 步(4800ms),小于 32ms 的抖动不补——这两个数是设计决策,不是物理常数,后面会讲怎么定。

P 层内核:封顶补偿

下面这段是 P 层内核 core/src/e05/lifecycle.ts。resume 的逻辑只有四步:算离线时长 elapsed、折算成 wantedSteps、封顶得到 catchUpSteps、再把这 catchUpSteps 步喂给上一节的 advanceSteps。注意它返回的 ResumeResult 把 wantedSteps(本该补多少)、catchUpSteps(实际补了多少)、droppedMs(丢了多少毫秒)、carriedMs(不足一步的余量还给累加器)全列了出来——补偿必须是可观测的,否则你永远不知道离线期间发生了什么。

// file: core/src/e05/lifecycle.ts
// tier: P
// verify: node --test 'core/test/e05_*.test.ts'
//
// 暂停 / 切后台 / 恢复:把离线时长折算成固定步数并封顶,避免「回来一帧跑一万步」。
// 与固定步长主循环共用同一份 stepMs;恢复后世界状态与「一直在线」逐位对齐到封顶处。
// 只用可擦除语法(禁 enum / namespace)。

import { advanceSteps, digest, type Loop, type LoopConfig } from "./loop.ts";

export interface LifecycleConfig {
  readonly stepMs: number;
  readonly maxCatchUpSteps: number;
  readonly minCatchUpMs: number;
}

export type AppState = "running" | "paused" | "background";

export interface ResumeResult {
  readonly ok: boolean;
  readonly state: AppState;
  readonly elapsedMs: number;
  readonly wantedSteps: number;
  readonly catchUpSteps: number;
  readonly carriedMs: number;
  readonly droppedMs: number;
  readonly digest: string;
}

export interface Lifecycle {
  state: AppState;
  pausedAtMs: number;
  log: string[];
}

export function createLifecycle(): Lifecycle {
  return { state: "running", pausedAtMs: -1, log: [] };
}

export function pause(lc: Lifecycle, nowMs: number): boolean {
  if (lc.state !== "running") return false;
  lc.state = "paused";
  lc.pausedAtMs = nowMs;
  lc.log.push("pause@" + nowMs);
  return true;
}

export function background(lc: Lifecycle, nowMs: number): boolean {
  if (lc.state !== "running") return false;
  lc.state = "background";
  lc.pausedAtMs = nowMs;
  lc.log.push("background@" + nowMs);
  return true;
}

/** 恢复:离线时长按固定步长补齐,单次封顶 maxCatchUpSteps 步;不足一步的余量还给累加器。 */
export function resume(lc: Lifecycle, nowMs: number, cfg: LifecycleConfig, loop: Loop, loopCfg: LoopConfig): ResumeResult {
  if (lc.state === "running") {
    return { ok: false, state: lc.state, elapsedMs: 0, wantedSteps: 0, catchUpSteps: 0,
             carriedMs: 0, droppedMs: 0, digest: digest(loop.world) };
  }
  const elapsed = Math.max(0, nowMs - lc.pausedAtMs);
  const wantedSteps = Math.floor(elapsed / cfg.stepMs);
  const remainder = elapsed - wantedSteps * cfg.stepMs;
  let catchUpSteps = wantedSteps;
  let carriedMs = remainder;
  let droppedMs = 0;
  if (elapsed < cfg.minCatchUpMs) {
    catchUpSteps = 0;
    carriedMs = 0;
    droppedMs = elapsed;
  } else if (wantedSteps > cfg.maxCatchUpSteps) {
    catchUpSteps = cfg.maxCatchUpSteps;
    carriedMs = 0;
    droppedMs = elapsed - catchUpSteps * cfg.stepMs;
  }
  advanceSteps(loop, catchUpSteps, loopCfg);
  loop.accumulator += carriedMs;
  lc.state = "running";
  lc.log.push("resume@" + nowMs + "+" + catchUpSteps);
  return { ok: true, state: lc.state, elapsedMs: elapsed, wantedSteps, catchUpSteps,
           carriedMs, droppedMs, digest: digest(loop.world) };
}

两个细节值得单说。第一,不足一步的余量要还给累加器(carriedMs):离线 500ms,按 16ms 只能补 31 步,剩 4ms。这 4ms 不该丢——它被加回 loop.accumulator,下一帧接着用。只有真正被封顶截断的部分才进 droppedMs。第二,minCatchUpMs 挡的是抖动:切后台再回前台可能只隔十几毫秒(比如系统弹了个权限框又关掉),这点时间补一步没意义,反而可能触发一次多余的状态推进,所以直接丢弃。

对齐语义:恢复后必须等于「一直在线」

补偿做对没做对,有一个干净的判据:恢复后的世界状态,必须与「这台设备一直在线、一直跑到封顶那一步」的状态逐位一致。因为封顶补偿补的就是 catchUpSteps 个固定步,而固定步长保证了「补 N 步」和「在线跑 N 步」是同一条轨迹。测试里就是这么断言的:一条 loop 先在线跑 100 步、暂停、离线 5000ms 恢复(补 300 步,共 400 步);另一条 loop 直接在线跑 400 步;两者摘要必须相等。这条判据也解释了封顶的代价:一旦触发封顶,恢复后的世界就不等于「一直在线」了——因为多出来的时间被明确丢弃了。这是有意为之:面对「玩家离线了一小时」这种情况,「和一直在线对齐」本就不可能也不应该(真的在线一小时,世界会走到很远),封顶让游戏「假装只过了 4.8 秒」,然后由离线收益系统单独结算那一小时。实时补偿和离线收益是两套系统:前者管「世界别卡死」,后者管「玩家别吃亏」。把两者分开,是这一节最重要的架构判断。

W 层:把整条路径放进浏览器

P 层内核光跑测试还不够,得有一个能在浏览器里点、能被人看见的原型。下面两个块是一对:web/build_e05.mjs 把 loop.ts + scene.ts + lifecycle.ts 用 pack.mjs 的擦除器打成一个单文件 HTML(产物 web/dist/e05.html),web/verify_e05.mjs 用 Playwright 无头打开它、跑一条确定的「暂停 → 恢复」时间线、断言状态并截图。

#!/usr/bin/env node
// file: web/build_e05.mjs
// tier: W
// verify: node web/build_e05.mjs
// 把 core/src/e05 的固定步长主循环 + 场景栈 + 生命周期打成单文件 HTML 可玩原型。
// 必须复用 web/pack.mjs 的擦除逻辑与页面外壳,产物 web/dist/e05.html。

import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const loopCfg = JSON.parse(readFileSync(join(ROOT, "config", "e05_loop.json"), "utf8"));
const lcCfg = JSON.parse(readFileSync(join(ROOT, "config", "e05_lifecycle.json"), "utf8"));
const js = bundle(ROOT, ["core/src/e05/loop.ts", "core/src/e05/scene.ts", "core/src/e05/lifecycle.ts"]);

const script = `const CFG = ${inlineConfig(loopCfg)};
const LC = ${inlineConfig(lcCfg)};
${js}
const inputs = Array.from({ length: 100000 }, (_v, i) => (i % 40 < 20 ? 1 : -1));
let loop = createLoop(CFG, inputs), lc = createLifecycle(), stack = createStack(), wallMs = 0;
let lastResume = { catchUpSteps: 0, droppedMs: 0 };
const boot = () => { loop = createLoop(CFG, inputs); lc = createLifecycle(); stack = createStack(); wallMs = 0; lastResume = { catchUpSteps: 0, droppedMs: 0 }; push(stack, "menu"); push(stack, "game"); draw(); };
const cv = document.getElementById("board"), g = cv.getContext("2d");
function draw() {
  const W = cv.width, H = cv.height;
  g.clearRect(0, 0, W, H);
  g.fillStyle = "#1e293b"; g.fillRect(0, H / 2 - 1, W, 2);
  const x = ((loop.world.posX % W) + W) % W;
  const dy = Math.max(-H / 2 + 24, Math.min(H / 2 - 24, loop.world.velY * 6));
  g.fillStyle = lc.state === "running" ? "#38bdf8" : "#f97316";
  g.beginPath(); g.arc(x, H / 2 + dy, 10, 0, Math.PI * 2); g.fill();
  document.getElementById("state").textContent = lc.state;
  document.getElementById("scenes").textContent = dump(stack);
  document.getElementById("tick").textContent = String(loop.world.tick);
  document.getElementById("catchup").textContent = lastResume.catchUpSteps + " / " + lastResume.droppedMs + "ms";
  document.getElementById("digest").textContent = digest(loop.world);
  globalThis.__pebble = {
    state: lc.state, scenes: dump(stack), digest: digest(loop.world), tick: loop.world.tick,
    posX: loop.world.posX, score: loop.world.score, accumulator: loop.accumulator, wallMs,
    lastResume, canvasDrawn: true, paintCount: (globalThis.__pebble?.paintCount ?? 0) + 1, api,
  };
}
const api = {
  stepFrames(n, frameMs) { for (let i = 0; i < n; i += 1) { const dt = frameMs ?? CFG.stepMs; frame(loop, dt, CFG); wallMs += dt; } draw(); return loop.world.tick; },
  pauseNow() { const ok = pause(lc, wallMs); draw(); return ok; },
  backgroundNow() { const ok = background(lc, wallMs); draw(); return ok; },
  resumeAfter(gapMs) { wallMs += gapMs; const r = resume(lc, wallMs, LC, loop, CFG); lastResume = r; draw(); return r; },
  pushScene(id) { const r = push(stack, id); draw(); return r.ok; },
  popScene() { const r = pop(stack); draw(); return r.ok; },
  reset: boot,
};
document.getElementById("step").addEventListener("click", () => api.stepFrames(10));
document.getElementById("pause").addEventListener("click", () => api.pauseNow());
document.getElementById("resume").addEventListener("click", () => api.resumeAfter(5000));
document.getElementById("huge").addEventListener("click", () => api.resumeAfter(3600000));
document.getElementById("push").addEventListener("click", () => api.pushScene("popup"));
document.getElementById("pop").addEventListener("click", () => api.popScene());
document.getElementById("reset").addEventListener("click", () => api.reset());
boot();
`;

const body = `  <h1>《合成小岛》· 固定步长主循环原型</h1>
  <div class="sub">逻辑由 core/src/e05 提供:固定步长累加器 + 场景栈 + 暂停/恢复封顶补偿</div>
  <div class="card row"><div><div class="k">应用状态</div><div class="big" id="state">-</div></div>
    <div><div class="k">tick(固定步)</div><div class="big" id="tick">0</div></div></div>
  <div class="card"><div class="k">场景栈</div><div id="scenes" style="font-family:ui-monospace,monospace">-</div></div>
  <div class="card"><div class="k">上次恢复:补齐步数 / 丢弃毫秒</div><div id="catchup">0 / 0ms</div></div>
  <div class="card"><canvas id="board" width="720" height="180"></canvas></div>
  <div class="card"><div class="k">世界摘要</div><div id="digest" style="font-family:ui-monospace,monospace;font-size:13px">-</div></div>
  <div class="card row"><button id="step">前进 10 帧</button><button id="pause">暂停</button>
    <button id="resume">恢复(离线 5s)</button><button id="huge">恢复(离线 1h)</button></div>
  <div class="card row"><button id="push">入栈 popup</button><button id="pop">出栈</button><button id="reset">重置</button></div>`;

const bytes = writePage(join(ROOT, "web/dist/e05.html"), page({ title: "《合成小岛》固定步长主循环原型 · e05", body, script }));
console.log(JSON.stringify({ html: "web/dist/e05.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));
#!/usr/bin/env node
// file: web/verify_e05.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e05.mjs web/dist/e05.html
// W 层验收:Playwright 无头打开单文件 HTML,模拟「暂停 → 切后台 → 恢复」并断言状态;
// 采样 globalThis.__pebble、检查 canvas 真画了东西、跑两遍验确定性、截图存档。

import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";

const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/e05.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const warm = await browser.newPage(); await warm.goto(url); await warm.close(); // 预热:冷启动首个 page 读 canvas 像素恒为空白
const errors = [];
const open = async () => {
  const p = await browser.newPage({ viewport: { width: 800, height: 900 } });
  p.on("pageerror", (e) => errors.push(String(e)));
  await p.goto(url);
  await p.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 15000 });
  return p;
};

// 一段确定的合成时间线:跑 100 帧 → 暂停 → 离线 5s 恢复 → 再暂停 → 离线 1h 恢复 → 场景栈出入。
const run = (page) => page.evaluate(() => {
  const a = globalThis.__pebble.api;
  a.stepFrames(100, 16);
  const t100 = globalThis.__pebble.tick;
  a.pauseNow();
  const paused = globalThis.__pebble.state;
  const r5 = a.resumeAfter(5000);
  const t500 = globalThis.__pebble.tick;
  a.pauseNow();
  const r1h = a.resumeAfter(3600000);
  const pushOk = a.pushScene("popup"), scenesTop = globalThis.__pebble.scenes;
  const popOk = a.popScene(), scenesBack = globalThis.__pebble.scenes;
  a.popScene(); a.popScene();
  const scenesEmpty = globalThis.__pebble.scenes, popEmpty = a.popScene();
  const d = globalThis.__pebble;
  return { t100, paused, r5, t500, r1h, pushOk, scenesTop, popOk, scenesBack,
           scenesEmpty, popEmpty, state: d.state, digest: d.digest, paintCount: d.paintCount };
});

// canvas 像素采样在无头下偶有抖动,用有界重试取最大值。
const painted = async (page) => {
  const read = () => page.evaluate(() => {
    const cv = document.getElementById("board");
    const px = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data;
    let c = 0;
    for (let i = 0; i < px.length; i += 4) if (px[i + 3] > 0 && (px[i] + px[i + 1] + px[i + 2]) > 60) c += 1;
    return c;
  });
  let best = 0;
  for (let t = 0; t < 30; t += 1) {
    best = Math.max(best, await read());
    if (best > 500) break;
    await page.waitForTimeout(100);
  }
  return best;
};

const page = await open();
const a = await run(page);
a.painted = await painted(page);
await page.screenshot({ path: join(ROOT, "out/shots/e05_prototype.png"), fullPage: true });
await page.close();
const page2 = await open();
const b = await run(page2);
await page2.close();
await browser.close();

assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(a.t100, 100, "100 帧 × 16ms 应补 100 步");
assert.equal(a.paused, "paused", "暂停后状态应为 paused");
assert.equal(a.r5.elapsedMs, 5000);
assert.equal(a.r5.wantedSteps, 312, "floor(5000/16)=312");
assert.equal(a.r5.catchUpSteps, 300, "应封顶到 300 步");
assert.equal(a.r5.droppedMs, 200, "5000-300×16=200ms 被丢弃");
assert.equal(a.t500, 400, "100 + 300 = 400 步");
assert.equal(a.state, "running", "恢复后状态应为 running");
assert.equal(a.r1h.wantedSteps, 225000);
assert.equal(a.r1h.catchUpSteps, 300, "离线 1h 也只补 300 步");
assert.equal(a.scenesTop, "menu=paused,game=paused,popup=active");
assert.equal(a.scenesBack, "menu=paused,game=active");
assert.equal(a.scenesEmpty, "");
assert.equal(a.popEmpty, false, "空栈出栈应被拒");
assert.ok(a.paintCount > 0, "draw() 应至少被调用一次");
assert.ok(a.painted > 500, `canvas 应有绘制内容,实测 ${a.painted} px`);
assert.equal(b.digest, a.digest, "同 seed 同输入,两次加载终局应逐位一致");

console.log(JSON.stringify({ page: "web/dist/e05.html", stepsAt100Frames: a.t100, pausedState: a.paused,
  resume5s: { wantedSteps: a.r5.wantedSteps, catchUpSteps: a.r5.catchUpSteps, droppedMs: a.r5.droppedMs, tick: a.t500 },
  resume1h: { wantedSteps: a.r1h.wantedSteps, catchUpSteps: a.r1h.catchUpSteps }, scenesTop: a.scenesTop,
  canvasPaintedPx: a.painted, deterministicRerun: { digestA: a.digest, digestB: b.digest },
  screenshot: "out/shots/e05_prototype.png" }, null, 2));
console.log("SMOKE-W5: PASS");

本机跑 node web/build_e05.mjs && NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e05.mjs web/dist/e05.html,实测输出:

{"stepsAt100Frames":100,"pausedState":"paused","resume5s":{"wantedSteps":312,"catchUpSteps":300,"droppedMs":200,"tick":400},"resume1h":{"wantedSteps":225000,"catchUpSteps":300},"canvasPaintedPx":1778,"deterministicRerun":{"digestA":"700|-4331|...","digestB":"700|-4331|..."}}  SMOKE-W5: PASS

数字读出来三件事。第一,封顶真的挡住了长离线:离线 5 秒本该补 312 步,实际只补 300 步、丢了 200ms;离线 1 小时本该补 22.5 万步,照样只补 300 步。这就是「回来一帧跑一万步」的解药——无论离线多久,单帧补步数永远是常数。第二,恢复后与在线一致:tick 从 100 补到 400,正好是「在线多跑 300 步」。第三,确定性依然成立:两次独立加载跑同一段时间线,世界摘要逐位相同。截图落在 out/shots/e05_prototype.png。

平台事件:把差异挡在适配层

原型里的「暂停 / 恢复」是按钮触发的,真机上则由平台事件触发:微信小游戏的 wx.onHide / wx.onShow、切到其他 App、锁屏、系统来电。这些事件的具体行为(触发时机、是否成对、后台是否真的冻结了 JS 执行)因平台和系统版本而异,属于需真实业务环境、命令行不可复现的部分(X 层)——本机原型只能验证「事件到达之后我们的逻辑对不对」,验证不了「事件本身什么时候到」。所以工程上的做法是把平台事件挡在适配层:宿主把 onHide 翻译成一次 background(lc, now)、把 onShow 翻译成一次 resume(lc, now, ...),内核只认这两个抽象调用,不认任何平台 API。这样内核逻辑可以在本机用合成事件测到穷尽,平台差异则收窄成适配层里几行 if。第 10 章的「平台能力适配层」会把这件事做完整,本节只把恢复语义钉死。

自检表

上线前逐条对照:

检查项合格标准
恢复策略封顶补(不冻结、不全补);离线收益与实时补偿分开,另有一套结算
上限与余量maxCatchUpSteps 随玩法定;不足一步还给累加器,被截断的进 droppedMs
抖动与可观测小于 minCatchUpMs 的离线直接丢弃;恢复返回 wantedSteps / catchUpSteps / droppedMs 可打点
对齐未封顶时,恢复后状态与「一直在线」逐位一致

暂停与恢复讲完了,本卷这一章(游戏循环与状态机)也就收尾了。回头看,三节其实是一条链:5.1 把时间钉成固定步,5.2 把「谁在走」钉成状态机,5.3 把「暂停期间的时间」钉成封顶补偿。这条链的每一环都指向同一个目标——让小游戏在千奇百怪的设备与生命周期事件下,行为始终可预测、可复现、可回放。下一节进入输入与交互:前面我们假设「输入」是一个已经排好序的数组,可真实玩家不会按数组出牌——触控是异步的、手势是连续的、不同平台的事件模型还不一样。怎么把一团乱糟糟的触摸事件,整理成主循环能按 tick 消费的干净输入序列,是下一节要解决的问题。

阅读导航:上一节:场景管理与状态机 · 下一节:触控输入抽象与手势识别 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制