13.2 六个品类变体的实例化
站内 《小游戏开发实战》3.1 品类分类体系 把八个品类摊成一张分类表,读的是产品特征——变现模式、单局时长、内容量级。本节读的是同一批品类的另一面:13.1 立了六段 schema,这一节把它真跑一遍——六份规格各喂给各自的确定性内核,把结果并列成一张表。上一节是「同一份 schema」,这一节要拿运行结果证明它。
六个变体各自跑一遍
| 变体 | 规格 id | 规则条目 | 状态字段 | 参数字段 | 不变量 | digest 里的 dealt / turn / won |
|---|---|---|---|---|---|---|
| 卡牌构筑(本体) | ashdeck | 27 | 2 | 8 | 4 | 50 / 4 / true |
| 塔防 | gd_proto_06_td | 26 | 3 | 8 | 5 | 772 / 6 / true |
| 三消 | gd_proto_07_m3 | 11 | 2 | 2 | 5 | 166 / 24 / false |
| 平台跳跃 | gd_proto_08_platformer | 15 | 2 | 2 | 4 | 30000 / 120 / true |
| 自走棋 | gd_proto_09_ab | 52 | 4 | 5 | 5 | 149 / 12 / true |
| 放置 | gd_proto_10_idle | 22 | 3 | 4 | 6 | 324619 / 800 / false |
最后一列只摘了 digest 里最能看出量级的三个字段(完整 digest 在下面 P 层的实跑输出里逐字给出)。四组计数一眼看去就参差不齐:规则条目最多 52(自走棋,因为它的单位与羁绊都算规则)最少 11(三消,只有匹配与掉落两条),参数字段最多 8(本体与塔防)最少 2(三消与平台跳跃)。这正是「同一份 schema,六组参数」该有的样子。
表要证明的两件事
结构同构:六个快照的键集必须逐个相同,且等于 13.1 声明的那 12 个契约槽位。注意这里的六个品类在真实游戏里毫无关系——塔防没有「手牌」、三消没有「回合」——但它们都必须把状态投影进同一套槽位:
| 契约槽位 | 在本体里 | 在塔防里 | 在三消里 |
|---|---|---|---|
turn | 回合数 | 波次进度 | 已用步数 |
energy | 剩余能量 | 击杀获得的金币 | 本局得分 |
block | 本回合护甲 | 基地剩余血量 | 连锁长度 |
hand | 手牌 | 场上的塔 | 当前棋盘片段 |
「投影」这个词是认真的:塔防的 energy 装的是金币,语义完全不同,但类型与位置相同。这就是 naming 段存在的理由——槽位是契约,名字是品类自己的话。
内容不同:四组计数与六个 digest 都必须两两不同。如果两个品类跑出同一个 digest,说明它们的规则段其实没区别,那这六份规格里就有一份是白写的。
S 层:结构的那一半
Python 侧只实现了本体内核,五个品类内核都写在 TypeScript 侧,所以 S 层只能负责结构那一半——规格 id、四组计数、内容指纹:
# snippet: 从注册表把六个变体并列成一张表(只读,写 out/gd_proto_13_variant_table.csv)
/opt/homebrew/bin/python3 sim/gd_proto_13_2_variant_table.py
genre specId rules state params inv naming hash
deckbuilder ashdeck 27 2 8 4 8 2090613211
tower-defense gd_proto_06_td 26 3 8 5 8 3936912495
match3 gd_proto_07_m3 11 2 2 5 8 3384481313
platformer gd_proto_08_platformer 15 2 2 4 8 3729999018
autobattler gd_proto_09_ab 52 4 5 5 8 1902561097
idle gd_proto_10_idle 22 3 4 6 8 3660024852
skeletonIsomorphic=True namingSlots=8
contractSlotsDeclared=block|cardCount|damageConserved|dealt|enemyHp|energy|hand|over|played|playerHp|turn|won (由 P 层 digest 键集核对)
structuralTuplesDistinct=True (6/6)
csv=6 rows
最后一行 structuralTuplesDistinct=True (6/6) 是把(规则条目 / 状态字段 / 参数字段 / 不变量 / 内容指纹)当成一个五元组去看——六个五元组两两不同。运行结果那一半(digest)S 层给不了,只能由 P 层给,所以下一节才是本节的正戏。
P 层:运行结果那一半
// file: core/test/gd_proto_13_2_variant_table.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_13_2_variant_table.test.ts'
//
// 卷三 13.2 六个品类变体的实例化(P 层):把 6 个变体**真跑一遍**——各自读自己那份规格、
// 各自跑自己的确定性内核——然后并列成一张表:规格 id / 规则段条数 / 状态段字段数 /
// 参数段字段数 / 不变量条数 / 确定性 digest。表要证明两件事:结构同构(六段、契约 12 槽位
// 逐个相同)、内容不同(计数两两不同、digest 两两不同)。
//
// 六个内核都是既有文件,本测试只读不改。全整数,无浮点、无 Math.random。
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { runSpec, invariantsHold } from "../src/ashdeck/combat.ts";
import type { Spec } from "../src/ashdeck/types.ts";
import { digestSnap } from "../src/ashdeck/digest.ts";
import { tdRun, tdInvariantsHold, tdDigest } from "../genre/towerdefense.ts";
import type { TdSpec } from "../genre/towerdefense.ts";
import { m3Run, m3InvariantsHold, m3Digest } from "../genre/match3.ts";
import type { M3Spec } from "../genre/match3.ts";
import { pfRun, pfInvariantsHold, pfDigest } from "../genre/platformer.ts";
import type { PfSpec } from "../genre/platformer.ts";
import { abRun, abInvariantsHold, abDigest } from "../genre/autobattler.ts";
import type { AbSpec } from "../genre/autobattler.ts";
import { idleRun, idleInvariantsHold, idleDigest } from "../genre/idle.ts";
import type { IdleSpec } from "../genre/idle.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const load = (rel: string): any => JSON.parse(readFileSync(join(ROOT, rel), "utf8"));
const REG: any = load("config/gd_proto_13_variants.json");
const SEGMENTS: string[] = REG.segments;
const NAMING_SLOTS: string[] = REG.naming_slots;
const CONTRACT: string[] = REG.contract_slots;
const clone = <T>(o: T): T => JSON.parse(JSON.stringify(o)) as T;
/** 与 13.1 同一口径的六段投影(本测试自包含,不跨文件 import 测试模块)。 */
function resolve(spec: any, view: any): Record<string, any> {
const out: Record<string, any> = {};
for (const seg of SEGMENTS) {
const r = view[seg];
if (r.kind === "path") out[seg] = spec[r.path];
else if (r.kind === "identity") out[seg] = Object.fromEntries(NAMING_SLOTS.map((s) => [s, s]));
else if (r.kind === "derived") out[seg] = Object.fromEntries(r.fields.map((f: string) => [f, spec[r.from][f]]));
else if (r.kind === "composite") out[seg] = Object.fromEntries(r.from.map((k: string) => [k, spec[k]]));
else throw new Error("unknown view kind: " + r.kind);
}
return out;
}
function leafCount(x: unknown): number {
if (Array.isArray(x)) return x.reduce((a: number, v) => a + leafCount(v), 0);
if (x !== null && typeof x === "object") {
return Object.values(x as Record<string, unknown>).reduce((a: number, v) => a + leafCount(v), 0);
}
return 1;
}
/** 每个变体一条「实例化」入口:读自己的规格、跑自己的内核、投影进共享契约。 */
const RUNNERS: Record<string, { spec: string; run: (sp: any) => any; inv: (sp: any, s: any) => boolean; dig: (s: any) => string }> = {
deckbuilder: { spec: "config/play_spec.json", run: (sp: Spec) => runSpec(sp), inv: invariantsHold, dig: digestSnap },
"tower-defense": { spec: "config/gd_proto_06_td.json", run: (sp: TdSpec) => tdRun(sp), inv: tdInvariantsHold, dig: tdDigest },
match3: { spec: "config/gd_proto_07_m3.json", run: (sp: M3Spec) => m3Run(sp), inv: m3InvariantsHold, dig: m3Digest },
platformer: { spec: "config/gd_proto_08_platformer.json", run: (sp: PfSpec) => pfRun(sp), inv: pfInvariantsHold, dig: pfDigest },
autobattler: { spec: "config/gd_proto_09_ab.json", run: (sp: AbSpec) => abRun(sp), inv: abInvariantsHold, dig: abDigest },
idle: { spec: "config/gd_proto_10_idle.json", run: (sp: IdleSpec) => idleRun(sp), inv: idleInvariantsHold, dig: idleDigest },
};
const ROWS = REG.variants.map((v: any) => {
const raw = load(v.spec);
const view = resolve(raw, v.view);
const r = RUNNERS[v.genre];
const snap = r.run(clone(raw));
// 本体把不变量写在 invariants(复数)里,品类变体写在 invariant(单数)里——投影只换名不增减。
const invSource = raw[v.view.invariant.path as string] as string[];
return {
genre: v.genre, label: v.label, specId: view.id, rulesItems: leafCount(view.rules),
stateFields: Object.keys(view.state).length, paramsFields: Object.keys(view.params).length,
invariantCount: view.invariant.length, specInvariants: invSource.length,
invOk: r.inv(clone(raw), snap), digest: r.dig(snap), snap,
};
});
test("结构同构:6 个变体的快照都投影进同一套 12 个契约槽位", () => {
assert.equal(CONTRACT.length, 12);
for (const row of ROWS) {
const keys = Object.keys(JSON.parse(row.digest)).sort();
assert.deepEqual(keys, CONTRACT.slice().sort(), row.genre + " 的契约槽位");
assert.equal(Object.keys(row.snap).length > 0, true);
}
});
test("内容不同:四组计数与 digest 都两两不同", () => {
assert.deepEqual(ROWS.map((r) => r.rulesItems), [27, 26, 11, 15, 52, 22]);
assert.deepEqual(ROWS.map((r) => r.stateFields), [2, 3, 2, 2, 4, 3]);
assert.deepEqual(ROWS.map((r) => r.paramsFields), [8, 8, 2, 2, 5, 4]);
assert.deepEqual(ROWS.map((r) => r.invariantCount), [4, 5, 5, 4, 5, 6]);
assert.equal(new Set(ROWS.map((r) => r.digest)).size, 6);
assert.equal(new Set(ROWS.map((r) => r.specId)).size, 6);
});
test("规格里声明的 invariant 条数 == 六段视图里的 invariant 条数(投影不增不减)", () => {
for (const row of ROWS) assert.equal(row.invariantCount, row.specInvariants, row.genre);
});
test("6 个变体各自的不变量在本机实跑下全部成立", () => {
for (const row of ROWS) assert.equal(row.invOk, true, row.genre + " 的不变量");
});
test("实例化表(本机实跑输出)", () => {
console.log(" genre specId rules state params inv digest");
for (const r of ROWS) {
console.log(` ${r.genre.padEnd(16)} ${r.specId.padEnd(22)} ${String(r.rulesItems).padStart(5)} ${String(r.stateFields).padStart(6)} ${String(r.paramsFields).padStart(7)} ${String(r.invariantCount).padStart(4)} ${r.digest}`);
}
assert.equal(ROWS.length, 6);
});
genre specId rules state params inv digest
deckbuilder ashdeck 27 2 8 4 {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
tower-defense gd_proto_06_td 26 3 8 5 {"block":4,"cardCount":4,"damageConserved":true,"dealt":772,"enemyHp":0,"energy":70,"hand":["0:cannon:1","1:arrow:2","2:arrow:1","3:cannon:1"],"over":true,"played":124,"playerHp":20,"turn":6,"won":true}
match3 gd_proto_07_m3 11 2 2 5 {"block":6,"cardCount":49,"damageConserved":true,"dealt":166,"enemyHp":1011,"energy":0,"hand":["1","1","2","1","1","3","1","1","1","2","1","1","1","2","2","1","1","1","4","2","1","6","1","3"],"over":true,"played":24,"playerHp":1989,"turn":24,"won":false}
platformer gd_proto_08_platformer 15 2 2 4 {"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}
autobattler gd_proto_09_ab 52 4 5 5 {"block":3,"cardCount":48,"damageConserved":true,"dealt":149,"enemyHp":147,"energy":47,"hand":["arcane_adept:2","ward_sentinel:2","blade_grunt:2","blade_champion:2","blade_champion:1","blade_champion:1"],"over":true,"played":19,"playerHp":28,"turn":12,"won":true}
idle gd_proto_10_idle 22 3 4 6 {"block":3,"cardCount":7,"damageConserved":true,"dealt":324619,"enemyHp":4,"energy":64807,"hand":["m45","m75","m115"],"over":true,"played":279,"playerHp":3,"turn":800,"won":false}
✔ 结构同构:6 个变体的快照都投影进同一套 12 个契约槽位 (0.957167ms)
✔ 内容不同:四组计数与 digest 都两两不同 (0.150125ms)
✔ 规格里声明的 invariant 条数 == 六段视图里的 invariant 条数(投影不增不减) (0.072459ms)
✔ 6 个变体各自的不变量在本机实跑下全部成立 (0.070708ms)
✔ 实例化表(本机实跑输出) (0.482833ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 144.206666
六行 digest 全在同一套槽位上,值却差得很远:本体的 dealt 是 50,平台跳跃是 30000(一局 120 帧的累计位移),放置是 324619(800 tick 的累计产出)。同一套槽位,六个数量级——这就是「投影」而不是「相等」的意思。
顺带记一个真实的坑:本体把不变量写在 invariants(复数)里,品类变体写在 invariant(单数)里。第一版测试直接读 raw.invariant.length,在本体上抛 TypeError: Cannot read properties of undefined。修法是不要绕过 13.1 的 view 声明——按 v.view.invariant.path 去取,投影层保证「只换名不增减」,于是这条断言本身也成了对投影的检验。
W 层:六个变体在同一个页面里
W 层把六个内核装进 out/proto13.html(构建细节见 13.1),页面是一个品类选择器:点一个品类就跑对应内核,把该变体的四组结构计数画进同一张图。构建期顺手在 Node 侧把六个变体各跑一遍,把 digest 冻进 out/gd_proto_13_build.json:
NODE_DIGEST[deckbuilder]={"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
NODE_DIGEST[tower-defense]={"block":4,"cardCount":4,"damageConserved":true,"dealt":772,"enemyHp":0,"energy":70,"hand":["0:cannon:1","1:arrow:2","2:arrow:1","3:cannon:1"],"over":true,"played":124,"playerHp":20,"turn":6,"won":true}
NODE_DIGEST[match3]={"block":6,"cardCount":49,"damageConserved":true,"dealt":166,"enemyHp":1011,"energy":0,"hand":["1","1","2","1","1","3","1","1","1","2","1","1","1","2","2","1","1","1","4","2","1","6","1","3"],"over":true,"played":24,"playerHp":1989,"turn":24,"won":false}
NODE_DIGEST[platformer]={"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}
NODE_DIGEST[autobattler]={"block":3,"cardCount":48,"damageConserved":true,"dealt":149,"enemyHp":147,"energy":47,"hand":["arcane_adept:2","ward_sentinel:2","blade_grunt:2","blade_champion:2","blade_champion:1","blade_champion:1"],"over":true,"played":19,"playerHp":28,"turn":12,"won":true}
NODE_DIGEST[idle]={"block":3,"cardCount":7,"damageConserved":true,"dealt":324619,"enemyHp":4,"energy":64807,"hand":["m45","m75","m115"],"over":true,"played":279,"playerHp":3,"turn":800,"won":false}
这六条与上表 P 层的 digest 逐字相同——同一份规格、同一个内核、两条不同的运行路径(Node 测试 / 浏览器页面)给出同一个结果。这就是「确定性」在工程上唯一有意义的检验方式。
实跑清单
| 层 | 命令 | 结果 |
|---|---|---|
| S | /opt/homebrew/bin/python3 sim/gd_proto_13_2_variant_table.py | 六行结构表;五元组 6/6 两两不同 |
| P | node --test 'core/test/gd_proto_13_2_variant_table.test.ts' | 5 测试全绿;六个 digest 两两不同 |
| W | node web/build_proto13.mjs | 六个 NODE_DIGEST 与 P 层逐字一致 |
小结
13.2 把 13.1 的 schema 兑现成了运行结果:六个变体各自跑一遍,六行 digest 全部落在同一套 12 个契约槽位上(结构同构),值却相差六个数量级(内容不同)。到这里「同一份规格」还是只被证明了一次——六份规格各跑一遍而已。下一节要问一个更狠的问题:这六个变体,各自真的「能玩」吗? 一套判据能不能同时判它们。
阅读导航:上一节:13.1 规格的品类参数化 · 下一节:13.3 变体的可玩性闸门 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。