13.3 变体的可玩性闸门

给六个品类变体定一套统一的五条判据——确定性、不变量、终态可达、契约同构、扰动非退化——逐个判通过或不通过,落成机器可读的 out/gd_proto_13_gate.json 并在正文逐字贴出。平台跳跃的种子轴惰性(16 个种子只出 1 个结果桶)如实记录、不掩盖;胜率与主桶占比都显式声明为代理指标。

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」,而扰动轴由注册表声明、不由结果反推:

变体扰动轴轴点为什么是这条轴
卡牌构筑 / 塔防 / 三消 / 自走棋 / 放置seed16不确定来源是 rngNext 的种子流
平台跳跃timing81pfRun 不调用 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,所以这条只当偶发记录,不当判据。

实跑清单

层命令结果
Pnode --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 行
Wnode out/gd_proto_13_wcheck.mjs9 断言全绿(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 品类的特征向量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