14.2 可观测的体验指标
《小游戏开发实战》卷一 12.2 可玩性指标与测试方法
那篇用模拟采样 + 置信区间算的是占比类指标(胜率、通关率这类 [0,1] 区间内的比例);本节算的是结构类指标——它们不回答「赢了多少」,而回答「过程长什么样」:战斗多久、每回合真打了几张、有多少能量白扔、几步里真做了一次选择。14.1 借用了快照里现成的 turn / playerHp 当观测列,本节则为体验专门构造四个指标,每一个都必须能被一个整数公式写出来。
四个代理指标的定义
四个指标全部落在整数或千分比上,无浮点。定义如下(turn / played 直接来自快照,其余三个由逐步重放的轨迹推出):
| 指标 | 公式(整数地板除) | 测的是 | 代理哪个体验目标 |
|---|---|---|---|
turn | 终态 turn | 战斗长度 | 悬念、节奏 |
actPermille | 1000 × played // turn | 每回合有效动作数 ×1000 | 操作密度 |
idlePermille | 1000 × idle // avail | 未花能量占可用能量的千分比 | 资源压迫感 |
decisionPermille | 1000 × dec // opp | 有 ≥2 个付得起选项的步数占比 | 抉择密度 |
其中 avail = energyMax × max(1, turn),idle = max(0, avail − spent),spent = Σ max(0, 上一步能量 − 本步能量)(只累计下降,跳过回合重置造成的上升);opp 是游戏结束前的出牌机会数,dec 是这些机会里手牌存在 ≥2 个可付得起不同 id 的次数。
这四个全是代理指标(proxy)。 它们不等于「紧张」「爽快」这些体验本身,只用来做相对比较——配置 A 的闲置率低于配置 B,只能说 A 在「能量被用满」这件事上比 B 更接近目标,不能说 A 比 B「好玩」。正文里不出现「体验提升 N 倍」这类绝对化表述,因为体验没有单位。
decisionPermille 的定义里有个必须点明的细节:它数的是手牌提供了几个选择,不是玩家做了几个选择。一个把所有选项都无视、只机械出第一张牌的玩家,决策点密度照样满分——这个指标测的是机制提供的机会,不是玩家的行为。把它当成「玩家在思考」的证据,就是典型的代理指标误用。
计算(S 层)
脚本用 ashlib.run_spec 对命令序列逐段前缀重放(第 k 项 = 处理完前 k 个命令后的快照),因此每一步的能量与手牌都拿得到,而规则仍与内核同源、不重写任何一条。
#!/usr/bin/env python3
# file: sim/gd_mech_14_metric.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_14_metric.py
"""卷一 14.2 可观测的体验指标(S 层):四个全部可算的代理指标。
每个种子一条轨迹(用 ashlib.run_spec 对命令序列逐段重放,规则与内核同源),派生:
turn 回合数 —— 「战斗长度」的代理
actPermille 每回合有效动作率 = 1000 × played // turn(动作密度)
idlePermille 资源闲置率 = 1000 × 未花能量 // 可用能量
decisionPermille 决策点密度 = 1000 × 有≥2个可付得起选项的步数 // 出牌机会数
其中 idle 的能量预算按内核的「每回合重置」口径:avail = energyMax × turn,
spent = Σ max(0, 上一步能量 − 本步能量)(只累计下降,跳过回合重置的上升)。
四个指标全是整数或千分比,无浮点;产物 CSV + 纯 xml.etree SVG。
它们都是**代理指标**,只用于相对比较,不构成「好玩」的绝对判据。
"""
import copy
import json
import os
import sys
import time
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_mech_14_metric.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
HEADER = ["seed", "turn", "played", "avail", "spent", "idle", "idlePermille",
"opportunities", "decisions", "decisionPermille", "actPermille", "won"]
def trace(spec, seq):
"""逐步前缀重放:第 k 项 = 处理完前 k 个命令后的快照。"""
return [ashlib.run_spec(spec, seq[:k]) for k in range(len(seq) + 1)]
def metrics(spec, seq, cost):
tr = trace(spec, seq)
final = tr[-1]
turn = final["turn"]
avail = spec["params"]["energyMax"] * max(1, turn)
spent = sum(max(0, tr[k - 1]["energy"] - tr[k]["energy"]) for k in range(1, len(tr)))
idle = max(0, avail - spent)
opp = dec = 0
for k in range(len(seq)):
st = tr[k]
if st["over"] or not seq[k].startswith("p"):
continue
opp += 1
if len({cid for cid in st["hand"] if cost[cid] <= st["energy"]}) >= 2:
dec += 1
return {"turn": turn, "played": final["played"], "avail": avail, "spent": spent, "idle": idle,
"idlePermille": 1000 * idle // avail if avail else 0,
"opportunities": opp, "decisions": dec,
"decisionPermille": 1000 * dec // opp if opp else 0,
"actPermille": 1000 * final["played"] // max(1, turn),
"won": final["won"]}
def draw(hist, means, path):
"""左:回合数分布(竖条);右:三个千分比指标(横条,按三者最大值归一)。"""
W, H = 640, 300
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 24, 28, "《灰烬牌库》可观测体验指标(16 个种子,enemyHp=70)", 14)
ashlib.svg_text(root, 24, 48, "全部为代理指标:只做相对比较,禁绝对化", 11, "#94a3b8")
ashlib.svg_text(root, 24, 78, "回合数分布", 12)
top = max(hist.values()) or 1
for i, (t, c) in enumerate(sorted(hist.items())):
x = 30 + i * 46
h = 90 * c // top
ET.SubElement(root, "rect", {"x": str(x), "y": str(170 - h), "width": "30",
"height": str(max(h, 1)), "fill": "#38bdf8"})
ashlib.svg_text(root, x + 15, 186, f"turn {t}", 10, "#94a3b8", "middle")
ashlib.svg_text(root, x + 15, 166 - h, f"{c}", 11, "#cbd5e1", "middle")
ashlib.svg_text(root, 24, 224, "指标均值(‰)", 12)
mx = max(means.values()) or 1
for i, (k, v) in enumerate(means.items()):
y = 236 + i * 20
ashlib.svg_text(root, 24, y + 12, k, 11)
ashlib.svg_bar(root, 200, y, 360, 14, v, mx, fill="#22c55e", label=f"{v}‰")
return ashlib.svg_save(root, path)
def main():
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
op = cfg["operatingPoint"]
spec["params"][op["param"]] = op["value"]
cost = {c["id"]: c["cost"] for c in spec["cards"]}
seq = cfg["probePattern"] * cfg["probeTurns"]
rows, csv = [], []
for sd in cfg["seeds"]:
s = copy.deepcopy(spec)
s["seed"] = sd
m = metrics(s, seq, cost)
rows.append(m)
csv.append([sd] + [m[k] for k in HEADER[1:]])
n = len(rows)
hist = {}
for m in rows:
hist[m["turn"]] = hist.get(m["turn"], 0) + 1
means = {
"idlePermille": sum(m["idlePermille"] for m in rows) // n,
"decisionPermille": sum(m["decisionPermille"] for m in rows) // n,
"actPermille": sum(m["actPermille"] for m in rows) // n,
}
wrote = ashlib.write_csv(os.path.join(OUT, "mech14_metric.csv"), HEADER, csv)
size = draw(hist, means, os.path.join(OUT, "mech14_metric.svg"))
report = {"id": "gd_mech_14_metric", "operatingPoint": op, "seeds": n,
"turnHistogram": {str(k): v for k, v in sorted(hist.items())},
"meansPermille": means, "rows": rows,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
with open(os.path.join(OUT, "mech14_metric.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"{'seed':>5}{'turn':>5}{'played':>7}{'avail':>6}{'spent':>6}{'idle':>5}"
f"{'idle‰':>7}{'opp':>5}{'dec':>5}{'dec‰':>7}{'act‰':>7}")
for sd, m in zip(cfg["seeds"], rows):
print(f"{sd:>5}{m['turn']:>5}{m['played']:>7}{m['avail']:>6}{m['spent']:>6}{m['idle']:>5}"
f"{m['idlePermille']:>7}{m['opportunities']:>5}{m['decisions']:>5}"
f"{m['decisionPermille']:>7}{m['actPermille']:>7}")
print(f"turn 分布: {report['turnHistogram']}")
print(f"均值: idle={means['idlePermille']}‰ decision={means['decisionPermille']}‰ "
f"act={means['actPermille']}‰")
print(f"csv={wrote} 行 svg={size} bytes elapsed={report['elapsedMs']} ms")
print("wrote out/mech14_metric.csv out/mech14_metric.svg out/mech14_metric.json")
ET.parse(os.path.join(OUT, "mech14_metric.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_14_metric.py(elapsed 是墙钟采样,每次运行都不同):
seed turn played avail spent idle idle‰ opp dec dec‰ act‰
201 5 11 15 12 3 200 17 11 647 2200
202 5 11 15 12 3 200 17 10 588 2200
203 6 12 18 13 5 277 21 12 571 2000
204 5 11 15 12 3 200 17 12 705 2200
205 6 12 18 13 5 277 21 11 523 2000
206 5 11 15 12 3 200 17 11 647 2200
207 5 11 15 13 2 133 17 10 588 2200
208 4 10 12 11 1 83 14 9 642 2500
209 6 12 18 13 5 277 21 10 476 2000
210 6 12 18 13 5 277 21 9 428 2000
211 4 10 12 12 0 0 15 9 600 2500
212 6 12 18 13 5 277 21 12 571 2000
213 5 11 15 12 3 200 17 11 647 2200
214 5 10 15 12 3 200 17 10 588 2000
215 6 12 18 13 5 277 21 13 619 2000
216 5 11 15 12 3 200 17 9 529 2200
turn 分布: {'4': 2, '5': 8, '6': 6}
均值: idle=204‰ decision=585‰ act=2150‰
csv=16 行 svg=2954 bytes elapsed=25 ms
wrote out/mech14_metric.csv out/mech14_metric.svg out/mech14_metric.json
读表:三个指标各有各的噪声源
16 行逐行看下来,每个指标的分布形状不一样,这直接决定了它们各自能承担什么判断:
| 指标 | 取值范围 | 形状 | 噪声来源 |
|---|---|---|---|
turn | 4–6 | 三档离散 | 洗牌决定前两回合能否摸到重击 |
idlePermille | 0–277 | 随回合数走 | 回合越少,每回合丢掉的零头越显眼 |
decisionPermille | 428–705 | 波动最大 | 手牌里「付得起且不同 id」的搭配随机 |
actPermille | 2000–2500 | 只有两档 | 只跟每回合出牌张数挂钩 |
三行数字值得单独读:
idlePermille最小是 0(seed 211)。那不是「玩得好」,而是那一局在第 4 回合就用光了所有能量后正好取胜,最后一个回合没有浪费——它是运气,不是设计。指标能到 0 不代表设计能稳定到 0。decisionPermille的极值 428 与 705 差了 277‰,同一套规则、同一段策略,只因洗牌不同。这个指标对随机性极敏感,用它做单局判断会被噪声淹没,只能跨种子看均值。actPermille只有 2000 与 2500 两档,对应「每回合打 2 张」与「每回合打 2.5 张」。粒度这么粗,是因为探针策略把每回合的出牌数锁死了;换策略才会解锁更多档。
这也解释了为什么四个指标要一起看:单看 turn 会漏掉「打得快是因为没得选」;单看 decisionPermille 会被噪声带跑。它们的均值(204‰ / 585‰ / 2150‰)才是这一节的结论,任何单局数字都只是样本。
指标的分工与陷阱
| 陷阱 | 症状 | 对策 |
|---|---|---|
| 把代理当体验 | 直接说「闲置率低所以更好玩」 | 只写相对比较,写明代理名 |
| 单局下结论 | 拿 seed 211 的 idle=0 当设计达标 | 一律跨种子聚合,报均值与极值 |
| 指标重叠 | 同时用 played 与 actPermille 当两个证据 | 认清 actPermille = 1000 × played // turn,是同一个量的两种写法 |
| 语义漂移 | 像 14.1 那样把 turn 同时当「赢得多快」与「活得多久」 | 指标定义里写清它在哪个工作点有效 |
下一节把这张表变成门禁:指标算出来只是数字,要能用来自动判「机制有没有跑偏」,就得给每个指标配一条阈值、写成能失败的断言,并且给每条断言配一个故意违反的负例。
| 检查项 | 合格标准 |
|---|---|
| 指标可算 | 每个指标有整数公式,能写成一行地板除 |
| 无浮点 | 全部整数或千分比,config/ 与 sim/ 里不出现小数 |
| 产物落地 | 落 CSV + 纯 xml.etree SVG,SVG 可 ET.parse |
| 代理显式声明 | 正文写明「代理指标、只做相对比较、禁绝对化」 |
| 语义写清 | 每个指标注明它测的是机会还是行为、在哪个工作点有效 |
阅读导航:上一节:14.1 机制到体验的映射 · 下一节:14.3 从机制到体验的断言 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。