13.3 变体的可玩性闸门
站内 《游戏美术与技术美术》 把「品类」当成视觉风格的轴,量的是风格之间的距离;本节把「品类」当成规格的轴,量的是同一套判据在不同规格上的通过与否。13.2 证明了六个变体跑得动——但这只说明它们没崩,不说明它们能玩。这一节要立一套判据,同时判六个变体。
先定「能玩」的可判形态
「能玩」是主观的,但可以拆成几条可判真假的性质。本节用五条:
| # | 判据 | 形式 | 它排除的坏情况 |
|---|---|---|---|
| ① | determinism | 同输入跑两遍,digest 逐字相同 | 结果随机、不可复现 |
| ② | invariants | 内核的不变量谓词在全部输入上成立 | 规则被破坏 |
| ③ | terminal_reachable | 全部输入都到达终态,且无死局 / 卡死 | 一局打不完 |
| ④ | contract_isomorphic | 快照投影进同一套 12 个契约槽位 | 变体偷偷改了契约 |
| ⑤ | perturbation_spread | 声明的扰动轴上结果桶数 ≥ 2 | 玩法退化成一个结果 |
①~④ 都是「不可能发生某事」型断言,与全书口径一致。真正需要设计的是⑤。
⑤ 为什么不是「胜率落在某个区间」
最直觉的判据是「胜率不能是 0‰ 也不能是 1000‰」。这条判据在本章会立刻失败:本体、塔防、自走棋的胜率都是 1000‰(第 5、6、9 章把它们的胜利条件调成了「策略正确就赢」),三消与放置都是 0‰(第 7、10 章的胜利条件故意设得够不着)。六条里五条不过——这不是变体坏了,是判据错了。
判据错在哪:它把「某个数值落在区间里」当成了可玩性,而本章的六个配置是按各自章节的设计意图调过的。硬把它们调进同一个胜率区间,等于抹掉那六章的全部结论。
正确的形态是非退化:在该品类自己声明的扰动轴上,结果不能塌成一个。于是判据⑤只要求「结果桶(digest)数 ≥ 2」,而扰动轴由注册表声明、不由结果反推:
| 变体 | 扰动轴 | 轴点 | 为什么是这条轴 |
|---|---|---|---|
| 卡牌构筑 / 塔防 / 三消 / 自走棋 / 放置 | seed | 16 | 不确定来源是 rngNext 的种子流 |
| 平台跳跃 | timing | 81 | pfRun 不调用 rngNext,改种子结果不变;不确定来源是玩家逐跳的按下帧 |
平台跳跃那一行是个诚实的让步:它的种子轴是惰性的(16 个种子只出 1 个结果桶)。如果按「统一用种子轴」办,它必然不过;但这不是它的缺陷,是它的机制里根本没有随机。所以报告里对每个变体都另记一条统一的种子轴诊断(seedAxis),平台跳跃那条 distinctBuckets: 1 原样落盘、原样贴出,不掩盖。
闸门报告(逐字贴出)
// snippet: out/gd_proto_13_gate.json 的头部(逐字;variants 数组见下)
{
"id": "gd_proto_13_gate",
"gateVersion": 1,
"criteria": [
"determinism",
"invariants",
"terminal_reachable",
"contract_isomorphic",
"perturbation_spread"
],
"minDistinctBuckets": 2,
"contractSlots": [
"block",
"cardCount",
"damageConserved",
"dealt",
"enemyHp",
"energy",
"hand",
"over",
"played",
"playerHp",
"turn",
"won"
],
"proxyFields": [
"spread.winPermille",
"spread.dominantPermille",
"seedAxis.winPermille",
"seedAxis.dominantPermille"
],
"proxyNote": "winPermille / dominantPermille 是代理指标(proxy),只做相对比较,禁绝对化",
variants 是六条记录。六条的字段完全相同(这正是 13.1 的「结构同构」在报告层的样子),为篇幅只贴一条——平台跳跃那条,因为它同时含典型字段与本节唯一一处例外:
// snippet: out/gd_proto_13_gate.json 里 platformer 那条记录(逐字,注意 seedAxis)
{
"genre": "platformer",
"label": "平台跳跃",
"spec": "config/gd_proto_08_platformer.json",
"specId": "gd_proto_08_platformer",
"kernel": "core/genre/platformer.ts",
"axis": "timing",
"axisPoints": 81,
"terminalKind": "goalX|frameLimit|fallLimit",
"determinism": {
"runs": 2,
"digestsEqual": true,
"digest": "{\"block\":4750,\"cardCount\":3,\"damageConserved\":true,\"dealt\":30000,\"enemyHp\":0,\"energy\":250,\"hand\":[\"P1\",\"P2\"],\"over\":true,\"played\":2,\"playerHp\":0,\"turn\":120,\"won\":true}"
},
"invariants": {
"checked": 81,
"held": 81
},
"terminal": {
"checked": 81,
"reached": 81
},
"contract": {
"slots": 12,
"matches": true
},
"spread": {
"distinctBuckets": 5,
"dominantPermille": 888,
"winPermille": 888
},
"seedAxis": {
"distinctBuckets": 1,
"dominantPermille": 1000,
"points": 16,
"winPermille": 1000
},
"criteria": {
"determinism": true,
"invariants": true,
"terminal_reachable": true,
"contract_isomorphic": true,
"perturbation_spread": true
},
"failed": [],
"pass": true
}
平台跳跃的 axisPoints 是 81 而不是 16,因为 timing 轴是「两跳各平移 ±4 帧」的笛卡尔积(9 × 9)。±4 不是随手取的:该关实测容差 pfReqTol = 2,平移 ±4 帧正好跨过可解边界,于是 81 个输入里既有解得出的、也有解不出的——winPermille: 888 就是这么来的(81 个里 72 个仍能通关)。如果幅度取 ±1,81 个输入全在容差内,桶数会塌回 1,判据⑤会假通过。扰动幅度必须跨过边界,这条比判据本身更容易被忽略。
P 层:一套判据跑六个变体
# snippet: 跑 13.3 的 P 层闸门(写 out/gd_proto_13_gate.json)
node --test 'core/test/gd_proto_13_3_variant_gate.test.ts'
genre axis 轴点 桶 主桶‰ 胜率‰ 种子轴桶 种子轴胜率‰ 通过
deckbuilder seed 16 16 62 1000 16 1000 是
tower-defense seed 16 10 187 1000 10 1000 是
match3 seed 16 16 62 0 16 0 是
platformer timing 81 5 888 888 1 1000 是
autobattler seed 16 16 62 1000 16 1000 是
idle seed 16 16 62 0 16 0 是
passed=6 failed=[] allPass=true
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 242.872792
六行全绿,但表里藏着两个值得读的数字。一是塔防的种子轴只有 10 个桶(16 个种子里有重复结果),是六个变体里最低的。查下去发现原因很具体:这 16 个种子里 dealt 恒为 772、enemyHp 恒为 0,唯一在变的是 played(开火次数,10 个不同值)。因为种子只喂给每击的伤害抖动(jitterPermille = 250,即 ±25%),而伤害经整数地板除与 Math.min(dmg, tgt.hp) 截顶之后,剩下的差别只够让某些波少开一两枪。digest 的分辨率取决于扰动够得着哪些字段——这是一条比「桶数」本身更有用的结论。二是平台跳跃那一行的「种子轴桶 = 1」:这一列不参与判据,是统一的诊断位,专门用来说明「它的不确定来源不在这里」。
代理指标纪律
表里的 winPermille(胜率千分比)与 dominantPermille(最大结果桶占比千分比)都是代理指标(proxy),报告里用 proxyFields 显式列出、用 proxyNote 显式声明:
- 它们只做相对比较,禁绝对化。本体 1000‰ 与三消 0‰ 不代表「前者比后者好玩」,只代表两者的胜利条件被调成了不同的可及性。
- 它们的粒度很粗。塔防 187‰ 的主桶占比与本体 62‰ 差三倍,但两者都是「16 个种子里 16 个桶」,可玩性上没有三倍差距。
- 判据⑤不读这两个数,只读
distinctBuckets。把代理指标从判据里摘出去,是本节最重要的设计决定——否则就会回到「胜率必须落在区间里」那个错判据。
S 层:把报告读回来复算一致性
闸门报告是机器产物,所以还要有一个独立读它的人——否则报告自己写错了也没人知道:
# snippet: 读 out/gd_proto_13_gate.json,复算一致性并画记分卡
/opt/homebrew/bin/python3 sim/gd_proto_13_3_gate_report.py
gate=gd_proto_13_gate version=1
criteria=determinism|invariants|terminal_reachable|contract_isomorphic|perturbation_spread minBuckets=2
variants=6 passed=6 failed=0 allPass=True
totalAxisChecks=161 maxSpreadBuckets=16
deckbuilder axis=seed pts= 16 buckets=16 domPermille= 62 winPermille=1000 seedBuckets=16 PASS
tower-defense axis=seed pts= 16 buckets=10 domPermille=187 winPermille=1000 seedBuckets=10 PASS
match3 axis=seed pts= 16 buckets=16 domPermille= 62 winPermille= 0 seedBuckets=16 PASS
platformer axis=timing pts= 81 buckets= 5 domPermille=888 winPermille= 888 seedBuckets= 1 PASS
autobattler axis=seed pts= 16 buckets=16 domPermille= 62 winPermille=1000 seedBuckets=16 PASS
idle axis=seed pts= 16 buckets=16 domPermille= 62 winPermille= 0 seedBuckets=16 PASS
svg=11061 B csv=6 rows
OK 闸门报告自洽,6/6 通过
totalAxisChecks=161 = 5 × 16 + 81。S 层复算的是报告自身的一致性:passed 与逐条 criteria 是否对得上、failed 列表与 pass 是否对得上、allPass 与 passed 是否对得上、每条 held == checked、reached == checked、slots == 12、buckets >= minDistinctBuckets。任何一条不自洽就报 FAIL 并以非零码退出。
W 层:确定性在浏览器里也成立
闸门的①号判据是「同输入两遍 digest 逐字相同」。它在 Node 里成立不算数——得在另一个运行时里也成立:
// file: out/gd_proto_13_wcheck.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node out/gd_proto_13_wcheck.mjs
//
// 13.2 的 W 层加断言:通用验证器只调一次 __ashdeck.run()(默认品类),本脚本把**六个品类
// 逐个**在无头浏览器里跑一遍,断言每个品类的页面 digest == 构建期 Node 侧算出的 digest
// (即 out/gd_proto_13_build.json 里冻结的那条),并断言同一品类两遍逐字一致。
// 预热页是必需的:Playwright 冷启动首个 page 读 canvas 像素恒为空白。
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..");
const PAGE = resolve(join(ROOT, "out", "proto13.html"));
const BUILD = JSON.parse(readFileSync(join(ROOT, "out", "gd_proto_13_build.json"), "utf8"));
const GENRES = BUILD.genres;
const fails = [];
const check = (c, m) => { console.log(` ${c ? "ok " : "FAIL"} ${m}`); if (!c) fails.push(m); };
const browser = await chromium.launch();
try {
const warm = await browser.newPage();
await warm.goto("about:blank");
await warm.evaluate(() => {
const c = document.createElement("canvas");
c.width = 8; c.height = 8;
c.getContext("2d").getImageData(0, 0, 8, 8).data.length;
});
await warm.close();
const open = async () => {
const p = await browser.newPage({ viewport: { width: 900, height: 760 } });
await p.goto("file://" + PAGE);
await p.waitForFunction(() => globalThis.__ashdeck && globalThis.__ashdeck.canvasDrawn === true,
null, { timeout: 15000 });
return p;
};
const a = await open();
check(JSON.stringify(await a.evaluate(() => globalThis.__ashdeck.genres())) === JSON.stringify(GENRES),
"页面暴露的六个品类名与注册表逐个相同");
for (const g of GENRES) {
const d = String(await a.evaluate((x) => globalThis.__ashdeck.run(x), g));
check(d === BUILD.nodeDigests[g], `浏览器 ${g} 的 digest == 构建期 Node 侧 digest`);
}
const first = String(await a.evaluate(() => globalThis.__ashdeck.run("idle")));
const again = String(await a.evaluate(() => globalThis.__ashdeck.run("idle")));
check(first === again, "同一品类两遍逐字一致(确定性)");
await a.close();
const b = await open();
const d2 = String(await b.evaluate(() => globalThis.__ashdeck.run("autobattler")));
await b.close();
check(d2 === BUILD.nodeDigests.autobattler, "独立第二次加载的 autobattler digest 仍一致");
} finally {
await browser.close();
}
console.log(fails.length === 0 ? "OK W 层加断言通过(6/6 品类)" : `FAIL ${fails.length} 条`);
process.exit(fails.length === 0 ? 0 : 1);
ok 页面暴露的六个品类名与注册表逐个相同
ok 浏览器 deckbuilder 的 digest == 构建期 Node 侧 digest
ok 浏览器 tower-defense 的 digest == 构建期 Node 侧 digest
ok 浏览器 match3 的 digest == 构建期 Node 侧 digest
ok 浏览器 platformer 的 digest == 构建期 Node 侧 digest
ok 浏览器 autobattler 的 digest == 构建期 Node 侧 digest
ok 浏览器 idle 的 digest == 构建期 Node 侧 digest
ok 同一品类两遍逐字一致(确定性)
ok 独立第二次加载的 autobattler digest 仍一致
OK W 层加断言通过(6/6 品类)
九条断言把①号判据从「Node 里成立」升级成「Node 与浏览器两个运行时里都成立」。这里踩的坑是冷启动:Playwright 新起的第一个 page 读 canvas 像素恒为 0,必须先开一个预热页画一个 8×8;而且 canvasDrawn 必须等到两次 requestAnimationFrame 之后再置真,否则通用验证器会等到「已调用绘制」而不是「已合成一帧」。另有一条经验:如果在同一条命令里紧跟一次重型构建再启动浏览器,首个 page 的像素读取仍可能偶发为 0——独立重跑三次全部是 280000 px,所以这条只当偶发记录,不当判据。
实跑清单
| 层 | 命令 | 结果 |
|---|---|---|
| P | node --test 'core/test/gd_proto_13_3_variant_gate.test.ts' | 8 测试全绿;6/6 通过;落 out/gd_proto_13_gate.json |
| S | /opt/homebrew/bin/python3 sim/gd_proto_13_3_gate_report.py | 报告自洽;记分卡 svg 11061 B / csv 6 行 |
| W | node out/gd_proto_13_wcheck.mjs | 9 断言全绿(6/6 品类跨运行时一致) |
小结
13.3 立了一套统一的闸门:五条判据、一条声明式扰动轴、一份机器可读的报告。六个变体 6/6 通过,但结论里有三处如实记录的保留——平台跳跃的种子轴惰性(seedAxis.distinctBuckets: 1)、塔防的种子轴只有 10 个桶、以及「胜率不能是 0‰ 或 1000‰」这条直觉判据在本章被证明是错的(六条里五条不过)。这三处都是「不调参硬凑」的结果:如果为了让表格好看去动那六章的胜利条件,后面就没有真东西可写了。
闸门报告 out/gd_proto_13_gate.json 会被第 14 章的品类距离矩阵与第 15 章读走——字段名是为它们定的:criteria 是判据名单,variants[].criteria 是逐条布尔值,variants[].spread.distinctBuckets 是那条唯一参与判定的计数,proxyFields 列出所有不得当结论用的代理指标。
阅读导航:上一节:13.2 六个品类变体的实例化 · 下一节:14.1 品类的特征向量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。