13.1 规格的品类参数化
站内 《小游戏开发实战》3.1 品类分类体系 从产品视角切品类:按玩法核心、变现模式、单局时长、内容量级画一张互斥可判的格子表,回答「这款产品该落在哪个格子」。本节换一个视角——机制视角:不回答「这是什么品类」,而回答「同一份可执行规格,怎么在六个品类下各长一副样子,而骨架仍是同一份」。前者是选型,后者是参数化;两者共用同一批品类名词,但读的是规格的不同侧面。
六份规格,一份骨架
第 5~10 章各自落了一份 config/gd_proto_*.json。它们看起来完全不像:本体 config/play_spec.json 的顶层键是 id / seed / cards / goals / params / invariants / strategy,而五个品类变体的顶层键是 id / naming / state / rules / params / invariant。六份并排读,才认出同一份骨架的六段:
| 段 | 回答什么 | 跨品类的要求 |
|---|---|---|
id | 这份规格是谁 | 字符串,六份两两不同 |
naming | 8 个契约槽位在本品类里叫什么 | 槽位集合逐个相同,只有名字变 |
state | 一局里会变的东西 | 字段数自由,但必须投影进 12 个契约槽位 |
rules | 机制怎么算 | 条目数自由,是本体的规则段 + 目标段 |
params | 数值旋钮 | 字段数自由,全整数 |
invariant | 一局里绝不允许发生的事 | 非空,且条条可判真假 |
这张表就是本节要立的东西:六段是骨架,段里的内容是参数。所以「同一份规格」不是「同一个文件」,而是「同一个 schema」。
本体为什么需要一层 view
五个品类变体本来就是照这六段写的,本体不是——它是契约的诞生地,所以它没有 naming(槽位名就是它自己),不变量写成复数 invariants,规则散在 cards 与 goals 两处。硬把本体改写成六段是篡改既有文件,也会让第 5 章的证据失效。正确做法是给本体写一条投影声明,把「它自己的形状」映到六段上:
// snippet: config/gd_proto_13_variants.json 里本体的 view(五个品类变体的 view 全是 path)
"view": {
"id": { "kind": "path", "path": "id" },
"naming": { "kind": "identity" },
"state": { "kind": "derived", "from": "params", "fields": ["playerHp", "enemyHp"] },
"rules": { "kind": "composite", "from": ["cards", "goals"] },
"params": { "kind": "path", "path": "params" },
"invariant": { "kind": "path", "path": "invariants" }
}
四种取法足够覆盖全部六份:
| kind | 含义 | 用在哪 |
|---|---|---|
path | 直接取顶层某键 | 五个品类变体的全部六段 |
identity | 槽位名到自身 | 本体的 naming(它就是契约) |
derived | 从另一段里挑几个字段 | 本体的 state(状态就是双方血量) |
composite | 把几个顶层键并成一段 | 本体的 rules(规则 + 目标) |
identity 这一条值得强调:本体的 naming 不是空的,而是「槽位名等于自身」的恒等映射。它后来成了 P 层的一条断言——五个品类改名 8 个槽位,本体改名 0 个,但两边覆盖的槽位集合逐个相同。
不变量:跨品类必须同构的四件事
| # | 不变量 | 形式 |
|---|---|---|
| ① | 六段骨架一致 | 六份规格解析出的段名集合逐个等于 id/naming/state/rules/params/invariant |
| ② | naming 覆盖同样的槽位 | 六份的 naming 键集都等于注册表声明的 8 个槽位 |
| ③ | 规格里没有浮点 | 六份规格递归扫一遍,float 计数为 0 |
| ④ | invariant 非空 | 每份至少一条,且每条都是可判真假的字符串 |
这四条是同构性:缺任何一条,「同一份 schema」这句话就不成立。注意④的措辞——不是「不变量写得好」,而是「不变量存在且可判」。机制层的老规矩在这里同样适用:先保证能判,再谈判什么。
还有一条运行期的不变量放在第 13.3 节的闸门里:六个变体的快照都必须投影进同一套 12 个契约槽位(block / cardCount / damageConserved / dealt / enemyHp / energy / hand / over / played / playerHp / turn / won)。那是「结构同构」在运行结果上的兑现。
自由度:允许不同构的五件事
| 量 | 六个变体的实测值 | 说明 |
|---|---|---|
| 规则段条目数 | 27 / 26 / 11 / 15 / 52 / 22 | 递归数标量叶子,不是行数 |
| 状态段字段数 | 2 / 3 / 2 / 2 / 4 / 3 | 品类之间差 2 倍 |
| 参数段字段数 | 8 / 8 / 2 / 2 / 5 / 4 | 塔防的波次旋钮多,三消只有两个 |
| 不变量条数 | 4 / 5 / 5 / 4 / 5 / 6 | 放置最多,因为它有离线与转生两条 |
| 内容指纹 | 六个两两不同 | FNV-1a 32 位,规范化 JSON 上算 |
「自由度」这五个量全都不参与同构判定,反而要两两不同——如果六份规格连内容都一样,那参数化就没发生。
S 层:把六份规格投影到同一份 schema
# snippet: sim/gd_proto_13_1_schema.py 的两个核心函数(完整文件 175 行)
def canonical(obj) -> str:
"""规范化 JSON:键递归排序、无空格、中文不转义——与 TS 侧同口径。"""
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def fnv1a32(s: str) -> int:
"""FNV-1a 32 位整数哈希:全整数运算,无浮点,Python 与 JS 逐位一致。"""
h = 2166136261
for b in s.encode("utf-8"):
h = ((h ^ b) * 16777619) & 0xFFFFFFFF
return h
# snippet: 跑 13.1 的 S 层投影(只读注册表与六份规格,写 out/**)
/opt/homebrew/bin/python3 sim/gd_proto_13_1_schema.py
skeleton=id|naming|state|rules|params|invariant uniform=True
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
namingSlotsUniform=True noFloatAnywhere=True hashesDistinct=True
svg=5564 B csv=6 rows
指纹这一列是本节的「内容不同」证据:六个数两两不同,改一个参数字符串就会变。它不是哈希密码学意义上的指纹,只是一个内容比对用的整数——换任何一份规格重算都对不上,这已经够用。
P 层:把「不变量」与「自由度」各写成断言
// file: core/test/gd_proto_13_1_spec_schema.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_13_1_spec_schema.test.ts'
//
// 卷三 13.1 规格的品类参数化(P 层):把 6 个变体投影到同一份六段 schema,断言「不变量」的
// 那一半——六段名字集合一致、naming 段覆盖同样的 8 个契约槽位、规格里一个浮点都没有、
// invariant 段非空——并断言「自由度」的那一半:内容指纹两两不同。
//
// 指纹口径与 S 层 sim/gd_proto_13_1_schema.py 逐位一致:FNV-1a 32 位整数,输入是规范化
// JSON(键递归排序、无空格、中文不转义)。本测试把 S 层实跑出的 6 个指纹**冻结成字面量**,
// 于是「同一份 schema」这件事有了跨语言证据。全整数,无浮点、无 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";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const load = (rel: string): any => JSON.parse(readFileSync(join(ROOT, rel), "utf8"));
type ViewRule = { kind: string; path?: string; from?: string | string[]; fields?: string[] };
type Variant = { genre: string; label: string; spec: string; axis: string; view: Record<string, ViewRule> };
const REG: any = load("config/gd_proto_13_variants.json");
const SEGMENTS: string[] = REG.segments;
const NAMING_SLOTS: string[] = REG.naming_slots;
const VARIANTS: Variant[] = REG.variants;
/** 规范化 JSON:键递归排序、无空格——与 Python 的 json.dumps(sort_keys=True) 同输出。 */
function canonical(v: unknown): string {
if (Array.isArray(v)) return "[" + v.map(canonical).join(",") + "]";
if (v !== null && typeof v === "object") {
const o = v as Record<string, unknown>;
return "{" + Object.keys(o).sort().map((k) => JSON.stringify(k) + ":" + canonical(o[k])).join(",") + "}";
}
return JSON.stringify(v);
}
/** FNV-1a 32 位整数哈希;用 Math.imul 取 32 位乘法的低 32 位,避免超出 2^53 的精度损失。 */
function fnv1a32(s: string): number {
const bytes = new TextEncoder().encode(s);
let h = 2166136261;
for (const b of bytes) h = Math.imul(h ^ b, 16777619) >>> 0;
return h;
}
/** 按 schema 声明把一份规格投影成六段视图:path / identity / derived / composite 四种取法。 */
function resolve(spec: any, view: Record<string, ViewRule>): Record<string, any> {
const out: Record<string, any> = {};
for (const seg of SEGMENTS) {
const rule = view[seg];
if (rule.kind === "path") out[seg] = spec[rule.path as string];
else if (rule.kind === "identity") out[seg] = Object.fromEntries(NAMING_SLOTS.map((s) => [s, s]));
else if (rule.kind === "derived") {
const src = spec[rule.from as string];
out[seg] = Object.fromEntries((rule.fields as string[]).map((f) => [f, src[f]]));
} else if (rule.kind === "composite") {
out[seg] = Object.fromEntries((rule.from as string[]).map((k) => [k, spec[k]]));
} else throw new Error("unknown view kind: " + rule.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;
}
function hasFloat(x: unknown): boolean {
if (Array.isArray(x)) return x.some(hasFloat);
if (x !== null && typeof x === "object") return Object.values(x as Record<string, unknown>).some(hasFloat);
return typeof x === "number" && !Number.isInteger(x);
}
const VIEWS = VARIANTS.map((v) => ({ v, spec: load(v.spec), view: resolve(load(v.spec), v.view) }));
/** S 层 sim/gd_proto_13_1_schema.py 实跑出的内容指纹,冻结在此。 */
const FROZEN_HASH: Record<string, number> = {
deckbuilder: 2090613211,
"tower-defense": 3936912495,
match3: 3384481313,
platformer: 3729999018,
autobattler: 1902561097,
idle: 3660024852,
};
test("六段骨架跨品类一致:6 个变体解析出的段名集合逐个相同", () => {
assert.deepEqual(SEGMENTS, ["id", "naming", "state", "rules", "params", "invariant"]);
for (const { v, view } of VIEWS) {
assert.deepEqual(Object.keys(view), SEGMENTS, v.genre + " 的段名集合");
}
});
test("naming 段覆盖同样的 8 个契约槽位;五个品类映射到品类词、本体是恒等", () => {
assert.equal(NAMING_SLOTS.length, 8);
assert.equal(REG.contract_slots.length, 12);
for (const { v, view } of VIEWS) {
assert.deepEqual(Object.keys(view.naming).sort(), NAMING_SLOTS.slice().sort(), v.genre + " 的 naming 槽位");
const renamed = Object.entries(view.naming).filter(([k, x]) => k !== x).length;
assert.equal(renamed, v.genre === "deckbuilder" ? 0 : 8, v.genre + " 的改名槽位数");
}
});
test("不变量:六个变体的规格里没有任何浮点数,且 invariant 段非空", () => {
for (const { v, spec, view } of VIEWS) {
assert.equal(hasFloat(spec), false, v.genre + " 的规格含浮点");
assert.ok(view.invariant.length > 0, v.genre + " 的 invariant 段为空");
for (const name of view.invariant) assert.equal(typeof name, "string");
}
});
test("自由度:规则条目数 / 状态字段数 / 参数字段数 各变体不同构", () => {
const rules = VIEWS.map(({ view }) => leafCount(view.rules));
const states = VIEWS.map(({ view }) => Object.keys(view.state).length);
const params = VIEWS.map(({ view }) => Object.keys(view.params).length);
assert.deepEqual(rules, [27, 26, 11, 15, 52, 22]);
assert.deepEqual(states, [2, 3, 2, 2, 4, 3]);
assert.deepEqual(params, [8, 8, 2, 2, 5, 4]);
assert.ok(new Set(rules).size > 1 && new Set(states).size > 1 && new Set(params).size > 1);
});
test("内容指纹:TS 侧重算 FNV-1a 与 S 层实跑值逐位一致,且 6 个两两不同", () => {
const seen = new Set<number>();
for (const { v, view } of VIEWS) {
const h = fnv1a32(canonical(view));
assert.equal(h, FROZEN_HASH[v.genre], v.genre + " 的内容指纹");
seen.add(h);
}
assert.equal(seen.size, 6);
});
test("口径标定点:FNV-1a 的偏移基与质数在规范化 JSON 上给出可复算的整数", () => {
assert.equal(fnv1a32(""), 2166136261);
assert.equal(fnv1a32("a"), 3826002220);
assert.equal(fnv1a32("foobar"), 3214735720);
assert.equal(canonical({ b: 1, a: [2, { d: 3, c: 4 }] }), '{"a":[2,{"c":4,"d":3}],"b":1}');
});
✔ 六段骨架跨品类一致:6 个变体解析出的段名集合逐个相同 (1.417792ms)
✔ naming 段覆盖同样的 8 个契约槽位;五个品类映射到品类词、本体是恒等 (0.198041ms)
✔ 不变量:六个变体的规格里没有任何浮点数,且 invariant 段非空 (0.176291ms)
✔ 自由度:规则条目数 / 状态字段数 / 参数字段数 各变体不同构 (0.167166ms)
✔ 内容指纹:TS 侧重算 FNV-1a 与 S 层实跑值逐位一致,且 6 个两两不同 (0.481291ms)
✔ 口径标定点:FNV-1a 的偏移基与质数在规范化 JSON 上给出可复算的整数 (0.07375ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 256.729666
最后一条测试是口径标定点:它不检查任何变体,只检查 fnv1a32 这个函数本身在已知输入上给出已知输出。跨语言指纹最怕的是「两边都算错了,但错得一样」——标定点把这种风险挡在门外。
W 层:同一份 schema 装进一个页面
W 层要把六个内核(本体 combat.ts 加五个 core/genre/*.ts)与六份规格装进同一个 HTML。模块清单必须是手工拓扑序:
// snippet: web/build_proto13.mjs 的模块清单(先声明后使用;去掉跨文件 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",
"core/genre/towerdefense.ts",
"core/genre/match3.ts",
"core/genre/platformer.ts",
"core/genre/autobattler.ts",
"core/genre/idle.ts",
];
# snippet: 构建单文件页面并顺手在 Node 侧跑六个变体
NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto13.mjs
modules=10 kernelChars=44801
built /Users/bingrong.yan/.gdbook/ashdeck/out/proto13.html 65921 B
十个模块擦完类型共 44801 个字符,页面 65921 字节——六个内核同时装进一个页面是可行的。这里踩了一个坑:towerdefense / match3 / autobattler / idle 四个内核各自带一个同名 idiv,实现逐字相同,但 stripTypeScriptTypes 的输出跑在**模块作用域(严格模式)**里,重复的顶层函数声明是语法错误,页面会直接白屏。解法不是改内核(那四个是既有文件,不许动),而是在构建脚本里加一个折叠器:逐字相同的同名函数折叠成一份,同名但内容不同则直接抛错。13.2 会用到这个页面。
实跑清单
| 层 | 命令 | 结果 |
|---|---|---|
| S | /opt/homebrew/bin/python3 sim/gd_proto_13_1_schema.py | 6 行投影表;指纹两两不同;svg 5564 B / csv 6 行 |
| P | node --test 'core/test/gd_proto_13_1_spec_schema.test.ts' | 6 测试全绿 |
| W | node web/build_proto13.mjs | 10 模块 / 44801 字符 → 65921 B 页面 |
小结
13.1 立了本章的骨架:六段 schema 是不变量,段里的内容是自由度。本体靠一层四种取法的 view 投影进来,五个品类变体本来就在这六段上;同构性有四条硬断言(骨架一致、槽位覆盖一致、无浮点、不变量非空),自由度有五个两两不同的量。下一节把这套 schema 真正实例化——六个变体各跑一遍,把结果并列成一张表。
阅读导航:上一节:12.3 手感的度量与断言 · 下一节:13.2 六个品类变体的实例化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。