附录 A 动态与体验指标公式、参数表与数据集索引

《游戏设计》卷二《策略、涌现与体验度量》附录:把全卷 16 章的动态与体验指标定义式汇成一张整数公式表、把共享种子池与各章工作点汇成一张参数表、把 48 节的产物与生成脚本汇成一张数据集索引表,并给出一段真跑过的全卷指标总表聚合内核。全部指标都是代理指标,只做相对比较,禁绝对化。

一、这份索引解决什么

卷二 48 节各自给了一个动态或体验指标:熵、支配缺失度、涌现度、心流带宽、张力峰值、可玩性组合……它们散在 16 章里,每章一份配置、一个脚本、几个产物。这份附录做三件事:把指标定义式汇成一张整数公式表、把共享采样约定与各章工作点汇成一张参数表、把产物与生成脚本汇成一张数据集索引表,最后给出一段真跑过的全卷指标总表聚合内核。

卷一的净增量是「可执行」——同一份规格四处消费、摘要逐字相同。卷二的净增量是**「把好玩拆成能算的量」**:把「平衡」「涌现」「心流」这些词操作化成可跑脚本能算出的整数。

一条贯穿全卷的口径必须写在这里:这些量全部是代理指标(proxy)。它们只用于相对比较——「A 配置的熵比 B 高」可说,「所以 A 更好玩」不可说。任何把代理指标绝对化的写法都违反本卷口径。

公式与参数都以整数为准:// 是整数地板除,熵 ×1000 取整,比值一律千分比(‰);违反「预算与阈值一律整数、随机一律自实现可播种 PRNG、config/ 禁浮点」三条纪律里的任何一条,跨章对照必然崩。

二、卷内文件命名约定

全卷文件一律带 gd_play_ / play 前缀,与卷一的 gd_mech_ / mech、卷三的 gd_proto_ / proto 严格不串卷:

