一、这份索引解决什么
卷三 48 节各自把同一份 play_spec.json 推到六个品类、6/6 过可玩性闸门,但产物散在 16 章里。这份附录把三样东西汇成表:六品类的规则段与参数段关键字段、六品类的机制距离矩阵、以及全卷按章的数据集索引。《小游戏设计》
那套是单款产品口径——一个品类的立项、原型、上线、运营;本套是机制口径——同一份规格换品类还成不成立。这份附录就是全套工具链的索引。
距离这块要和 《风格与品类美术实战》 做口径对照,不重造:那套的风格距离矩阵量的是一张画面在色相、明度、饱和度、笔触上的距离,本套的机制距离矩阵量的是规则在条数、状态字段、策略空间、相变宽度上的距离。两者写法一致——整数 L1 + 归一化千分比——但特征集完全不同,各量各的。
一条贯穿全卷的口径写在这里:这些量全部是代理指标(proxy)。它们只用于相对比较——「塔防离自走棋比离放置近」可说,「所以塔防更好玩」不可说。公式与参数都以整数为准:// 是整数地板除,比值一律千分比(‰);违反「预算与阈值一律整数、随机一律自实现可播种 PRNG、config/ 禁浮点」三条纪律里的任何一条,跨品类对照必然崩。
二、品类参数表(六品类,全整数)
六个品类不是六个游戏,是同一份 play_spec.json 在六组规则参数下的实例。它们共用同一套六段骨架 id|naming|state|rules|params|invariant,其中 id / naming / state / invariant 是跨品类同构的契约槽位,rules / params 是品类自由度——内容与规模逐变体不同。
| 段 | 六变体是否同构 | 证据(取自 out/gd_proto_13_schema.json) |
|---|---|---|
id / state | 同构(契约) | id 非空工程名;state 字段数 2–4 |
naming | 同构(契约) | 8 个命名槽位一致(turn/energy/playerHp/enemyHp/block/played/hand/cardCount) |
invariant | 同构(契约) | 每变体不变量清单非空(4–6 条) |
rules | 自由度 | 整数叶子数 9–41,内容各不同 |
params | 自由度 | 整数叶子数 2–8 |
各品类 rules / params 的关键字段(全部整数,逐条可从 config/ 读回):
| 品类 | spec | rules 关键字段 | params 关键字段 |
|---|---|---|---|
| deckbuilder(本体) | play_spec.json | cards 4 张(strike/heavy/jab/guard)· deckSize 12 · goals 3 条 | playerHp 60 · enemyHp 50 · enemyAttack 6 · energyMax 3 · handSize 5 · turnLimit 20 · rewardGold 12 · punishHp 4 |
| tower-defense | gd_proto_06_td.json | pathLength 12 · slots 4 · towerTypes 2 · upgradeMaxLevel 3 · jitterPermille 250 · rewardPerKill 2 · rewardPerWave 5 | waveCount 6 · waveHpBase 8 · waveHpStep 4 · enemiesBase 4 · enemiesStep 1 · moveEvery 2 · spawnGap 3 · leakDamage 2 |
| match3 | gd_proto_07_m3.json | rows 7 · cols 7 · types 5 · minRun 3 · cascadeCap 16 · scorePerTile 10 · cascadeBonusPermille 250 · pity 1/4 | moveCount 24 · targetScore 3000 |
| platformer | gd_proto_08_platformer.json | platforms 3 · goalX 30000 · runSpeed 250 · jumpVel 500 · gravity 25 · coyoteFrames 6 · bufferFrames 6 | frameLimit 160 · fallLimit 20000 |
| autobattler | gd_proto_09_ab.json | units 4 · shopSize 5 · poolPerUnit 12 · refreshCost 2 · mergeCount 3 · interestStep 10 · interestCap 5 · maxLevel 4 · buysPerRound 2 | rounds 12 · boardBase 3 · boardPerLevel 1 · enemyBase 26 · enemyStep 11 |
| idle | gd_proto_10_idle.json | producerBaseCost 10 · costGrowthPermille 1040 · baseRate 1500 · milestones 7 · prestige 50000/200 · jitterPermille 200 · autoBuyEvery 2 | ticks 800 · offlineTicks 200 · offlineCapTicks 120 · offlinePermille 500 |
下面这段 S 层聚合内核把上面两张表重新算一遍(递归展平 rules / params 数整数叶子、读回 schema 的同构标记),并顺带把下一节的距离矩阵与第四节的索引一起打印——它只读、只打印,不写任何新产物:
#!/usr/bin/env python3
# file: sim/gd_proto_appendix_a.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_appendix_a.py
"""卷三 附录 A 聚合内核:品类参数表 / 机制距离矩阵 / 数据集索引。只读、只打印,不写新产物。
距离、支配缺失度等都是**代理指标(proxy)**,只做相对比较,禁绝对化。无浮点、无 random。
"""
from __future__ import annotations
import json, os, re
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
NAMES = {"play_spec.json": "deckbuilder", "gd_proto_06_td.json": "tower-defense",
"gd_proto_07_m3.json": "match3", "gd_proto_08_platformer.json": "platformer",
"gd_proto_09_ab.json": "autobattler", "gd_proto_10_idle.json": "idle"}
SCAN = [("config/", r"^gd_proto_.*\.json$"), ("sim/", r"^gd_proto_.*\.py$"),
("core/test/", r"^gd_proto_.*\.test\.ts$"), ("core/genre/", r"^.*\.ts$"),
("web/", r"^build_proto.*\.mjs$"), ("out/", r"^gd_proto_.*\.(json|csv|svg)$"),
("out/", r"^proto.*\.(html|json)$"), ("out/shots/", r"^proto.*\.png$")]
CHAP = re.compile(r"(?:gd_proto_|proto|gd-proto-)(\d\d)")
def leaves(o, acc):
if isinstance(o, (dict, list)):
for v in (o.values() if isinstance(o, dict) else o): leaves(v, acc)
else: acc.append(o)
return acc
def main() -> int:
parts = []
for fn, key in NAMES.items():
cfg = json.load(open(os.path.join(ROOT, "config", fn), encoding="utf-8"))
c = lambda s: len([v for v in leaves(cfg.get(s, {}), []) if type(v) is int])
segs = [s for s in ("rules", "params") if s in cfg] or ["cards", "goals", "params"]
parts.append(key + "[" + " ".join(f"{s}={c(s)}" for s in segs) + "]")
print("=== 一、品类参数表(rules / params 的整数叶子数)===")
print(" " + " ".join(parts))
sc = json.load(open(os.path.join(OUT, "gd_proto_13_schema.json"), encoding="utf-8"))
print(f" 同构契约 skeleton={sc['skeleton']} namingSlots={sc['namingSlotCount']} invariant非空={sc['invariantAllNonEmpty']}")
mat = json.load(open(os.path.join(OUT, "gd_proto_14_matrix.json"), encoding="utf-8"))
print("\n=== 二、机制距离矩阵(L1,代理指标 proxy,只做相对比较)===")
print(" " + "".ljust(13) + "".join(g.rjust(14) for g in mat["genres"]))
for g, row in zip(mat["genres"], mat["l1"]):
print(" " + g.ljust(13) + "".join(str(x).rjust(14) for x in row))
ck, cl, fa = mat["checks"], mat["closest"][0], mat["farthest"][-1]
print(f" 对称={ck['symmetric']} 三角={ck['triangle']} minNonZero={ck['minNonZero']} 两两不同={ck['distinct']} maxL1={mat['maxL1']}")
print(f" 最近对 {cl['pair'][0]}↔{cl['pair'][1]} {cl['l1']}/{cl['permille']}‰;最远对 {fa['pair'][0]}↔{fa['pair'][1]} {fa['l1']}/{fa['permille']}‰")
by = {}
for sub, pat in SCAN:
for n in sorted(os.listdir(os.path.join(ROOT, sub))):
if re.match(pat, n):
m = CHAP.search(n)
by.setdefault(m.group(1) if m else "内核", []).append(n)
print("\n=== 三、数据集索引(实扫工作区,只读)===")
print(" 各章条目数: " + " ".join(f"ch{c}={len(by[c])}" for c in sorted(by) if c != "内核") + f" 内核/附录={len(by.get('内核', []))}")
print(f" 扫描条目总数 = {sum(len(v) for v in by.values())}")
print("(全部代理指标口径,只做相对比较,禁绝对化;本脚本只打印,不写新产物)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_proto_appendix_a.py:
=== 一、品类参数表(rules / params 的整数叶子数)===
deckbuilder[params=8] tower-defense[rules=23 params=8] match3[rules=9 params=2] platformer[rules=15 params=2] autobattler[rules=41 params=5] idle[rules=22 params=4]
同构契约 skeleton=id|naming|state|rules|params|invariant namingSlots=8 invariant非空=True
=== 二、机制距离矩阵(L1,代理指标 proxy,只做相对比较)===
deckbuilder towerdefense match3 platformer autobattler idle
deckbuilder 0 2357 3080 4958 3808 5508
towerdefense 2357 0 1811 3501 1743 4811
match3 3080 1811 0 3576 2996 5386
platformer 4958 3501 3576 0 5186 4520
autobattler 3808 1743 2996 5186 0 6496
idle 5508 4811 5386 4520 6496 0
对称=True 三角=True minNonZero=1743 两两不同=True maxL1=6496
最近对 塔防↔自走棋 1743/268‰;最远对 自走棋↔放置 6496/1000‰
=== 三、数据集索引(实扫工作区,只读)===
各章条目数: ch01=11 ch02=16 ch03=10 ch04=12 ch05=7 ch06=10 ch07=13 ch08=9 ch09=10 ch10=10 ch11=13 ch12=7 ch13=19 ch14=11 ch15=24 ch16=18 内核/附录=9
扫描条目总数 = 209
(全部代理指标口径,只做相对比较,禁绝对化;本脚本只打印,不写新产物)
读数三行。deckbuilder 的规格是 play_spec.json,只有 params 段(无 rules 段,故记 params=8),其余五变体各有 rules/params 两段;同构契约 一行是六变体共用的六段骨架。第二节的 L1 矩阵对角为 0、两两对称。第三节的 ch05=7 与 ch12=7 最少——这两个品类章没有独立 config/ 与 S 层脚本,产物集中在 P/W 层。
三、机制距离矩阵(代理指标 proxy)
6×10 整数特征向量
十个维度(取自 out/gd_proto_14_features.json)全为整数;④⑤⑨⑩ 四维由 P 层真跑内核得出,其余六维从规格文本数出:
| 品类 | rules | stateFields | strategyBits | entropyMilli | paretoPermille | stateBits | decisionDepth | randomBits | phaseWidth | avgStepsMilli |
|---|---|---|---|---|---|---|---|---|---|---|
| 卡牌构筑 | 39 | 12 | 51 | 431 | 800 | 21383 | 20 | 1 | 8 | 4971 |
| 塔防 | 39 | 19 | 22 | 1837 | 600 | 20118 | 6 | 1 | 4 | 5200 |
| 三消 | 18 | 19 | 158 | 1492 | 500 | 21282 | 24 | 2 | 6 | 23968 |
| 平台跳跃 | 21 | 18 | 413 | 1400 | 600 | 46458 | 160 | 0 | 0 | 100400 |
| 自走棋 | 62 | 22 | 33 | 2168 | 200 | 17975 | 12 | 1 | 5 | 11993 |
| 放置 | 32 | 19 | 800 | 1000 | 1000 | 24975 | 800 | 1 | 0 | 800000 |
6×6 距离矩阵(L1 与归一化千分比两级)
距离用整数 L1(归一化向量各维差的绝对值之和)与归一化千分比(1000·L1 // maxL1)两级表达(取自 out/gd_proto_14_matrix.json,单元格写作 L1/‰):
| L1 / ‰ | 卡牌构筑 | 塔防 | 三消 | 平台跳跃 | 自走棋 | 放置 |
|---|---|---|---|---|---|---|
| 卡牌构筑 | — | 2357/362 | 3080/474 | 4958/763 | 3808/586 | 5508/847 |
| 塔防 | 2357/362 | — | 1811/278 | 3501/538 | 1743/268 | 4811/740 |
| 三消 | 3080/474 | 1811/278 | — | 3576/550 | 2996/461 | 5386/829 |
| 平台跳跃 | 4958/763 | 3501/538 | 3576/550 | — | 5186/798 | 4520/695 |
| 自走棋 | 3808/586 | 1743/268 | 2996/461 | 5186/798 | — | 6496/1000 |
| 放置 | 5508/847 | 4811/740 | 5386/829 | 4520/695 | 6496/1000 | — |
三条验证与口径对照
三条验证全部成立:对称(L1[i][j] == L1[j][i],对角为 0,symmetric=True)、三角不等式(对所有 i,j,k:L1[i][j] ≤ L1[i][k] + L1[k][j],triangle=True)、可区分(15 个非零对两两不同,最小非零 L1=1743 即塔防↔自走棋 268‰,distinct=True)。
口径对照:《风格与品类美术实战》的风格距离矩阵与本套的机制距离矩阵是同一套写法、两套特征——都用整数 L1 加归一化千分比,但一套量视觉、一套量机制,不重造、不合并。本套最远的一对是自走棋↔放置(6496/1000‰),最近的一对是塔防↔自走棋(1743/268‰)。最近的一对仍有 268‰ 的距离,说明「同源但可区分」不是靠肉眼,而是靠这张矩阵坐实。
下面这段 P 层测试从 6×10 特征向量重算归一化向量、L1 与千分比,再与落盘矩阵逐位比对,并验证上面三条:
// file: core/test/gd_proto_appendix_a.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_appendix_a.test.ts'
// 附录 A 自检:从 6×10 特征向量重算归一化 / L1 / 千分比,与落盘矩阵逐位比对,验证对称 / 三角 / 可区分。只读 out/ 下的 JSON,全整数、无浮点、无 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 ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const load = (p: string) => JSON.parse(readFileSync(join(ROOT, p), "utf8"));
const feat = load("out/gd_proto_14_features.json");
const mat = load("out/gd_proto_14_matrix.json");
const l1 = (a: number[], b: number[]) => a.reduce((s, x, i) => s + Math.abs(x - b[i]), 0);
test("重算归一化 / L1 / 千分比,与落盘矩阵逐位一致", () => {
const norm = feat.vectors.map((v: number[]) => feat.dims.map((_: string, j: number) => {
const col = feat.vectors.map((r: number[]) => r[j]);
const lo = Math.min(...col), hi = Math.max(...col);
return hi === lo ? 0 : ((1000 * (v[j] - lo)) / (hi - lo)) | 0;
}));
assert.deepEqual(norm, mat.normalized);
assert.deepEqual(norm.map((a: number[]) => norm.map((b: number[]) => l1(a, b))), mat.l1);
const maxL1 = Math.max(...mat.l1.flat());
assert.equal(maxL1, mat.maxL1);
assert.deepEqual(mat.l1.map((r: number[]) => r.map((x: number) => ((1000 * x) / maxL1) | 0)),
mat.distPermille);
});
test("三条验证:对称 / 三角不等式 / 可区分", () => {
const n = mat.l1.length;
for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) {
assert.equal(mat.l1[i][j], mat.l1[j][i]);
for (let k = 0; k < n; k++) assert.ok(mat.l1[i][j] <= mat.l1[i][k] + mat.l1[k][j]);
}
const nz = mat.l1.flat().filter((x: number) => x > 0);
assert.equal(Math.min(...nz), 1743);
assert.equal(new Set(nz).size, 15);
console.log(` 对称=${mat.checks.symmetric} 三角=${mat.checks.triangle} minNonZero=${Math.min(...nz)}`
+ ` 两两不同=${mat.checks.distinct} 最近对=${mat.closest[0].pair.join("↔")}`
+ `(${mat.closest[0].l1}/${mat.closest[0].permille}‰)`);
});
本机实跑 node --test 'core/test/gd_proto_appendix_a.test.ts':
对称=true 三角=true minNonZero=1743 两两不同=true 最近对=塔防↔自走棋(1743/268‰)
✔ 重算归一化 / L1 / 千分比,与落盘矩阵逐位一致 (1.1465ms)
✔ 三条验证:对称 / 三角不等式 / 可区分 (0.580625ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
四、数据集索引(按章,S/P/W/D/B 分档)
下表由脚本实扫工作区(os.listdir,按 gd_proto_ / proto 前缀归章)得出,逐章给出关键产物与一句话说明。档位:S=可跑仿真、P=确定性内核与断言、W=浏览器真玩、D=规格/清单、B=软告警不硬失败。X 档(真实玩家测试数据、真实测试服、真实榜单)需真实业务环境,命令行不可复现——本索引只收机制侧可复现产物,X 档项不落在任何路径上。
| 章 | 档位 | 关键产物(路径) | 一句话 |
|---|---|---|---|
| 1 纸面原型 | D+S | config/gd_proto_01_*.json · sim/gd_proto_01_*.py · out/gd_proto_01_replay.json/.svg | 纸面规则表→规格,回放校验守恒 |
| 2 数字原型 | P+W | sim/gd_proto_02_*.py · core/test/gd_proto_02_*.test.ts · out/proto02.html | 最小内核 + 迭代循环 + 止损 |
| 3 可玩原型 | W | sim/gd_proto_03_*.py · core/test/gd_proto_03_play_assert.test.ts · out/proto03.html | 规格内联单文件 HTML,浏览器真玩 |
| 4 可玩性测试 | S+B | sim/gd_proto_04_*.py · out/proto04_{hypothesis,sampling,decision}.json | 假设 / 采样 / 决策三段 |
| 5 卡牌构筑 | W+P | core/test/gd_proto_05_*.test.ts · out/gd_proto_05_anatomy.json · out/proto05.html | 贯穿案例本体的解剖 / 方差 / 相变 |
| 6 塔防 | W+P | config/gd_proto_06_td.json · core/genre/towerdefense.ts · out/proto06.html | 塔防内核 + 索敌状态空间 |
| 7 三消 | W+P | config/gd_proto_07_m3.json · core/genre/match3.ts · out/gd_proto_07_rhythm.json | 三消内核 + 保底 + 节奏谱 |
| 8 平台跳跃 | W+P | config/gd_proto_08_platformer.json · core/genre/platformer.ts · out/proto08.html | 跳跃内核 + 技巧曲线 + 心流带宽 |
| 9 自走棋 | W+P | config/gd_proto_09_ab.json · core/genre/autobattler.ts · out/proto09.html | 自走棋经济 + 羁绊 + 涌现 |
| 10 放置 | W+P | config/gd_proto_10_idle.json · core/genre/idle.ts · out/proto10.html | 放置曲线 + 相变 + 动因 |
| 11 战斗作为机制 | S+P | config/gd_proto_11_combat.json · sim/gd_proto_11_*.py · out/gd_proto_11_space.json/.svg | 伤害 / 时序 / 资源的机制解剖 |
| 12 手感作为机制 | P+W | core/genre/gamefeel.ts · core/test/gd_proto_12_*.test.ts · out/proto12.html | 可断言的输入-反馈机制 |
| 13 跨品类:同一份规格 | S+P+W | config/gd_proto_13_variants.json · sim/gd_proto_13_*.py · out/gd_proto_13_{schema,gate}.json | 六变体注册表 + 参数化 + 闸门 |
| 14 品类间距离矩阵 | S | sim/gd_proto_14_*.py · out/gd_proto_14_features.json/.csv · out/gd_proto_14_matrix.json | 6×10 特征向量 + 6×6 距离 + 热力图 |
| 15 品类变体的验收 | S+P+W+B | sim/gd_proto_15_*.py · out/gd_proto_15_gate_matrix.json · out/gd_proto_15_robust_summary.json | 30/30 格 + 稳健性 + 失败诚实报告 |
| 16 装配:三卷总收官 | S+P+W+B | sim/gd_proto_16_*.py · out/gd_proto_16_replay.json · out/gd_proto_16_checklist.json | 全链路回放 + 19 项装配清单 |
| 内核与附录 | P/S | core/genre/*.ts(7 个品类内核) · sim/gd_proto_appendix_a.py · core/test/gd_proto_appendix_a.test.ts | 六品类最小确定性内核 + 本附录聚合 |
脚本实扫口径(os.listdir,8 组前缀匹配):config/ 下 28 个 gd_proto_*.json(另有唯一真源 play_spec.json)、sim/ 下 32 个 gd_proto_*.py、core/test/ 下 39 个 gd_proto_*.test.ts、core/genre/ 下 7 个品类内核、web/ 下 12 个 build_proto*.mjs、out/ 下 54 个 gd_proto_* 产物与 24 个 proto* 页面/JSON、out/shots/ 下 13 张 proto*.png,合计 209 项。
五、复现命令(三层)
S 层(Python,全路径解释器;逐个跑,不要一次传多个文件):
# snippet: shell 的 for 循环逐个执行,避免只跑第一个文件
cd "$HOME/.gdbook/ashdeck"
for f in sim/gd_proto_*.py; do /opt/homebrew/bin/python3 "$f" || exit 1; done
P 层(TypeScript,必须写全 glob;node --test <目录> 不发现 .ts):
# snippet: node --test 接收 glob 而非目录,必须加引号让 node 自己展开
cd "$HOME/.gdbook/ashdeck"
node --test 'core/test/gd_proto_*.test.ts'
W 层(Playwright 无头真玩,先 build 再 verify):
# snippet: 复用全局 Playwright 必须经 createRequire;verify 产出截图并断言 canvas 已绘制
cd "$HOME/.gdbook/ashdeck"
node web/build_proto16.mjs
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs --page out/proto16.html --shot out/shots/proto16.png
排错顺序固定为规格 → S 层 → P 层 → W 层 → 构建:先确认 config/play_spec.json 无浮点、spec_version 与 id 正确,再看 S 层脚本 exit 0,接着 P 层 fail == 0,然后 W 层无头断言全绿,最后才谈构建与产物。跨品类数字对不上时,先查两侧是否都用整数地板除、是否都按千分比口径——本附录的每张表都带单位、都能指回一个落盘文件,少了单位或指不回文件,它就退化成一堆无法核对的数字。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。