12.2 从机制仿真到体验指标
《小游戏开发实战》卷一 12.2 可玩性指标测试
那篇的指标是占比类——胜率、完成率,本质是一次伯努利试验的比例,所以能直接套二项区间。本节处理的是占比之外的情形:一条机制量指标(回合数、剩余血量、打出张数)怎么从仿真快照变成一个可比、可复现的数。它回答的是「从 run_spec 返回的字典到指标」这中间那段被跳过的路。
一条管线,四段
上一节选出的指标,落到底层都是「某个快照字段经过某种加工」。这段加工可以固定成四段,每段的输出都是整数:
| 段 | 输入 | 输出 | 整数约束 |
|---|---|---|---|
raw 原始字段 | 一次 run_spec 的快照 | 一个整数字段值 | 字段本身即整数 |
perSeed 每种子取值 | 同一配置、同一策略下的若干快照 | 每种子一个整数 | 无聚合,保留全部种子 |
agg 聚合 | 每种子的整数序列 | 一个整数 | 均值地板除、中位数取下中位 |
normalize 归一化 | 聚合值 | 千分比整数 | 除以该管线的跨配置最大值 |
relative 基线相对化 | 归一化值 | 相对基线的整数差 | 减去基线配置的读数 |
raw 与 perSeed 是「读」,agg 之后才是「造指标」。换聚合算子就是换指标——这是本节最容易被忽略的一点。同一个 turn 字段,「平均回合」与「最长回合」是两条不同的体验代理:前者说典型节奏,后者说最坏拖沓。它们从同一个字段来,却不是同一个指标。
同一字段,不同算子,不同指标
把 5 个字段 × 4 个聚合算子摆成 20 条管线,跑同一批配置,量每条管线的区分度(跨配置极差 ÷ 该管线最大值,千分比,使不同单位的字段可比)。三条结论值得先记住:
- 算子会改结论:
played(一局打出的张数)用最小值时区分度 583‰,用最大值时只有 250‰——同一字段,差一倍多。 - 字段会死:
block在这个参考策略下恒为零——最大伤害优先从不打guard,于是 block 字段没有任何信息。字段存在、schema 合法,但这条策略不读它。 - 字段会饱和:
dealt的四个算子给出同一条曲线(区分度都 358‰),因为打满敌人血量后伤害就封顶了,换算法也分不出来。
{ "seeds": [521, "…", 536], "maxSteps": 40, "referencePolicy": "max_damage",
"fields": ["turn", "playerHp", "dealt", "played", "block"],
"ops": ["mean", "min", "max", "median"] }
归一化与基线相对化
不同字段的单位不一样:turn 是回合、playerHp 是血量点、played 是张数。直接比它们没有意义。归一化把它们压到同一条 0–1000 的轴上——除以该管线跨配置的最大值,于是「这条管线在哪个配置最高」变成一个可比的形状。
基线相对化再进一步:挑一个基线配置(这里取 c0,最容易的配置),其他配置减去它的读数。得到的是相对差,不是绝对值——「配置 X 比基线低 54 点存活血量」,而不是「配置 X 的体验值是多少」。这是代理指标唯一允许的表述方式:相对比较,禁绝对化。
S 层:把 20 条管线跑一遍
脚本对 12 个配置各跑一条参考策略 × 16 个种子,把每个字段的每种子读数聚合、归一化,量出区分度并排序,再对最优管线做基线相对化。全整数、无浮点、无 random。
#!/usr/bin/env python3
# file: sim/gd_play_12_pipeline.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_12_pipeline.py
"""卷二 12.2 从机制仿真到体验指标(S 层):一条管线四段,逐段都可跑。
管线固定四段:raw(原始字段)→ perSeed(每种子一个整数)→ agg(聚合算子)→
normalize(归一化到千分比)→ relative(减基线)。本节把「字段 × 聚合算子」当成两条可换的
轴:同一个原始字段换一个聚合算子就是**另一个体验指标**——这正是代理指标只能相对比较的原因。
对 5 个字段 × 4 个聚合算子(mean/min/max/median)× 12 个配置各算一遍,量出每条管线的
**区分度**(跨配置极差,千分比)与归一化曲线,再对最优管线做基线相对化。
产出 out/play12_pipeline.json + out/play12_pipeline.csv + out/play12_pipeline.svg。
全整数、无浮点、无 random;纯 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_play_12_pipeline.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
KEYS = ("enemyHp", "turnLimit", "enemyAttack")
FIELDS = ("turn", "playerHp", "dealt", "played", "block")
OPS = ("mean", "min", "max", "median")
OPLABEL = {"mean": "均值", "min": "最小值", "max": "最大值", "median": "中位数"}
def choose(rule, s, cost, dmg, blk, ea):
"""参考策略:最大伤害优先(本节固定一条策略,只让字段与聚合算子变化)。"""
hand, en = s["hand"], s["energy"]
aff = [i for i, c in enumerate(hand) if cost[c] <= en]
if not aff:
return "e"
i = max(aff, key=lambda i: (dmg[hand[i]], -cost[hand[i]]))
return f"p{i}"
def run_config(spec, cfg, seeds, max_steps):
"""返回每个种子一条快照,字段即 perSeed 段的原始读数。"""
cost = {c["id"]: c["cost"] for c in spec["cards"]}
dmg = {c["id"]: c["damage"] for c in spec["cards"]}
blk = {c["id"]: c["block"] for c in spec["cards"]}
snaps = []
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"].update({k: cfg[k] for k in KEYS})
seq = []
for _ in range(max_steps):
sn = ashlib.run_spec(s, seq)
if sn["over"]:
break
seq.append(choose("max_damage", sn, cost, dmg, blk, cfg["enemyAttack"]))
snaps.append(ashlib.run_spec(s, seq))
return snaps
def agg(op, vals):
"""聚合算子:全部返回整数(median 取下中位数,n 为偶数时取第 n//2 个)。"""
if op == "mean":
return sum(vals) // len(vals)
if op == "min":
return min(vals)
if op == "max":
return max(vals)
sv = sorted(vals)
return sv[len(sv) // 2]
def main():
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
seeds, fields, ops = cfg["seeds"], cfg["fields"], cfg["ops"]
ids = [g["id"] for g in cfg["grid"]]
raw = {g["id"]: run_config(spec, g, seeds, cfg["maxSteps"]) for g in cfg["grid"]}
metric = {}
for f in fields:
for op in ops:
metric[(f, op)] = {cid: agg(op, [s[f] for s in raw[cid]]) for cid in ids}
disc = {f"{f}.{op}": max(metric[(f, op)].values()) - min(metric[(f, op)].values())
for f in fields for op in ops}
discp = {k: (1000 * disc[k] // max(metric[tuple(k.split("."))].values())
if max(metric[tuple(k.split("."))].values()) else 0) for k in disc}
norm = {}
for key in disc:
f, op = key.split(".")
mx = max(metric[(f, op)].values()) or 1
norm[key] = {cid: 1000 * metric[(f, op)][cid] // mx for cid in ids}
rank = sorted(disc, key=lambda k: (-discp[k], -disc[k], k))
best = rank[0]
bf, bop = best.split(".")
base_cfg = ids[0]
base_val = metric[(bf, bop)][base_cfg]
relative = {cid: metric[(bf, bop)][cid] - base_val for cid in ids}
rows = [[cid] + [metric[(f, op)][cid] for f in fields for op in ops] for cid in ids]
header = ["config"] + [f"{f}.{op}" for f in fields for op in ops]
n = ashlib.write_csv(os.path.join(OUT, "play12_pipeline.csv"), header, rows)
size = draw(norm, discp, rank, ids, os.path.join(OUT, "play12_pipeline.svg"))
report = {
"id": "gd_play_12_pipeline", "seeds": len(seeds), "configs": ids, "fields": fields, "ops": ops,
"metric": {f"{f}.{op}": metric[(f, op)] for f in fields for op in ops},
"normalized": norm, "discrimination": disc, "discriminationPermille": discp,
"rank": rank, "bestPipeline": best,
"baseline": {"config": base_cfg, "value": base_val}, "relative": relative,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000,
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play12_pipeline.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"{'管线':<14}{'区分度‰':>8} " + " ".join(f"{c:>5}" for c in ids))
for key in rank:
f, op = key.split(".")
vals = metric[(f, op)]
print(f"{f + '.' + OPLABEL[op]:<14}{discp[key]:>8} " + " ".join(f"{vals[c]:>5}" for c in ids))
print(f"最优管线={best}(区分度 {discp[best]}‰ / 原始极差 {disc[best]})")
print(f"基线 config={base_cfg} 值={base_val};相对化={[relative[c] for c in ids]}")
print(f"csv={n} rows svg={size} bytes elapsed={report['elapsedMs']} ms")
print("wrote out/play12_pipeline.csv out/play12_pipeline.svg out/play12_pipeline.json")
ET.parse(os.path.join(OUT, "play12_pipeline.svg"))
return 0
def draw(norm, discp, rank, ids, path):
W, H, x0, y0, y1 = 720, 320, 110, 52, 240
hi = len(ids) - 1
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 20, 26, "同一字段 × 不同聚合算子 = 不同体验指标(归一化千分比)", 15)
ashlib.svg_text(root, 20, 46, "每条管线都是代理指标:只做相对比较,禁绝对化", 11, "#94a3b8")
for k, key in enumerate(rank[:6]):
vals = [norm[key][c] for c in ids]
pts = " ".join(f"{x0 + i * (W - 130) // hi},{y1 - v * (y1 - y0) // 1000}" for i, v in enumerate(vals))
ET.SubElement(root, "polyline", {"points": pts, "fill": "none",
"stroke": ["#38bdf8", "#22c55e", "#f59e0b", "#ef4444", "#a855f7", "#eab308"][k],
"stroke-width": "3" if k == 0 else "2"})
ashlib.svg_text(root, 20, y0 + 22 + 20 * k, f"{key} {discp[key]}", 11)
return ashlib.svg_save(root, path)
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_12_pipeline.py:
管线 区分度‰ c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11
playerHp.最大值 1000 54 48 48 40 28 16 4 48 48 48 0 0
playerHp.均值 1000 49 47 44 32 20 8 0 44 44 44 0 0
playerHp.中位数 1000 48 48 42 30 18 6 0 42 42 42 0 0
playerHp.最小值 1000 48 47 42 30 18 6 0 42 42 42 0 0
turn.最大值 750 3 4 4 4 4 4 4 4 4 4 12 12
turn.中位数 750 3 3 4 4 4 4 3 4 4 4 12 12
turn.均值 727 3 3 4 4 4 4 3 4 4 4 11 11
turn.最小值 727 3 3 4 4 4 4 3 4 4 4 11 11
played.最小值 583 5 6 9 9 9 9 7 9 9 9 12 12
played.均值 416 7 8 9 9 9 9 8 9 9 9 12 12
played.中位数 416 7 8 10 10 10 10 8 10 10 10 12 12
dealt.最大值 358 50 60 70 70 70 70 70 70 70 70 78 78
dealt.均值 358 50 60 70 70 70 70 63 70 70 70 78 78
dealt.中位数 358 50 60 70 70 70 70 64 70 70 70 78 78
dealt.最小值 358 50 60 70 70 70 70 55 70 70 70 78 78
played.最大值 250 9 10 11 11 11 11 11 11 11 11 12 12
block.最大值 0 0 0 0 0 0 0 0 0 0 0 0 0
block.均值 0 0 0 0 0 0 0 0 0 0 0 0 0
block.中位数 0 0 0 0 0 0 0 0 0 0 0 0 0
block.最小值 0 0 0 0 0 0 0 0 0 0 0 0 0
最优管线=playerHp.max(区分度 1000‰ / 原始极差 54)
基线 config=c0 值=54;相对化=[0, -6, -6, -14, -26, -38, -50, -6, -6, -6, -54, -54]
csv=12 rows svg=2340 bytes elapsed=28 ms
wrote out/play12_pipeline.csv out/play12_pipeline.svg out/play12_pipeline.json
读表:三种「不提供信息」的字段
表里最值得看的是那些看起来有数、其实没信息的行:
| 现象 | 例子 | 机制原因 |
|---|---|---|
| 恒零字段 | block.* 全为 0 | 参考策略是最大伤害优先,从不打 guard——字段与策略不匹配 |
| 饱和字段 | dealt.* 四算子同曲线 | 伤害打满敌人血量即封顶,换算法也分不出来 |
| 算子敏感字段 | played.min 583‰ vs played.max 250‰ | 打出张数的「最坏一局」比「最好一局」更能分开配置 |
block 这一行是最该警惕的:字段在 play_spec.json 里合法存在,run_spec 也返回它,但在这条策略下它永远是 0。把「防御强度」映射到 block 是一条写得出、跑不动的映射——它不报错,只沉默。这和卷一 14.1 机制到体验的映射
里 punishHp 那个死旋钮是同一类缺陷,只是这次死在字段这一侧而不是参数那一侧。
再看最优管线 playerHp.max:它的区分度是 1000‰,但四个 playerHp 算子的区分度都是 1000‰(因为最小值都落到 0),排序靠的是原始极差(54 > 49 > 48 = 48)。也就是说,在这张网格上「用哪个算子」对 playerHp 几乎不重要——区分度只能帮你排除死字段,不能帮你在活字段里选算子。选算子要靠领域判断:想知道「最坏一局能剩多少血」就用 min,想知道「最好一局」就用 max,那是设计问题,不是统计问题。
自检表
| 检查项 | 合格标准 |
|---|---|
| 四段齐全 | raw / perSeed / agg / normalize / relative 各段职责写清 |
| 算子入档 | 字段与聚合算子写进 config;换算子 = 换指标,必须显式 |
| 归一化同口径 | 不同单位的字段都归一到千分比后再比 |
| 基线入档 | 基线配置写进 config,输出的是相对差而非绝对值 |
| 代理指标声明 | 指标声明为 proxy,只做相对比较、禁绝对化 |
| 死字段检查 | 恒零 / 饱和字段单独标出,不许当作有效指标使用 |
管线把字段变成了指标,但表里每个数字都还只是点估计:换一批种子,played.min 可能从 583‰ 变成别的数。下一节给这个「换一批种子会变多少」一个整数的回答。
阅读导航:上一节:12.1 体验指标的选取 · 下一节:12.3 指标的置信区间 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。