14.1 品类的特征向量

给六个品类各造一个十维整数特征向量:机制计数、状态字段数、策略空间对数与熵、非支配率、状态空间对数、决策深度、随机结构计数、相变宽度、平均步数。说明每维的选择理由与「机制无关 vs 品类特有」的边界,从规格文本数出六维、从内核真跑出四维,落成可复现的 6×10 矩阵。

14.1 品类的特征向量

前 13 章把同一份 play_spec.json 实例化成了六个品类变体,但一直缺一把能回答「这两个品类到底差多远」的尺子。风格那边已经有一把:《风格与品类美术实战》的风格距离矩阵量的是一张画面在色相、明度、饱和度、笔触、纹理上的距离,那是视觉特征;本章要造的是机制距离矩阵,量的是规则在规则条数、状态字段、策略空间、相变宽度上的距离。两者是口径对照——同样的「整数 L1 + 归一化千分比」写法,但特征集完全不同,不重造。《小游戏设计》的品类分类 走的是产品层口径(面向用户与市场的品类标签),本节是机制层的可计算量化。

本节只做一件事:把每个品类变成一个十维整数向量,并交代每一维为什么被选、它到底能不能跨品类比较。所有维度都是代理指标(proxy),只做相对比较,禁绝对化——一个向量大不代表这个品类更好玩,只代表它在这一点上「数值更大」。

为什么是向量,不是标签

品类标签(Roguelike、塔防、三消)是名义量,只能判等、不能相减。要算距离,必须先把它变成有序整数。做法是给每个品类造一个特征向量:第 k 维是一个整数读数,两个品类在第 k 维上的差就是它们在这一维上的距离。选维度守三条纪律:

  1. 可复现:每一维都能由脚本重算,不靠人拍脑袋打分——主观评分是意见,不是测量。
  2. 整数:所有维度落成整数,避免浮点在不同机器上的最后一位漂移。
  3. 不重复:维度之间尽量不表达同一件事,否则等于偷偷给同一件事加权。

十个维度与选择理由

#维度定义选择理由可比性
①机制计数规格 JSON 的标量叶子数规格复杂度:一条规则是一个可变的机制单元机制无关
②状态字段数快照类型的一层字段数状态模型规模:一次快照里有多少个独立变量机制无关
③策略空间大小分支因子 × 决策深度的 log₂一棵决策树有多「宽」机制无关
④策略空间结果熵策略族结果分布的 Shannon 熵 ×1000有多少条路真的走得通,而非名义上有几条机制无关
⑤策略族非支配率不被别人压着的策略占比 ×1000Pareto 前沿有多宽机制无关
⑥状态空间规模各变量取值个数之积的 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.json5 测试全绿,0 失败
/opt/homebrew/bin/python3 sim/gd_proto_14_features.pyout/gd_proto_14_features.json / .csv6×10 整数矩阵落盘

六个品类、十个维度、全整数,这就是下一节要拿来算距离的原料。但原料摆出来还不能直接相减——各维量纲差了四个数量级,直接算 L1 会被 ⑥状态空间与 ⑩平均步数完全主导。14.2 先解决归一化,再定义距离。

阅读导航:上一节:13.3 变体的可玩性闸门 · 下一节:14.2 距离矩阵与可区分性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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