14.1 品类的特征向量
前 13 章把同一份 play_spec.json 实例化成了六个品类变体,但一直缺一把能回答「这两个品类到底差多远」的尺子。风格那边已经有一把:《风格与品类美术实战》的风格距离矩阵量的是一张画面在色相、明度、饱和度、笔触、纹理上的距离,那是视觉特征;本章要造的是机制距离矩阵,量的是规则在规则条数、状态字段、策略空间、相变宽度上的距离。两者是口径对照——同样的「整数 L1 + 归一化千分比」写法,但特征集完全不同,不重造。《小游戏设计》的品类分类
走的是产品层口径(面向用户与市场的品类标签),本节是机制层的可计算量化。
本节只做一件事:把每个品类变成一个十维整数向量,并交代每一维为什么被选、它到底能不能跨品类比较。所有维度都是代理指标(proxy),只做相对比较,禁绝对化——一个向量大不代表这个品类更好玩,只代表它在这一点上「数值更大」。
为什么是向量,不是标签
品类标签(Roguelike、塔防、三消)是名义量,只能判等、不能相减。要算距离,必须先把它变成有序整数。做法是给每个品类造一个特征向量:第 k 维是一个整数读数,两个品类在第 k 维上的差就是它们在这一维上的距离。选维度守三条纪律:
- 可复现:每一维都能由脚本重算,不靠人拍脑袋打分——主观评分是意见,不是测量。
- 整数:所有维度落成整数,避免浮点在不同机器上的最后一位漂移。
- 不重复:维度之间尽量不表达同一件事,否则等于偷偷给同一件事加权。
十个维度与选择理由
| # | 维度 | 定义 | 选择理由 | 可比性 |
|---|---|---|---|---|
| ① | 机制计数 | 规格 JSON 的标量叶子数 | 规格复杂度:一条规则是一个可变的机制单元 | 机制无关 |
| ② | 状态字段数 | 快照类型的一层字段数 | 状态模型规模:一次快照里有多少个独立变量 | 机制无关 |
| ③ | 策略空间大小 | 分支因子 × 决策深度的 log₂ | 一棵决策树有多「宽」 | 机制无关 |
| ④ | 策略空间结果熵 | 策略族结果分布的 Shannon 熵 ×1000 | 有多少条路真的走得通,而非名义上有几条 | 机制无关 |
| ⑤ | 策略族非支配率 | 不被别人压着的策略占比 ×1000 | Pareto 前沿有多宽 | 机制无关 |
| ⑥ | 状态空间规模 | 各变量取值个数之积的 log₂ | 状态空间有多大 | 机制无关 |
| ⑦ | 平均决策深度 | 一局最多走几步的声明预算 | 一局的「长度」 | 品类特有 |
| ⑧ | 随机性结构计数 | 用到几种随机结构(均匀/加权/洗牌袋/保底) | 随机性有多「结构化」 | 机制无关 |
| ⑨ | 相变宽度 | 难度扫过时「混合带」的格数 | 从易到难是不是一刀切 | 机制无关 |
| ⑩ | 单局平均步数 | 一局实际走几步 ×1000 | 一局的真实长度 | 机制无关 |
关于「可比性」:①⑥ 与 ⑧⑩ 是机制无关的,它们的定义不依赖某个品类的语义,归一化之后跨品类可比。⑦ 是品类特有的:六个品类对「一步」的定义各不相同——卡牌构筑一步是一个回合、塔防一步是一个波次、三消一步是一次交换、平台跳跃一步是一帧、自走棋一步是一个回合、放置一步是一个 tick。它们的数值放在一起只是「六个品类各自的决策预算」,量纲并不统一,所以 ⑦ 只在同品类内有意义,跨品类比较时它是噪声。这是 14.3 要交代的第一条边界。
还有一点:④⑤⑨⑩ 四维读不出——它们要么依赖策略族跑出来的结果分布(④⑤⑩),要么依赖参数扫描(⑨)。硬编码这些数字等于伪造测量,所以它们交给 P 层真跑内核,落成 JSON 再由 S 层读。
S 层:从规格文本数出六维
sim/gd_proto_14_features.py 负责 ①机制计数 ②状态字段数 ③策略空间对数 ⑥状态空间对数 ⑦决策深度 ⑧随机性结构 这六维:它读 config/*.json(递归数标量叶子当「规则条数」)、读 core/**/*.ts 的状态类型(数一层字段),再从内核 JSON 取另外四维。全整数、无浮点。
#!/usr/bin/env python3
# file: sim/gd_proto_14_features.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_14_features.py
"""卷三 14.1 品类的特征向量(S 层):给 6 个品类各算一个**整数特征向量**(10 维)。
6 个品类 = 同一份 play_spec.json 在 6 组规则参数下的实例:卡牌构筑(本体)/ 塔防 / 三消 /
平台跳跃 / 自走棋 / 放置。10 个维度里,6 个从规格文本直接数出来(①机制计数 ②状态字段数
③策略空间对数 ⑥状态空间对数 ⑦决策深度 ⑧随机性结构),4 个必须真跑内核(④结果熵 ⑤非支配率
⑨相变宽度 ⑩平均步数),由 P 层 `core/test/gd_proto_14_features.test.ts` 落成
out/gd_proto_14_kernel.json,本脚本读取装配。
全部整数、无浮点、无 random。产出 out/gd_proto_14_features.json + out/gd_proto_14_features.csv。
"""
from __future__ import annotations
import csv
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
KERNEL = os.path.join(OUT, "gd_proto_14_kernel.json")
DIMS = [
("rules", "机制计数(规则条数)"),
("stateFields", "状态字段数"),
("strategyBits", "策略空间大小(对数,bit)"),
("entropyMilli", "策略空间结果熵 ×1000"),
("paretoPermille", "策略族非支配率(千分比)"),
("stateBits", "状态空间规模(对数,bit)"),
("decisionDepth", "平均决策深度(声明预算)"),
("randomBits", "随机性结构计数(bit)"),
("phaseWidth", "相变宽度(格数)"),
("avgStepsMilli", "单局平均步数 ×1000"),
]
# 每个品类的 6 个「文本维度」参数。state 是**声明的状态模型**(变量名, 取值个数),
# 上界一律取自配置字段(注释里给出出处);random 列出该品类用到的随机结构种类。
GENRES = [
{"key": "deckbuilder", "label": "卡牌构筑", "config": "play_spec.json",
"sections": ["params", "cards", "goals", "invariants"],
"snap_file": "core/src/ashdeck/types.ts", "snap_type": "Snapshot",
"branch": 6, "depth": 20, "random": ["洗牌袋"],
# playerHp 61←params.playerHp; enemyHp 51←params.enemyHp; energy 4←params.energyMax;
# turn 20←params.turnLimit; block 11←Σcards.block(10)
"state": [("playerHp", 61), ("enemyHp", 51), ("energy", 4), ("turn", 20), ("block", 11)]},
{"key": "towerdefense", "label": "塔防", "config": "gd_proto_06_td.json",
"sections": ["rules", "params", "invariant"],
"snap_file": "core/genre/towerdefense.ts", "snap_type": "TdSnapshot",
"branch": 13, "depth": 6, "random": ["均匀"],
# baseHp 21←state.baseHp; wave 6←params.waveCount; gold 139←state.gold+Σkills×rewardPerKill+ΣrewardPerWave;
# towerCount 5←len(slots)+1; levelSum 13←len(slots)×upgrade.maxLevel+1
"state": [("baseHp", 21), ("wave", 6), ("gold", 139), ("towerCount", 5), ("levelSum", 13)]},
{"key": "match3", "label": "三消", "config": "gd_proto_07_m3.json",
"sections": ["rules", "params", "invariant"],
"snap_file": "core/genre/match3.ts", "snap_type": "M3Snapshot",
"branch": 98, "depth": 24, "random": ["洗牌袋", "保底"],
# score 6001←2×params.targetScore+1; movesUsed 25←params.moveCount+1; maxChain 17←rules.cascadeCap+1
"state": [("score", 6001), ("movesUsed", 25), ("maxChain", 17)]},
{"key": "platformer", "label": "平台跳跃", "config": "gd_proto_08_platformer.json",
"sections": ["rules", "params", "invariant"],
"snap_file": "core/genre/platformer.ts", "snap_type": "PfSnapshot",
"branch": 6, "depth": 160, "random": [],
# x 30001←rules.goalX+1; y 20001←params.fallLimit+1; vx 1001←2×rules.runSpeed+1;
# frame 161←params.frameLimit+1
"state": [("x", 30001), ("y", 20001), ("vx", 1001), ("frame", 161)]},
{"key": "autobattler", "label": "自走棋", "config": "gd_proto_09_ab.json",
"sections": ["rules", "params", "invariant"],
"snap_file": "core/genre/autobattler.ts", "snap_type": "AbSnapshot",
"branch": 7, "depth": 12, "random": ["加权"],
# hp 41←state.hp+1; level 4←rules.maxLevel; round 12←params.rounds;
# gold 131←state.gold+rounds×(baseIncome+interestCap)+1
"state": [("hp", 41), ("level", 4), ("round", 12), ("gold", 131)]},
{"key": "idle", "label": "放置", "config": "gd_proto_10_idle.json",
"sections": ["rules", "params", "invariant"],
"snap_file": "core/genre/idle.ts", "snap_type": "IdleSnapshot",
"branch": 2, "depth": 800, "random": ["均匀"],
# owned 101←2×len(milestones.at 上界 360)/7+1≈100;prestige 51←milestones 档数×8;
# milestonesHit 8←len(milestones)+1; tick 801←params.ticks+1
"state": [("owned", 101), ("prestige", 51), ("milestonesHit", 8), ("tick", 801)]},
]
def leaf_count(obj) -> int:
"""递归数标量叶子:dict 数 value、list 数 item、标量算 1。"""
if isinstance(obj, dict):
return sum(leaf_count(v) for v in obj.values())
if isinstance(obj, list):
return sum(leaf_count(v) for v in obj)
return 1
def log2_milli(n: int) -> int:
"""floor(log2(n) × 1000),纯整数(禁浮点)。"""
if n < 1:
return 0
one, x, k = 1 << 20, n << 20, 0
while x >= one << 1:
x >>= 1
k += 1
frac = 0
for _ in range(10):
x = (x * x) >> 20
frac <<= 1
if x >= one << 1:
frac += 1
x >>= 1
return k * 1000 + (frac * 1000) // 1024
def snapshot_fields(path: str, type_name: str) -> int:
"""数出 `export type <T> = { ... };` 的一层字段数(花括号深度 1 上的 `name:` 行)。"""
with open(os.path.join(ROOT, path), encoding="utf-8") as f:
text = f.read()
marker = "export type " + type_name + " = {"
start = text.index(marker)
depth, i, end = 0, start + len(marker) - 1, -1
while i < len(text):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
end = i
break
i += 1
body = text[start + len(marker):end]
n = 0
for seg in body.split(";"):
s = seg.split("//")[0].strip()
if not s:
continue
name = s.split(":")[0].strip().rstrip("?")
if name and all(c.isalnum() or c == "_" for c in name):
n += 1
return n
def main() -> int:
with open(KERNEL, encoding="utf-8") as f:
kernel = json.load(f)["genres"]
rows, vecs, meta = [], [], []
for g in GENRES:
with open(os.path.join(ROOT, "config", g["config"]), encoding="utf-8") as f:
cfg = json.load(f)
rules = sum(leaf_count(cfg[s]) for s in g["sections"])
fields = snapshot_fields(g["snap_file"], g["snap_type"])
strat = g["depth"] * log2_milli(g["branch"]) // 1000
state_prod = 1
for _, rng in g["state"]:
state_prod *= rng
k = kernel[g["key"]]
vec = [
rules, fields, strat, k["entropyMilli"], k["paretoPermille"],
log2_milli(state_prod), g["depth"], len(g["random"]),
k["phase"]["width"], k["avgStepsMilli"],
]
vecs.append(vec)
meta.append({"key": g["key"], "label": g["label"], "branch": g["branch"],
"stateProduct": state_prod, "random": g["random"]})
rows.append([g["key"]] + vec)
report = {"id": "gd_proto_14_features", "dims": [d[0] for d in DIMS],
"dimLabels": [d[1] for d in DIMS], "genres": [m["key"] for m in meta],
"labels": [m["label"] for m in meta], "vectors": vecs, "meta": meta}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "gd_proto_14_features.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
f.write("\n")
with open(os.path.join(OUT, "gd_proto_14_features.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["genre"] + [d[0] for d in DIMS])
w.writerows(rows)
print("6×10 特征向量(整数):")
print(" " + "genre".ljust(13) + "".join(d[0].rjust(15) for d in DIMS))
for m, v in zip(meta, vecs):
print(" " + m["key"].ljust(13) + "".join(str(x).rjust(15) for x in v))
print("\n状态模型(变量取值个数之积 → 对数 bit):")
for m in meta:
print(f" {m['key']:<13} 积={m['stateProduct']:<12} log2×1000={log2_milli(m['stateProduct']):<7}"
f" 分支因子={m['branch']:<3} 随机结构={m['random']}")
print("\nwrote out/gd_proto_14_features.json out/gd_proto_14_features.csv")
return 0
if __name__ == "__main__":
sys.exit(main())
# snippet: 跑本节 S 层
/opt/homebrew/bin/python3 sim/gd_proto_14_features.py
6×10 特征向量(整数):
genre rules stateFields strategyBits entropyMilli paretoPermille stateBits decisionDepth randomBits phaseWidth avgStepsMilli
deckbuilder 39 12 51 431 800 21383 20 1 8 4971
towerdefense 39 19 22 1837 600 20118 6 1 4 5200
match3 18 19 158 1492 500 21282 24 2 6 23968
platformer 21 18 413 1400 600 46458 160 0 0 100400
autobattler 62 22 33 2168 200 17975 12 1 5 11993
idle 32 19 800 1000 1000 24975 800 1 0 800000
状态模型(变量取值个数之积 → 对数 bit):
deckbuilder 积=2737680 log2×1000=21383 分支因子=6 随机结构=['洗牌袋']
towerdefense 积=1138410 log2×1000=20118 分支因子=13 随机结构=['均匀']
match3 积=2550425 log2×1000=21282 分支因子=98 随机结构=['洗牌袋', '保底']
platformer 积=96704658211161 log2×1000=46458 分支因子=6 随机结构=[]
autobattler 积=257808 log2×1000=17975 分支因子=7 随机结构=['加权']
idle 积=33007608 log2×1000=24975 分支因子=2 随机结构=['均匀']
wrote out/gd_proto_14_features.json out/gd_proto_14_features.csv
P 层:从内核跑出四维
另外四维(④结果熵 ⑤非支配率 ⑨相变宽度 ⑩平均步数)由 core/test/gd_proto_14_features.test.ts 真跑内核得出:六个品类变体各跑「K 条策略 × 64 个种子」(三消只有 2 条内置策略,其余各 5 条),算结果熵、非支配率、平均步数;再对每个品类扫一条难度参数,数出「胜率落在 100‰..900‰」的混合格数当相变宽度。它是纯 TS、node --test 直跑,结果落 out/gd_proto_14_kernel.json。
# snippet: 跑本节 P 层
node --test 'core/test/gd_proto_14_features.test.ts'
genre 熵×1000 非支配率‰ 平均步数 胜率‰ 相变宽度 带
deckbuilder 431 800 4971 906 8 20..34
towerdefense 1837 600 5200 600 4 38..41
match3 1492 500 23968 7 6 1250..2500
platformer 1400 600 100400 600 0 -1..-1
autobattler 2168 200 11993 934 5 15..23
idle 1000 1000 800000 0 0 -1..-1
✔ 三消只有 2 条内置策略,其余品类各 5 条(策略族规模)
✔ 结果熵 ×1000 落在 0..4000(16 桶字母表的上界是 4 bit)
✔ 支配策略缺失度是千分比,且等于 1000 − 1000×支配策略数/策略数
✔ 平台跳跃是确定性的:同一策略在任何种子上同结果
✔ 相变宽度是扫过的格数,且带宽落在扫描区间内(如实报告)
ℹ tests 5
ℹ pass 5
ℹ fail 0
(上面省略了每品类的 16 桶分布与扫描序列行,完整输出见脚本实跑。)
这张表里有两处必须如实报告的退化:平台跳跃与放置的相变宽度都是 0。原因是它们在这一维上没有混合带——平台跳跃是确定性的(同一策略在任何种子上同结果,测试里专门断言了这一点),jumpVel 扫过时胜率从 0 直接跳到 1000;放置的 ticks 扫过时也是从 0 直接跳到 1000 的硬台阶。相变宽度为 0 不是「量不出来」,而是这个品类在这条参数上真的是一刀切——如实记 0,不硬凑一个非零值。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
node --test 'core/test/gd_proto_14_features.test.ts' | out/gd_proto_14_kernel.json | 5 测试全绿,0 失败 |
/opt/homebrew/bin/python3 sim/gd_proto_14_features.py | out/gd_proto_14_features.json / .csv | 6×10 整数矩阵落盘 |
六个品类、十个维度、全整数,这就是下一节要拿来算距离的原料。但原料摆出来还不能直接相减——各维量纲差了四个数量级,直接算 L1 会被 ⑥状态空间与 ⑩平均步数完全主导。14.2 先解决归一化,再定义距离。
阅读导航:上一节:13.3 变体的可玩性闸门 · 下一节:14.2 距离矩阵与可区分性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。