2.2 数字原型的迭代循环
《小游戏开发实战》的原型迭代决策 讲的是产品判断——这一版该继续打磨还是换方向;本节讲的是这个判断之下的工程闭环:一轮迭代具体花多少秒、改几行、结果变几个字段。最小可玩原型 那篇框的是「原型要做多小」,本节量的是「把原型改一次要多久」。上一节把内核压到最小,正是为了这一节:只有内核够小,一轮迭代才够便宜,便宜到你可以在一小时里试二十个参数。
循环的三步
迭代循环只有三步,每一步都必须是机器可执行的,否则「一轮」就不成立:改——在规格里改一个规则参数(diff 只动 1 行);测——跑内核与不变量断言(invariantsHold 为 true,否则这一轮被拒绝);跑——重跑原型,看 digest 变了几字段、代理指标相对怎么动。三步里最关键的是第 2 步:它必须能失败。一个永远通过的内核测试等于没有测试;下面会看到,坏参数正是被这一步拦下的。
一轮迭代的代价:可数的四个量
「改一次要多久」听起来像感觉,其实可以数:改了几行、内核跑了几微秒、代理指标重采样花了几微秒、digest 变了几个字段。下面这 5 轮来自 sim/gd_proto_02_iteration_loop.py 的实跑(逐轮累加,负例被拒绝后回滚):
| 轮 | 参数 | 改动 | 行数 | 内核 | 代理重采样 | digest | 变了几字段 |
|---|---|---|---|---|---|---|---|
| R1 | energyMax | 2→3 | 1 | 5 µs | 600 µs | 变 | 1(energy) |
| R2 | enemyHp | 20→24 | 1 | 6 µs | 644 µs | 变 | 6 |
| R3 | enemyAttack | 4→5 | 1 | 6 µs | 548 µs | 变 | 1(playerHp) |
| R4 | energyMax | 3→-1 | 1 | 5 µs | 517 µs | 拒绝 | 不变量失败 |
| R5 | rewardGold | 5→7 | 1 | 6 µs | 568 µs | 否 | 0 |
代理指标是种子池上的血量余裕度(hpMarginPermille,池 101..132)。必须显式声明:它是代理指标(proxy),只用于相对比较(R1 的 870 对 R3 的 363),禁绝对化——「余裕度从 870 降到 363」是可说的,「因此更好玩」不可说。
一轮的代价量级就出来了:改 1 行、内核几微秒、代理重采样几百微秒。这里的时间是 wall time,会随机负载抖动,所以只作参考;真正可复现的是「1 行」「digest 变了几字段」这些整数。
负例:改坏一个参数会怎样
R4 是本节刻意造出来的负例:把 energyMax 从 3 改成 -1。内核跑得出来——它不会崩,只是初始能量成了 -1,所有出牌因 cost > energy 被跳过,一回合都没打成,6 回合后超时。但快照一出来,不变量 energyInRange(0 <= energy <= energyMax)当场 fail:energy = -1 越了下界。于是这一轮被拒绝并回滚,工作规格停在 R3。
这就是迭代循环的价值所在:坏参数在测试里被拦住,不会流进原型。如果第 2 步只是「跑一下没报错」,energyMax = -1 会一路带进原型,然后你在浏览器里看到一个「牌打不出去」的怪现象,再回头找原因。负例证明的不是内核不会出错,而是错误会被当场抓住。
W 层:同一台内核在浏览器里重跑
原型重跑不该是「再写一个页面」,而是同一台内核换一个宿主。构建脚本复用 web/pack.mjs 的 bundle() 把五个模块擦类型后内联,页面暴露 run() / iterated() / stopLossRound():
// file: web/build_proto02.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto02.mjs
//
// 卷三第 2 章 数字原型:把 core/src/ashdeck/ 的确定性内核与最小 config
// (config/gd_proto_02_minimal_kernel.json)一起塞进**一个** HTML(out/proto02.html)。
// 页面暴露 globalThis.__ashdeck:run() 基线 digest / iterated() 迭代 3 轮后的 digest /
// stopLossRound() 页面内真跑止损循环算出的止损轮号(字符串)/ margin() / snapshot() / canvasDrawn。
// 擦类型复用 web/pack.mjs 的 bundle(),本章不重写擦除逻辑。
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { runSpec } from "../core/src/ashdeck/combat.ts";
import { digestSnap } from "../core/src/ashdeck/digest.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, ".."); // $HOME/.gdbook/ashdeck
// 手工拓扑序:先声明后使用;去掉跨文件 import 后同一作用域内不得重名。
const MODULES = [
"core/src/ashdeck/types.ts", "core/src/ashdeck/rng.ts", "core/src/ashdeck/cards.ts",
"core/src/ashdeck/combat.ts", "core/src/ashdeck/digest.ts",
];
const rd = (p) => JSON.parse(readFileSync(join(ROOT, p), "utf8"));
const spec = rd("config/gd_proto_02_minimal_kernel.json");
const iter = rd("config/gd_proto_02_iteration_loop.json");
const stop = rd("config/gd_proto_02_stop_loss.json");
const POOL = Array.from({ length: 32 }, (_, i) => 101 + i);
const core = bundle(ROOT, MODULES);
const OUT = join(ROOT, "out", "proto02.html");
const script = core + `
const SPEC = ` + inlineConfig(spec) + `;
const ITER = ` + inlineConfig(iter) + `;
const STOP = ` + inlineConfig(stop) + `;
const POOL = ` + inlineConfig(POOL) + `;
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
let snap = null;
function withParam(spec, key, val) {
const c = JSON.parse(JSON.stringify(spec));
c.params[key] = val;
return c;
}
// 代理指标:种子池上的血量余裕度(千分比)。只做相对比较,禁绝对化。
function margin(spec) {
let sum = 0;
for (const sd of POOL) {
const sp = JSON.parse(JSON.stringify(spec));
sp.seed = sd;
sum += runSpec(sp).playerHp;
}
return Math.floor((1000 * sum) / (POOL.length * spec.params.playerHp));
}
// 页面内真跑止损循环:连续 stallLimit 轮代理指标无变化即停,返回止损轮号。
function stopLossRound() {
let work = SPEC, prev = margin(work), streak = 0, ran = 0;
for (const r of STOP.rounds) {
if (ran >= STOP.policy.roundBudget) return ran;
const cand = withParam(work, r.param, r.to);
const s = runSpec(cand);
if (!invariantsHold(cand, s)) return r.round;
const m = margin(cand);
streak = (m !== prev) ? 0 : streak + 1;
work = cand; prev = m; ran += 1;
if (streak >= STOP.policy.stallLimit) return r.round;
}
return ran;
}
function render(s) {
const W = cv.width, H = cv.height, p = SPEC.params;
const bar = (x, y, w, h, n, d, c) => {
ctx.fillStyle = "#334155"; ctx.fillRect(x, y, w, h);
ctx.fillStyle = c; ctx.fillRect(x, y, Math.floor((w * n) / d), h);
};
ctx.fillStyle = "#111827"; ctx.fillRect(0, 0, W, H);
ctx.font = "13px system-ui, sans-serif";
bar(20, 24, W - 40, 16, s.enemyHp, p.enemyHp, "#f87171");
bar(20, H - 40, W - 40, 16, s.playerHp, p.playerHp, "#34d399");
ctx.fillStyle = "#e2e8f0";
ctx.fillText("敌方 HP " + s.enemyHp + " / " + p.enemyHp, 20, 60);
ctx.fillText("回合 " + s.turn + " 能量 " + s.energy + " / " + p.energyMax +
" 余裕度 " + margin(SPEC), 20, 92);
ctx.fillText("我方 HP " + s.playerHp + " / " + p.playerHp, 20, H - 48);
document.getElementById("out").textContent = digestSnap(s);
document.getElementById("stop").textContent = String(stopLossRound());
}
globalThis.__ashdeck = {
spec: SPEC,
run() { snap = runSpec(SPEC); render(snap); return digestSnap(snap); },
iterated() { snap = runSpec(ITER.rounds.slice(0, 3).reduce((a, r) => withParam(a, r.param, r.to), SPEC)); render(snap); return digestSnap(snap); },
stopLossRound() { return String(stopLossRound()); },
margin() { return margin(SPEC); },
snapshot() { return snap; },
canvasDrawn: false,
};
globalThis.__ashdeck.run();
// 冷启动首个 page 的 canvas 在首帧合成前读像素恒为空白;推迟到两次 rAF 之后再置真,
// 让通用验证器的 waitForFunction 等到「已合成一帧」而不是「已调用 draw」。
requestAnimationFrame(() => requestAnimationFrame(() => {
globalThis.__ashdeck.canvasDrawn = true;
}));
`;
const body = `<h1>《灰烬牌库》· 数字原型的最小内核</h1>
<div class="sub">一份 config 进,一条 digest 出;改一个参数,重跑一次内核</div>
<canvas id="cv" width="640" height="360"></canvas>
<div class="card"><div class="k">digest(应与 P / S 层逐字相同)</div><div id="out" class="k">—</div></div>
<div class="card"><div class="k">止损轮号(页面内真跑止损循环算出)</div><div class="big" id="stop">—</div></div>`;
const html = page({ title: "《灰烬牌库》· 第 2 章 数字原型",
subtitle: "最小内核 / 迭代循环 / 止损", body, script });
const bytes = writePage(OUT, html);
const iterSpec = iter.rounds.slice(0, 3).reduce((a, r) => {
const c = JSON.parse(JSON.stringify(a));
c.params[r.param] = r.to;
return c;
}, spec);
mkdirSync(join(ROOT, "out"), { recursive: true });
writeFileSync(join(ROOT, "out", "proto02_build.json"),
JSON.stringify({ spec: spec.id, modules: MODULES.length, kernelChars: core.length,
page: "out/proto02.html", pageBytes: bytes, pool: POOL.length,
baseDigest: digestSnap(runSpec(spec)), iterDigest: digestSnap(runSpec(iterSpec)) },
null, 2) + "\n");
console.log("modules=" + MODULES.length + " kernelChars=" + core.length);
console.log("built " + OUT + " " + bytes + " B");
console.log("BASE_DIGEST=" + digestSnap(runSpec(spec)));
console.log("ITER_DIGEST=" + digestSnap(runSpec(iterSpec)));
构建与真玩分两步,先构建后断言(只跑断言等于在验上一版页面):
# snippet: 先构建页面,再用通用验证器无头真玩(--run 选页面暴露的哪个方法)
node web/build_proto02.mjs
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs \
--page out/proto02.html --shot out/shots/proto02.png --run iterated \
--expect-digest '{"block":0,"cardCount":6,"damageConserved":true,"dealt":20,"enemyHp":4,"energy":3,"hand":[],"over":false,"played":6,"playerHp":11,"turn":6,"won":false}'
modules=5 kernelChars=4123
built /Users/bingrong.yan/.gdbook/ashdeck/out/proto02.html 10786 B
BASE_DIGEST={"block":0,"cardCount":6,"damageConserved":true,"dealt":20,"enemyHp":0,"energy":0,"hand":[],"over":true,"played":6,"playerHp":28,"turn":3,"won":true}
ITER_DIGEST={"block":0,"cardCount":6,"damageConserved":true,"dealt":20,"enemyHp":4,"energy":3,"hand":[],"over":false,"played":6,"playerHp":11,"turn":6,"won":false}
=== 通用断言 ===
ok 两遍独立加载 digest 逐字一致(确定性)
ok canvas 已绘制(230400 px >= 1)
ok 截图已落盘(52540 B)
ok digest 与 --expect-digest 一致
PAGE_DIGEST={"block":0,"cardCount":6,"damageConserved":true,"dealt":20,"enemyHp":4,"energy":3,"hand":[],"over":false,"played":6,"playerHp":11,"turn":6,"won":false}
OK W 层通过
浏览器里的 iterated() 给出 {"...","enemyHp":4,"playerHp":11,"turn":6,...},与 S 层 R3 的 digest 一字不差——W 与 P 是同一段源码的两个宿主(第 5 章),本就该相等。canvas 已绘制(230400 px) 是 640×360 画布的全部像素;断言只写「≥ 1 px」,因为冷启动首个 page 读 canvas 像素恒为空白,页面靠「两次 rAF 后再置 canvasDrawn」让验证器等到首帧合成。
P 层:把每一轮的 digest 钉死
W 层证明「内核在浏览器里也这么跑」,P 层把每一轮的 digest 固化成断言——包括负例那一轮:
// file: core/test/gd_proto_02_iteration_loop.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_02_iteration_loop.test.ts'
//
// 2.2 迭代循环的 P 层:改一个参数 → 内核测试 → 原型重跑。逐轮把 digest 钉死,证明每一轮的变化
// 是**机制的**而非随机的;第 4 轮是**负例**——把 energyMax 改成 -1(坏值),内核跑得出来,但
// 不变量 energyInRange(0 <= energy <= energyMax)当场 fail,于是这一轮被拒绝、工作规格回滚。
// 第 5 轮改 rewardGold:参数被接受,但 digest 不变——它不在这条策略序列的因果链上。
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { runSpec, invariantsHold } from "../src/ashdeck/combat.ts";
import { digestSnap } from "../src/ashdeck/digest.ts";
import type { Params, Spec } from "../src/ashdeck/types.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const SPEC: Spec = JSON.parse(
readFileSync(join(ROOT, "config", "gd_proto_02_minimal_kernel.json"), "utf8"),
);
const D0 = '{"block":0,"cardCount":6,"damageConserved":true,"dealt":20,"enemyHp":0,"energy":0,"hand":[],"over":true,"played":6,"playerHp":28,"turn":3,"won":true}';
const D1 = '{"block":0,"cardCount":6,"damageConserved":true,"dealt":20,"enemyHp":0,"energy":1,"hand":[],"over":true,"played":6,"playerHp":28,"turn":3,"won":true}';
const D2 = '{"block":0,"cardCount":6,"damageConserved":true,"dealt":20,"enemyHp":4,"energy":3,"hand":[],"over":false,"played":6,"playerHp":16,"turn":6,"won":false}';
const D3 = '{"block":0,"cardCount":6,"damageConserved":true,"dealt":20,"enemyHp":4,"energy":3,"hand":[],"over":false,"played":6,"playerHp":11,"turn":6,"won":false}';
const D4 = '{"block":0,"cardCount":6,"damageConserved":true,"dealt":0,"enemyHp":24,"energy":-1,"hand":["strike","guard","guard"],"over":false,"played":0,"playerHp":5,"turn":6,"won":false}';
function withParam(spec: Spec, key: keyof Params, val: number): Spec {
const c: Spec = JSON.parse(JSON.stringify(spec));
c.params[key] = val;
return c;
}
test("R0 基线 digest 钉死", () => {
assert.equal(digestSnap(runSpec(SPEC)), D0);
});
test("R1 energyMax 2→3:只有 energy 字段变(多出的 1 点能量没用完)", () => {
const s1 = withParam(SPEC, "energyMax", 3);
assert.equal(invariantsHold(s1, runSpec(s1)), true);
assert.equal(digestSnap(runSpec(s1)), D1);
});
test("R2 enemyHp 20→24:enemyHp/over/won 等 6 个字段变(这一轮打不死敌人了)", () => {
const s2 = withParam(withParam(SPEC, "energyMax", 3), "enemyHp", 24);
assert.equal(invariantsHold(s2, runSpec(s2)), true);
assert.equal(digestSnap(runSpec(s2)), D2);
});
test("R3 enemyAttack 4→5:只有 playerHp 字段变", () => {
const s3 = withParam(withParam(withParam(SPEC, "energyMax", 3), "enemyHp", 24), "enemyAttack", 5);
assert.equal(invariantsHold(s3, runSpec(s3)), true);
assert.equal(digestSnap(runSpec(s3)), D3);
});
test("R4 负例:energyMax → -1 被不变量 energyInRange 拦住", () => {
const s3 = withParam(withParam(withParam(SPEC, "energyMax", 3), "enemyHp", 24), "enemyAttack", 5);
const bad = withParam(s3, "energyMax", -1);
const snap = runSpec(bad);
assert.equal(snap.energy, -1);
assert.equal(invariantsHold(bad, snap), false);
assert.equal(digestSnap(snap), D4);
});
test("R5 rewardGold 5→7:参数被接受,但 digest 逐字不变", () => {
const s5 = withParam(withParam(withParam(SPEC, "energyMax", 3), "enemyHp", 24), "enemyAttack", 5);
const s5b = withParam(s5, "rewardGold", 7);
assert.equal(invariantsHold(s5b, runSpec(s5b)), true);
assert.equal(digestSnap(runSpec(s5b)), D3);
});
test("若 S 层记录已存在,逐轮 digest 必须与记录一致", () => {
const p = join(ROOT, "out", "gd_proto_02_iteration_loop.json");
if (!existsSync(p)) return; // 未跑过 S 层则跳过。
const rec: { baseDigest: string; finalDigest: string; rounds: { digest: string }[] } =
JSON.parse(readFileSync(p, "utf8"));
assert.equal(rec.baseDigest, D0);
assert.equal(rec.finalDigest, D3);
assert.equal(rec.rounds[0].digest, D1);
assert.equal(rec.rounds[1].digest, D2);
assert.equal(rec.rounds[3].digest, D4);
});
P 实跑输出
✔ R0 基线 digest 钉死
✔ R1 energyMax 2→3:只有 energy 字段变(多出的 1 点能量没用完)
✔ R2 enemyHp 20→24:enemyHp/over/won 等 6 个字段变(这一轮打不死敌人了)
✔ R3 enemyAttack 4→5:只有 playerHp 字段变
✔ R4 负例:energyMax → -1 被不变量 energyInRange 拦住
✔ R5 rewardGold 5→7:参数被接受,但 digest 逐字不变
✔ 若 S 层记录已存在,逐轮 digest 必须与记录一致
ℹ tests 7
ℹ pass 7
ℹ fail 0
七条全绿。R4 尤其重要:它断言的不是「内核不会坏」,而是「坏参数会被不变量拦住」——snap.energy === -1 与 invariantsHold(...) === false 同时成立,这一轮才有资格被拒绝。R5 则给出另一半:参数被接受但 digest 一字不变,说明它不在这条策略序列的因果链上——「改了没变」不是 bug,是机制的事实。
一轮迭代的账怎么记
账要记四笔:改了几行(规格 diff 的行数,本节恒为 1——改一行说明是参数迭代而非代码迭代)、内核耗时(单局 wall time,判断第 2 步会不会拖慢循环,只作参考)、代理重采样(种子池 N 局的 wall time,通常是循环里最贵的一步)、结果变化(digest 的逐字段 diff,变化必须是机制的、可解释的)。迭代循环不是「多试几次」,是每一次都记一笔可数的账;账记不住,就无法判断哪一轮真的动了机制。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
/opt/homebrew/bin/python3 sim/gd_proto_02_iteration_loop.py | out/gd_proto_02_iteration_loop.json | 5 轮:接受 4、拒绝 1 |
node web/build_proto02.mjs | out/proto02.html + out/proto02_build.json | 5 模块内联,10792 B |
NODE_PATH=… node web/verify_mech.mjs --run iterated … | out/shots/proto02.png | 4 断言全绿,52617 B |
node --test 'core/test/gd_proto_02_iteration_loop.test.ts' | 断言结果 | 7 测试全绿 |
循环跑起来了,但循环不会自己停——它会一直改下去。下一节回答最后一个问题:什么时候该停,并把「该停」写成一个能触发的判据。
阅读导航:上一节:2.1 数字原型的最小内核 · 下一节:2.3 原型的范围与止损 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。