14.2 可观测的体验指标

把体验写成四个全部可算的代理指标:回合数、每回合有效动作率、资源闲置率、决策点密度,全是整数或千分比,落 CSV 并用纯 xml.etree 画图。16 个种子上实测闲置率均值 204‰、决策点密度 585‰、每回合动作 2150‰。它们都是代理指标,只用于相对比较,禁绝对化。

14.2 可观测的体验指标

《小游戏开发实战》卷一 12.2 可玩性指标与测试方法 那篇用模拟采样 + 置信区间算的是占比类指标(胜率、通关率这类 [0,1] 区间内的比例);本节算的是结构类指标——它们不回答「赢了多少」,而回答「过程长什么样」:战斗多久、每回合真打了几张、有多少能量白扔、几步里真做了一次选择。14.1 借用了快照里现成的 turn / playerHp 当观测列,本节则为体验专门构造四个指标,每一个都必须能被一个整数公式写出来。

四个代理指标的定义

四个指标全部落在整数或千分比上,无浮点。定义如下(turn / played 直接来自快照,其余三个由逐步重放的轨迹推出):

指标公式(整数地板除)测的是代理哪个体验目标
turn终态 turn战斗长度悬念、节奏
actPermille1000 × played // turn每回合有效动作数 ×1000操作密度
idlePermille1000 × idle // avail未花能量占可用能量的千分比资源压迫感
decisionPermille1000 × 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 行逐行看下来,每个指标的分布形状不一样,这直接决定了它们各自能承担什么判断:

指标取值范围形状噪声来源
turn4–6三档离散洗牌决定前两回合能否摸到重击
idlePermille0–277随回合数走回合越少,每回合丢掉的零头越显眼
decisionPermille428–705波动最大手牌里「付得起且不同 id」的搭配随机
actPermille2000–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 从机制到体验的断言 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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