目录命名例
config/play_spec.json(唯一真源,只读)+ gd_play_<NN>_<用途>.jsonconfig/gd_play_10_band.json
sim/gd_play_<NN>_<用途>.py(S 层);_warn / _robust 后缀为 B 层sim/gd_play_15_1_invert.py
core/src/ashdeck/共享底座 types / rng / cards / combat / digest.ts(只读)core/src/ashdeck/combat.ts
core/test/gd_play_<NN>_<用途>.test.tscore/test/gd_play_16_integration.test.ts
web/build_play<NN>.mjs / verify_mech.mjs(复用通用验证器)web/build_play16.mjs
out/play<NN>_<用途>.{json,csv,svg} 与 out/play16.html、out/shots/*.pngout/play12_interval.json

out/ 下的确切文件名以各节正文代码块头注释里的 file: 为准(下表给的是实际落盘名)。底座五个模块全卷只读,各章只在它之上写自己那一节的脚本与断言。

三、动态与体验指标公式表(全整数)

量公式(全整数)出处
策略空间熵H = -Σ pᵢ·log2 pᵢ,×1000 取整1.2
支配缺失度domFree = 1000 - 1000·(被支配策略数) // (策略总数)2.1 / 14.2
最优解代价livePermille = 1000·可用子博弈 // 全部子博弈3.3
涌现度emergence = 1000·可达模式数 // 规则数4.2
平衡度balanceScore:由胜率带宽度与可用策略数合成的整数5.2
相变临界宽度width = hi - lo(序参量跨阈值的参数区间宽度,整数格)6.1
序参量与临界指数序参量跨阈值(如胜率千分比 ≥500 掉到 ≤200);临界指数由双侧斜率整数近似6.2
自主归一化自主 = 1000·(branchAvg - 1) // handSize7.2
类型得分score_t = (Σ wᵢ·fᵢ) // 10008.1
张力与释放progress = dealt×1000 // enemyHp0;strain = (hp0 - hp)×1000 // hp0;tension = (progress + strain) // 29.1
心流带宽band = upperEdge - lowerEdge(上沿 = 胜率首次 ≤ easy,下沿 = 首次 ≥ hard)10.2
技巧习得曲线指数 q_{t+1} = q_t + (qMax - q_t)×rate // 1000;幂律 q_t + gain // t11.1
体验指标区分度discrimination(m) = max(m) - min(m)12.1
体验指标置信区间center = 1000·(k+2) // (n+4);half = 2000·isqrt((k(n-k)+n)·S²//n) // (S·(n+4))(Wilson,z=2,整数 isqrt)12.3
可玩性闸门组合 (entropy, domFree, emergence, band),四闸门全过才算 PASS13.1
动态可视化色阶t = 1000·(v - v0) // (v1 - v0);r = a_r + (b_r - a_r)×t // 100014.2
参数反推可行域二分求最小 p 使 f(p) ≤ T;可行域 = 目标带 ∩ 子池稳定15.1 / 15.2
装配口径每个代理指标单位 ∈ {permille, int};digest 固定 12 键、字母序16.1

log2 一律走整数定点:log2i(n) = n.bit_length() - 1,log2_milli(n) = floor(1000·log2 n)。浮点一出现,跨语言对照即崩。

四、参数表与工作点

共享采样约定(全卷各章复用同一套,写在 16.3 的冻结清单里):

约定取值
种子池大小32(各章取连续 16 / 24 / 32 个,如 301–332、401–432、501–524、601–616)
每格种子数cellsPerScan = 32
粗扫步长coarseStep = 5
二分轮数bisectMaxRounds ≤ 8
PRNGstate = state×48271 % 2147483647(Lehmer MINSTD,自实现)

各章工作点(enemyHp / enemyAttack 是绝大多数指标的两个主轴):

章enemyHpenemyAttack备注
770—base:turnLimit 20 / energyMax 3 / handSize 5
85516slice
9—6 / 12 / 18 / 24 / 30张力扫;曲线段取 6–12
10606–30 步长 1挑战轴;window easy=900 / hard=250,turnBudget 26
117614scenario;网格 56/68/76 × 8/12/16/18/20/24
127020 / 18mid(临界)/ sat(饱和)
13变体—四变体 ashdeck / mono / rich / tight
1460 / 702–30 步长 2曲线双 hp
1540–1002–34 步长 2反推区间 / 可行域网格
16506play_spec.json 基线

经实测证明的死旋钮与死字段——这三条最容易让「调参调不动」的困惑出现:

  • 第 7 章:playerHp、enemyAttack 在工作点上是死旋钮——把它们各加一档,动因对齐度一个‰都不动(7.3)。真正能动对齐度的是 energyMax、handSize、enemyHp。
  • 第 12 章:block.* 在「最大伤害优先」策略下是死字段——四算子(mean/min/max/median)恒为 0(12.2)。要让格挡可观测,先得换一个会打 guard 的策略。
  • 第 11 章:defense 维度在瓶颈分析里会翻符号(11.3),用「留一法」判承重维度时须留意——它可能不是没贡献,而是贡献方向相反。

五、数据集与生成脚本索引

下表按「产物 / 生成脚本 / 在哪节」列出各节的 S 层产物;out/ 下的确切文件名以该节 file: 头注释为准(此处为实际落盘名):

产物(out/ 下)生成脚本节
play01_representation.{json,csv,svg}sim/gd_play_01_representation.py1.1
play01_entropy.{json,csv,svg}sim/gd_play_01_entropy.py1.2
play01_scale.{json,csv,svg}sim/gd_play_01_scale.py1.3
play02_dominance.{json,svg}sim/gd_play_02_dominance.py2.1
play02_detection.{json,csv}sim/gd_play_02_detection.py2.2
play02_repair.{json,svg} + play02_matrix.csvsim/gd_play_02_repair.py2.3
play03_minimax.{json,svg}sim/gd_play_03_minimax.py3.1
play03_nash.svgsim/gd_play_03_nash.py3.2
play03_cost.{json,csv,svg}sim/gd_play_03_cost.py3.3
play04_emergence.{json,csv,svg}sim/gd_play_04_emergence.py4.1
play04_metric.{json,csv,svg}sim/gd_play_04_metric.py4.2
play04_degenerate.{json,csv,svg}sim/gd_play_04_degenerate.py4.3
play05_layers.{json,svg}sim/gd_play_05_layers.py5.1
play05_metric.{json,csv,svg}sim/gd_play_05_metric.py5.2
play05_diversity.{json,csv,svg}sim/gd_play_05_diversity.py5.3
play06_scan.{json,svg}sim/gd_play_06_scan.py6.1
play06_exponent.{json,svg}sim/gd_play_06_exponent.py6.2
play06_phase.{json,svg}sim/gd_play_06_phase.py6.3
play07_sdt.{json,csv,svg}sim/gd_play_07_sdt.py7.1
play07_model.{json,csv,svg}sim/gd_play_07_model.py7.2
play07_alignment.{json,csv,svg}sim/gd_play_07_alignment.py7.3
play08_taxonomy.{json,csv,svg}sim/gd_play_08_taxonomy.py8.1
play08_mapping.{json,csv,svg}sim/gd_play_08_mapping.py8.2
play08_drift.{json,csv,svg}sim/gd_play_08_drift.py8.3
play09_tension.jsonsim/gd_play_09_tension.py9.1
play09_curve.{json,svg}sim/gd_play_09_curve.py9.2
play09_align.{json,svg}sim/gd_play_09_align.py9.3
play10_match.{json,svg}sim/gd_play_10_match.py10.1
play10_band.{json,csv,svg}sim/gd_play_10_band.py10.2
play10_schedule.{json,csv,svg}sim/gd_play_10_schedule.py10.3
play11_acquisition.{json,svg}sim/gd_play_11_acquisition.py11.1
play11_alignment.{json,svg}sim/gd_play_11_alignment.py11.2
play11_bottleneck.{json,svg}sim/gd_play_11_bottleneck.py11.3
play12_select.{json,csv,svg}sim/gd_play_12_select.py12.1
play12_pipeline.{json,csv,svg}sim/gd_play_12_pipeline.py12.2
play12_interval.{json,csv,svg}sim/gd_play_12_interval.py12.3
play13_playability.{json,csv,svg}sim/gd_play_13_playability.py13.1
play13_sampling.{json,csv,svg}sim/gd_play_13_sampling.py13.2
play13_comparability.{json,csv,svg}sim/gd_play_13_comparability.py13.3
play14_charts.json + play14_curve.svg + play14_spectrum.svgsim/gd_play_14_curve_spectrum.py14.1
play14_heatmap.{json,csv,svg}sim/gd_play_14_heatmap.py14.2
play14_phase.json + play14_phase_curve.svg + play14_phase_map.svgsim/gd_play_14_phase_read.py14.3
play15_invert.{json,csv,svg}sim/gd_play_15_1_invert.py15.1
play15_region.{json,csv,svg}sim/gd_play_15_2_region.py15.2
play15_cost.{json,csv,svg}sim/gd_play_15_3_cost.py15.3
gd_play_16_assembly.jsonsim/gd_play_16_assembly.py16.1
gd_play_16_integration.json + play16.html + shots/play16.pngsim/gd_play_16_integration.py + web/build_play16.mjs16.2
gd_play_16_handoff.jsonsim/gd_play_16_handoff.py16.3

B 层不新增数据表,它读 S 层产物再算稳健性,落 play*_warn.json / play*_robust.json(如 play15_invert_warn.json、play13_playability_warn.json)。P 层产出的是 core/test/gd_play_<NN>_*.test.ts 里的一组断言;W 层产出的是 out/play16.html 与截图。本附录自己的聚合脚本 sim/gd_play_appendix_a.py 只打印、不写新产物。

六、复现命令与全卷指标总表

S 层(Python,逐个跑,不要一次传多个文件):

# snippet: S 层用全路径解释器;shell 的 for 循环逐个执行,避免只跑第一个文件
cd "$HOME/.gdbook/ashdeck"
for f in sim/gd_play_*.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_play_*.test.ts'

W 层(Playwright 无头真玩,先 build 再 verify):

# snippet: 复用全局 Playwright 必须经 createRequire;verify 产出截图并断言 canvas 已绘制
cd "$HOME/.gdbook/ashdeck"
node web/build_play16.mjs
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs --page out/play16.html --shot out/shots/play16.png --expect-digest '{"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}'

最后是全卷指标总表聚合内核:它把 16 章各自落盘的 out/play*.json 读回来,每章取一个「表头指标」聚成一张表。它不重跑任何指标——它跑的是「落盘产物能不能被汇总成一张可核对的表」:

#!/usr/bin/env python3
# file: sim/gd_play_appendix_a.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_appendix_a.py
"""卷二 附录 A 全卷指标总表(S 层聚合内核):把 16 章各自落盘的 out/play*.json 读回来,
每章取一个「表头指标」,聚成一张**全卷指标总表**打印。

它不重跑任何指标——它跑的是「落盘产物能不能被汇总成一张可核对的表」。每行的值是**代理指标**
口径下的整数或千分比,只用于**相对比较**,禁绝对化。缺文件即报缺,不臆造。本脚本只打印,
不写任何新产物(附录是只读汇总)。全整数、无浮点、无 random。
"""
from __future__ import annotations

import json
import os

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")


def get(d, path):
    for k in path:
        d = d[k]
    return d


# (章, 表头指标, 单位, 产物文件, 取值函数)——取值函数只做整数抽取,不含浮点
ROWS = [
    ("01", "策略空间熵", "permille", "play01_entropy.json", lambda d: get(d, ["entropyPermille", "H0"])),
    ("02", "严格支配对数", "int", "play02_dominance.json", lambda d: len(get(d, ["strictPairs"]))),
    ("03", "可用子博弈占比", "permille", "play03_cost.json", lambda d: get(d, ["livePermille"])),
    ("04", "涌现度", "permille", "play04_emergence.json", lambda d: get(d, ["emergencePermille"])),
    ("05", "平衡分", "permille", "play05_metric.json", lambda d: get(d, ["metrics", "balanceScore"])),
    ("06", "相变临界点", "int", "play06_phase.json", lambda d: get(d, ["criticalPoint"])),
    ("07", "动因覆盖", "permille", "play07_model.json", lambda d: get(d, ["coveragePermille"])),
    ("08", "类型漂移峰值", "permille", "play08_drift.json", lambda d: max(r["drift"] for r in get(d, ["points"]))),
    ("09", "张力峰值", "permille", "play09_tension.json", lambda d: max(r["peak"] for r in get(d, ["scan"]))),
    ("10", "心流带宽峰值", "int", "play10_band.json", lambda d: max(r["bandWidth"] for r in get(d, ["rows"]))),
    ("11", "习得达标局", "int", "play11_acquisition.json", lambda d: get(d, ["crossExponential"])),
    ("12", "指标区分度", "permille", "play12_pipeline.json", lambda d: get(d, ["discriminationPermille", "playerHp.max"])),
    ("13", "可玩性组合熵", "permille", "play13_playability.json", lambda d: get(d, ["rows"])[0]["entropy"]),
    ("14", "支配缺失度", "permille", "play14_heatmap.json", lambda d: get(d, ["dominatedPermille"])),
    ("15", "反推参数值", "int", "play15_invert.json", lambda d: get(d, ["inverted"])),
    ("16", "装配核对失败数", "int", "gd_play_16_assembly.json", lambda d: get(d, ["fail"])),
]


def main() -> int:
    table, missing = [], []
    for ch, name, unit, fn, pick in ROWS:
        p = os.path.join(OUT, fn)
        if not os.path.exists(p):
            missing.append(fn)
            continue
        with open(p, encoding="utf-8") as f:
            data = json.load(f)
        table.append({"chapter": ch, "metric": name, "unit": unit, "file": fn, "value": int(pick(data))})

    print("=== 卷二 全卷指标总表(16 章各取一个表头指标,代理指标口径)===")
    for r in table:
        print(f"ch{r['chapter']}  {r['metric']}  {r['unit']}  {r['value']}  <- out/{r['file']}")
    print(f"rows={len(table)}  missing={missing or '无'}")
    print("(全部为代理指标,只做相对比较,禁绝对化;本脚本只打印,不写新产物)")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_appendix_a.py:

=== 卷二 全卷指标总表(16 章各取一个表头指标,代理指标口径)===
ch01  策略空间熵  permille  2584  <- out/play01_entropy.json
ch02  严格支配对数  int  5  <- out/play02_dominance.json
ch03  可用子博弈占比  permille  500  <- out/play03_cost.json
ch04  涌现度  permille  2692  <- out/play04_emergence.json
ch05  平衡分  permille  605  <- out/play05_metric.json
ch06  相变临界点  int  18  <- out/play06_phase.json
ch07  动因覆盖  permille  666  <- out/play07_model.json
ch08  类型漂移峰值  permille  278  <- out/play08_drift.json
ch09  张力峰值  permille  910  <- out/play09_tension.json
ch10  心流带宽峰值  int  6  <- out/play10_band.json
ch11  习得达标局  int  5  <- out/play11_acquisition.json
ch12  指标区分度  permille  1000  <- out/play12_pipeline.json
ch13  可玩性组合熵  permille  2251  <- out/play13_playability.json
ch14  支配缺失度  permille  292  <- out/play14_heatmap.json
ch15  反推参数值  int  65  <- out/play15_invert.json
ch16  装配核对失败数  int  0  <- out/gd_play_16_assembly.json
rows=16  missing=无
(全部为代理指标,只做相对比较,禁绝对化;本脚本只打印,不写新产物)

读数三行。rows=16 missing=无:16 章的产物都在,聚合链完整。ch16 装配核对失败数=0:第 16 章的装配契约自洽,与这张总表对得上。其余每一格都是该章表头指标的一个快照——它们单位不同(permille 与 int 混排),所以只能按列看趋势,不能跨行加总。比如 ch09 张力峰值=910 是千分比(张力封顶 1000),ch15 反推参数值=65 是「目标胜率 500‰ 对应的 enemyHp」,两者根本不同量纲。

这张表本身就是本附录的口径示范:每一格都带单位、都能指回一个落盘文件、都是代理指标。少了单位或指不回文件,它就退化成一堆无法核对的数字。

排错顺序固定为规格 → S 层 → P 层 → W 层 → 构建:先确认 config/play_spec.json 无浮点、spec_version 与 id 正确,再看 S 层脚本 exit 0,接着 P 层 fail == 0,然后 W 层无头断言全绿,最后才谈构建与产物。跨章数字对不上时,先查两侧是否都用整数地板除、是否都按千分比口径。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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