13.1 规格的品类参数化

把六份品类规格投影到同一份六段 schema(id / naming / state / rules / params / invariant):本体要一层 view 投影,五个品类变体本来就在这六段上。划出「不变量」——骨架一致、naming 的 8 个槽位、无浮点、invariant 非空——与「自由度」——规则条目 11~52、参数字段 2~8、内容指纹两两不同。

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这份规格是谁字符串,六份两两不同
naming8 个契约槽位在本品类里叫什么槽位集合逐个相同,只有名字变
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.py6 行投影表;指纹两两不同;svg 5564 B / csv 6 行
Pnode --test 'core/test/gd_proto_13_1_spec_schema.test.ts'6 测试全绿
Wnode web/build_proto13.mjs10 模块 / 44801 字符 → 65921 B 页面

小结

13.1 立了本章的骨架:六段 schema 是不变量,段里的内容是自由度。本体靠一层四种取法的 view 投影进来,五个品类变体本来就在这六段上;同构性有四条硬断言(骨架一致、槽位覆盖一致、无浮点、不变量非空),自由度有五个两两不同的量。下一节把这套 schema 真正实例化——六个变体各跑一遍,把结果并列成一张表。

阅读导航:上一节:12.3 手感的度量与断言 · 下一节:13.2 六个品类变体的实例化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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