上一节把场景的状态机立了起来,其中 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 消费的干净输入序列,是下一节要解决的问题。
阅读导航:上一节:场景管理与状态机 · 下一节:触控输入抽象与手势识别 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。