附录 A 品类参数表、机制距离矩阵与数据集索引

《游戏设计》卷三《原型、可玩性与品类》附录:把六个品类变体的规则段与参数段关键字段汇成品类参数表、把 6×10 整数特征向量与 6×6 机制距离矩阵(L1 与归一化千分比两级)汇成距离表、把全卷 16 章的 S/P/W/D/B 产物按章汇成数据集索引。距离与全部指标都是代理指标,只做相对比较,禁绝对化。

一、这份索引解决什么

卷三 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/ 读回):

品类specrules 关键字段params 关键字段
deckbuilder(本体)play_spec.jsoncards 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-defensegd_proto_06_td.jsonpathLength 12 · slots 4 · towerTypes 2 · upgradeMaxLevel 3 · jitterPermille 250 · rewardPerKill 2 · rewardPerWave 5waveCount 6 · waveHpBase 8 · waveHpStep 4 · enemiesBase 4 · enemiesStep 1 · moveEvery 2 · spawnGap 3 · leakDamage 2
match3gd_proto_07_m3.jsonrows 7 · cols 7 · types 5 · minRun 3 · cascadeCap 16 · scorePerTile 10 · cascadeBonusPermille 250 · pity 1/4moveCount 24 · targetScore 3000
platformergd_proto_08_platformer.jsonplatforms 3 · goalX 30000 · runSpeed 250 · jumpVel 500 · gravity 25 · coyoteFrames 6 · bufferFrames 6frameLimit 160 · fallLimit 20000
autobattlergd_proto_09_ab.jsonunits 4 · shopSize 5 · poolPerUnit 12 · refreshCost 2 · mergeCount 3 · interestStep 10 · interestCap 5 · maxLevel 4 · buysPerRound 2rounds 12 · boardBase 3 · boardPerLevel 1 · enemyBase 26 · enemyStep 11
idlegd_proto_10_idle.jsonproducerBaseCost 10 · costGrowthPermille 1040 · baseRate 1500 · milestones 7 · prestige 50000/200 · jitterPermille 200 · autoBuyEvery 2ticks 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 层真跑内核得出,其余六维从规格文本数出:

品类rulesstateFieldsstrategyBitsentropyMilliparetoPermillestateBitsdecisionDepthrandomBitsphaseWidthavgStepsMilli
卡牌构筑3912514318002138320184971
塔防3919221837600201186145200
三消1819158149250021282242623968
平台跳跃211841314006004645816000100400
自走棋622233216820017975121511993
放置3219800100010002497580010800000

6×6 距离矩阵(L1 与归一化千分比两级)

距离用整数 L1(归一化向量各维差的绝对值之和)与归一化千分比(1000·L1 // maxL1)两级表达(取自 out/gd_proto_14_matrix.json,单元格写作 L1/‰):

L1 / ‰卡牌构筑塔防三消平台跳跃自走棋放置
卡牌构筑—2357/3623080/4744958/7633808/5865508/847
塔防2357/362—1811/2783501/5381743/2684811/740
三消3080/4741811/278—3576/5502996/4615386/829
平台跳跃4958/7633501/5383576/550—5186/7984520/695
自走棋3808/5861743/2682996/4615186/798—6496/1000
放置5508/8474811/7405386/8294520/6956496/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+Sconfig/gd_proto_01_*.json · sim/gd_proto_01_*.py · out/gd_proto_01_replay.json/.svg纸面规则表→规格,回放校验守恒
2 数字原型P+Wsim/gd_proto_02_*.py · core/test/gd_proto_02_*.test.ts · out/proto02.html最小内核 + 迭代循环 + 止损
3 可玩原型Wsim/gd_proto_03_*.py · core/test/gd_proto_03_play_assert.test.ts · out/proto03.html规格内联单文件 HTML,浏览器真玩
4 可玩性测试S+Bsim/gd_proto_04_*.py · out/proto04_{hypothesis,sampling,decision}.json假设 / 采样 / 决策三段
5 卡牌构筑W+Pcore/test/gd_proto_05_*.test.ts · out/gd_proto_05_anatomy.json · out/proto05.html贯穿案例本体的解剖 / 方差 / 相变
6 塔防W+Pconfig/gd_proto_06_td.json · core/genre/towerdefense.ts · out/proto06.html塔防内核 + 索敌状态空间
7 三消W+Pconfig/gd_proto_07_m3.json · core/genre/match3.ts · out/gd_proto_07_rhythm.json三消内核 + 保底 + 节奏谱
8 平台跳跃W+Pconfig/gd_proto_08_platformer.json · core/genre/platformer.ts · out/proto08.html跳跃内核 + 技巧曲线 + 心流带宽
9 自走棋W+Pconfig/gd_proto_09_ab.json · core/genre/autobattler.ts · out/proto09.html自走棋经济 + 羁绊 + 涌现
10 放置W+Pconfig/gd_proto_10_idle.json · core/genre/idle.ts · out/proto10.html放置曲线 + 相变 + 动因
11 战斗作为机制S+Pconfig/gd_proto_11_combat.json · sim/gd_proto_11_*.py · out/gd_proto_11_space.json/.svg伤害 / 时序 / 资源的机制解剖
12 手感作为机制P+Wcore/genre/gamefeel.ts · core/test/gd_proto_12_*.test.ts · out/proto12.html可断言的输入-反馈机制
13 跨品类:同一份规格S+P+Wconfig/gd_proto_13_variants.json · sim/gd_proto_13_*.py · out/gd_proto_13_{schema,gate}.json六变体注册表 + 参数化 + 闸门
14 品类间距离矩阵Ssim/gd_proto_14_*.py · out/gd_proto_14_features.json/.csv · out/gd_proto_14_matrix.json6×10 特征向量 + 6×6 距离 + 热力图
15 品类变体的验收S+P+W+Bsim/gd_proto_15_*.py · out/gd_proto_15_gate_matrix.json · out/gd_proto_15_robust_summary.json30/30 格 + 稳健性 + 失败诚实报告
16 装配:三卷总收官S+P+W+Bsim/gd_proto_16_*.py · out/gd_proto_16_replay.json · out/gd_proto_16_checklist.json全链路回放 + 19 项装配清单
内核与附录P/Score/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 层无头断言全绿,最后才谈构建与产物。跨品类数字对不上时,先查两侧是否都用整数地板除、是否都按千分比口径——本附录的每张表都带单位、都能指回一个落盘文件,少了单位或指不回文件,它就退化成一堆无法核对的数字。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 9.3 自走棋的涌现
  2. 9.2 自走棋的经济与平衡
  3. 9.1 自走棋的机制解剖