12.1 体验指标的选取

体验指标不能凭直觉堆。本节给一个八条候选的可算池,再用两条整数判据——跨配置区分度与两两同序冗余度——做贪心筛选;实跑《灰烬牌库》十二个配置,8 个候选里只有 band / entropy / domFree 三条互不冗余,其余五条要么分不开配置,要么与入选者几乎同序。

12.1 体验指标的选取

《小游戏开发实战》卷一 12.2 可玩性指标测试 那篇做的是 S 层模拟采样加置信区间,指标偏占比类——胜率、完成率这种「多少比例」的量。本节做的是另一件事:先决定该看哪些量。前者回答「这个占比有多准」,后者回答「占比之外还有哪些量值得看、它们会不会互相重复」。指标池里既会有占比类(可解率、可用策略占比),也会有机制量(策略胜率熵、胜率带宽、支配缺失度),本节把它们放在同一张判据下筛。

指标不是越多越好

一个自然的冲动是「多量几个总没坏处」。恰恰相反:指标之间会同序——两个指标在每一对配置上都给出同样的排序,那它们其实是同一个指标的两种写法,多量一个只是多一行数字,不提供新信息。更糟的是,两个高相关的指标同时进入一份复合评分时,会暗中把权重翻倍:你以为给了「平衡」和「多样性」各一半权重,实际给了同一件事两倍权重。

所以选取要做两件事:淘汰分不开配置的(没有信息量),淘汰和别人几乎同序的(重复信息)。两件事都可以用整数判据说清楚,不必靠口味。

候选池:八个可算的量

候选全部从同一次仿真的策略 × 种子胜率矩阵里数出来(怎么构造矩阵见 2.2 检测算法与复杂度 )。八个量、三种口径:

指标定义口径
solvability 可解率至少有一条策略赢下的种子占比(‰)占比
entropy 策略胜率熵策略行均值归一化后的 Shannon 熵 ÷ log2(策略数)(‰)分布形状
band 胜率带宽策略行均值的极差(‰)分布形状
domFree 支配缺失度未被任何策略严格支配的策略占比(‰)占比
viable 可用策略占比行均值 ≥ 最高 − 容差的策略占比(‰)占比
mutual 互有胜负对占比互有胜负的策略对占比(‰)占比
turnMean 平均回合全部(策略,种子)对的末回合均值机制量
dealtFrac 输出完成度平均已造成伤害 ÷ 敌人血量(‰)机制量

八个量全是代理指标(proxy):它们不等于「好玩」,只用于配置之间的相对比较。本节禁止任何绝对化说法——不说「熵高 200‰ 就更耐玩」,只说「配置 A 的熵高于配置 B」。

两条判据

区分度 discrimination(m) = max(m) − min(m):指标跨全部配置的极差(千分比)。一个在这张配置网格上纹丝不动的指标,无论多有道理都没用——它没有能力把任何两个配置分开。区分度低于阈值的候选直接淘汰。

冗余度 concordance(a, b):把全部配置两两配对,数出两指标给出同向排序的对数占比(千分比)。1000‰ 表示两指标在所有配置对上都同序,是同一个指标的两种写法。冗余度 ≥ 阈值即视为冗余,后来者淘汰。

筛选是贪心的:按区分度降序扫描候选,若它与每一个已选指标的冗余度都低于阈值就入选,否则淘汰。贪心不保证最优(这是集合覆盖问题),但它是可解释、可复现的:每一步为什么入选、为什么淘汰都写得出。

{ "seeds": [501, "…", 516], "cellsPerConfig": 16, "maxSteps": 40,
  "select": { "redundancyThresholdPermille": 800, "minDiscriminationPermille": 20,
              "viableTolPermille": 200 } }

S 层:把候选池跑一遍

脚本对 12 个配置各跑 6 条确定性策略 × 16 个种子,算出八个候选指标,再量区分度与冗余度、执行贪心。熵只在脚本内用 math.log2 求,落盘一律取整千分比;全程无浮点、无 random。

#!/usr/bin/env python3
# file: sim/gd_play_12_select.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_12_select.py
"""卷二 12.1 体验指标的选取(S 层):从候选池里选一组不冗余的代理指标。

候选指标池(全部千分比整数):solvability 可解率 · entropy 策略胜率熵 · band 胜率带宽 ·
domFree 支配缺失度 · viable 可用策略占比 · mutual 互有胜负对占比 · turnMean 平均回合 ·
dealtFrac 输出完成度。对 8 个配置各算一遍,得到「指标 × 配置」矩阵。

选取只看两条整数判据:
  区分度 discrimination = 该指标跨配置的极差(千分比);
  冗余度 concordance(a,b) = 两指标在全部配置对上给出同向排序的比例(千分比)。
贪心:按区分度降序扫描,若候选与已选每个指标的 concordance 都 < 阈值则入选。

所有指标都是**代理指标(proxy)**:只用于配置之间的相对比较,禁绝对化。
产出 out/play12_select.json + out/play12_select.csv + out/play12_select.svg。
全整数、无浮点、无 random;纯 xml.etree 出 SVG。
"""
import copy
import json
import math
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_select.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402

KEYS = ("enemyHp", "turnLimit", "enemyAttack")
METRICS = ("solvability", "entropy", "band", "domFree", "viable", "mutual", "turnMean", "dealtFrac")
LABEL = {
    "solvability": "可解率", "entropy": "策略胜率熵", "band": "胜率带宽",
    "domFree": "支配缺失度", "viable": "可用策略占比", "mutual": "互有胜负对占比",
    "turnMean": "平均回合", "dealtFrac": "输出完成度",
}


def choose(rule, s, cost, dmg, blk, ea):
    """确定性策略规则:读当前快照,返回下一步命令(pK 出第 K 张,e 结束回合)。"""
    hand, en = s["hand"], s["energy"]
    aff = [i for i, c in enumerate(hand) if cost[c] <= en]
    if not aff:
        return "e"
    if rule == "max_damage":
        i = max(aff, key=lambda i: (dmg[hand[i]], -cost[hand[i]]))
    elif rule == "block_first":
        g = [i for i in aff if blk[hand[i]] > 0]
        i = g[0] if g and s["block"] < ea else max(aff, key=lambda i: dmg[hand[i]])
    elif rule == "min_cost":
        i = min(aff, key=lambda i: (cost[hand[i]], -dmg[hand[i]]))
    elif rule == "even_turn_big":
        big = [i for i in aff if cost[hand[i]] >= 2]
        if s["turn"] % 2 == 0:
            return f"p{big[0]}" if big else "e"
        i = min(aff, key=lambda i: (cost[hand[i]], -dmg[hand[i]]))
    elif rule == "first_slot":
        i = aff[0]
    else:
        i = aff[-1]
    return f"p{i}"


def play(spec, pol, cfg, seeds, max_steps):
    """跑一条策略:返回每种子是否胜、末回合、已造成伤害(供多指标复用)。"""
    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"]}
    res = []
    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(pol["rule"], sn, cost, dmg, blk, cfg["enemyAttack"]))
        fin = ashlib.run_spec(s, seq)
        res.append({"won": fin["won"], "turn": fin["turn"], "dealt": fin["dealt"]})
    return res


def config_metrics(spec, cfg, policies, seeds, tol, max_steps):
    """一个配置下算全部候选指标(全整数千分比)。"""
    runs = {p["id"]: play(spec, p, cfg, seeds, max_steps) for p in policies}
    n, np_ = len(seeds), len(policies)
    wins = {k: sum(1 for r in runs[k] if r["won"]) for k in runs}
    means = {k: 1000 * wins[k] // n for k in runs}
    # 可解率:至少有一条策略赢下的种子占比
    solvable = sum(1 for i in range(n) if any(runs[k][i]["won"] for k in runs))
    # 策略胜率熵:行均值归一化后的 Shannon 熵 ÷ log2(策略数) ×1000(脚本内用 math.log2,落盘取整)
    tot = sum(means.values())
    ent = 0
    if tot > 0:
        h = sum(-(v / tot) * math.log2(v / tot) for v in means.values() if v > 0)
        ent = int(round(1000 * h / math.log2(np_)))
    # 支配:策略 a 在每个种子上都 ≥ b,且至少一个种子严格 >
    def dom(a, b):
        return (all(runs[a][i]["won"] >= runs[b][i]["won"] for i in range(n))
                and any(runs[a][i]["won"] > runs[b][i]["won"] for i in range(n)))
    ids = list(runs)
    dominant = {a for a in ids if any(dom(a, b) for b in ids if b != a)}
    best = max(means.values())
    viable = sum(1 for v in means.values() if v > 0 and v >= best - tol)
    mutual = sum(1 for i in range(np_) for j in range(i + 1, np_)
                 if any(runs[ids[i]][k]["won"] > runs[ids[j]][k]["won"] for k in range(n))
                 and any(runs[ids[i]][k]["won"] < runs[ids[j]][k]["won"] for k in range(n)))
    flat = [r for k in runs for r in runs[k]]
    turn_mean = sum(r["turn"] for r in flat) // len(flat)
    dealt_frac = 1000 * (sum(r["dealt"] for r in flat) // len(flat)) // cfg["enemyHp"]
    return {
        "solvability": 1000 * solvable // n,
        "entropy": ent,
        "band": max(means.values()) - min(means.values()),
        "domFree": 1000 * (np_ - len(dominant)) // np_,
        "viable": 1000 * viable // np_,
        "mutual": 2000 * mutual // (np_ * (np_ - 1)),
        "turnMean": turn_mean,
        "dealtFrac": dealt_frac,
    }


def concordance(a, b):
    """两指标在全部配置对上的同向排序比例(千分比):1000 = 完全同序。"""
    m = len(a)
    same = tot = 0
    for i in range(m):
        for j in range(i + 1, m):
            da, db = a[i] - a[j], b[i] - b[j]
            if da == 0 or db == 0:
                continue
            tot += 1
            same += 1 if (da > 0) == (db > 0) else 0
    return 1000 if tot == 0 else 1000 * same // tot


def draw(curves, ids, sel, disc, path):
    W, H, x0, y0, y1 = 700, 340, 96, 44, 250
    lo, hi = 0, len(ids) - 1
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, 20, 26, "候选指标 × 8 个配置:入选者加粗", 15)
    ashlib.svg_text(root, 20, 46, "全部为代理指标:只做相对比较,禁绝对化", 11, "#94a3b8")
    colors = {"selected": "#38bdf8", "rejected": "#475569"}
    for name in METRICS:
        vals = [curves[c][name] for c in ids]
        mx = max(vals) or 1
        pts = " ".join(f"{x0 + i * (W - 120) // hi},{y1 - v * (y1 - y0) // mx}" for i, v in enumerate(vals))
        ET.SubElement(root, "polyline", {
            "points": pts, "fill": "none",
            "stroke": colors["selected" if name in sel else "rejected"],
            "stroke-width": "3" if name in sel else "1",
        })
        ashlib.svg_text(root, 20, y0 + 18 * METRICS.index(name), f"{LABEL[name]} {disc[name]}", 11,
                        "#e2e8f0" if name in sel else "#64748b")
    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)
    seeds = cfg["seeds"][: cfg["cellsPerConfig"]]
    sel_cfg = cfg["select"]
    tol = sel_cfg["viableTolPermille"]
    ids = [g["id"] for g in cfg["grid"]]
    curves = {g["id"]: config_metrics(spec, g, cfg["policies"], seeds, tol, cfg["maxSteps"])
              for g in cfg["grid"]}

    disc = {m: max(curves[c][m] for c in ids) - min(curves[c][m] for c in ids) for m in METRICS}
    conc = {a: {b: concordance([curves[c][a] for c in ids], [curves[c][b] for c in ids])
                for b in METRICS if b != a} for a in METRICS}
    order = sorted(METRICS, key=lambda m: (-disc[m], m))
    selected, rejected = [], []
    for m in order:
        if disc[m] < sel_cfg["minDiscriminationPermille"]:
            rejected.append({"metric": m, "reason": "discrimination", "discrimination": disc[m]})
            continue
        red = {s: conc[m][s] for s in selected}
        if red and max(red.values()) >= sel_cfg["redundancyThresholdPermille"]:
            rejected.append({"metric": m, "reason": "redundant", "discrimination": disc[m],
                             "redundantWith": max(red, key=red.get), "concordance": max(red.values())})
        else:
            selected.append(m)

    rows = [[g["id"], g["enemyHp"], g["turnLimit"], g["enemyAttack"]] + [curves[g["id"]][m] for m in METRICS]
            for g in cfg["grid"]]
    n = ashlib.write_csv(os.path.join(OUT, "play12_select.csv"),
                         ["config", "enemyHp", "turnLimit", "enemyAttack", *METRICS], rows)
    size = draw(curves, ids, selected, disc, os.path.join(OUT, "play12_select.svg"))
    report = {
        "id": "gd_play_12_select", "seedsPerConfig": len(seeds), "configs": ids,
        "metrics": list(METRICS), "curves": curves, "discrimination": disc,
        "concordance": conc, "selectionOrder": order, "selected": selected, "rejected": rejected,
        "thresholds": sel_cfg, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000,
    }
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play12_select.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}{'区分度':>7}   " + " ".join(f"{c:>5}" for c in ids))
    for m in METRICS:
        print(f"{LABEL[m]:<14}{disc[m]:>7}   " + " ".join(f"{curves[c][m]:>5}" for c in ids))
    print(f"选取顺序(按区分度降序)={order}")
    print(f"入选={selected}")
    for r in rejected:
        if r["reason"] == "redundant":
            print(f"  剔除 {r['metric']}:与 {r['redundantWith']} 同序 {r['concordance']}‰"
                  f"(阈值 {sel_cfg['redundancyThresholdPermille']}‰)")
        else:
            print(f"  剔除 {r['metric']}:区分度 {r['discrimination']}‰ 低于阈值")
    print(f"csv={n} rows  svg={size} bytes  elapsed={report['elapsedMs']} ms")
    print("wrote out/play12_select.csv out/play12_select.svg out/play12_select.json")
    ET.parse(os.path.join(OUT, "play12_select.svg"))
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_12_select.py(elapsed 是墙钟采样,每次运行都不同,下同):

指标                区分度      c0    c1    c2    c3    c4    c5    c6    c7    c8    c9   c10   c11
可解率              1000    1000  1000  1000  1000  1000  1000   750  1000  1000  1000     0     0
策略胜率熵            1000    1000  1000  1000  1000   973   644   420  1000   973   644     0     0
胜率带宽             1000       0     0     0     0   563  1000   750     0   563  1000     0     0
支配缺失度             667    1000  1000  1000  1000   333   333   500  1000   333   333  1000  1000
可用策略占比           1000    1000  1000  1000  1000   666   333   166  1000   666   333     0     0
互有胜负对占比           133       0     0     0     0   133    66     0     0   133    66     0     0
平均回合                8       3     4     4     4     4     4     3     4     4     4    11    11
输出完成度             158    1000  1000  1000  1000   971   900   842  1000   971   900   975   866
选取顺序(按区分度降序)=['band', 'entropy', 'solvability', 'viable', 'domFree', 'dealtFrac', 'mutual', 'turnMean']
入选=['band', 'entropy', 'domFree']
  剔除 solvability:与 entropy 同序 1000‰(阈值 800‰)
  剔除 viable:与 entropy 同序 1000‰(阈值 800‰)
  剔除 dealtFrac:与 entropy 同序 886‰(阈值 800‰)
  剔除 mutual:与 band 同序 833‰(阈值 800‰)
  剔除 turnMean:区分度 8‰ 低于阈值
csv=12 rows  svg=2885 bytes  elapsed=171 ms
wrote out/play12_select.csv out/play12_select.svg out/play12_select.json

读表:八进三

入选三条:band(胜率带宽)、entropy(策略胜率熵)、domFree(支配缺失度)。三者的跨配置曲线形状互不相同——band 在 c4(enemyAttack=14)就开始抬、到 c5 封顶;entropy 是从 c4 起持续下滑的单调曲线;domFree 则在两端配置取满、中间配置掉到 333‰。三条各说一件事:分布有多宽、分布有多匀、有没有谁被压死。

淘汰五条,成因分三类,必须分开读:

类别条目成因
与入选者同序solvability(1000‰)、viable(1000‰)在这张网格上,可解率与可用策略占比的排序和 entropy 完全一致——它们量的是同一件事
近似同序dealtFrac(886‰)、mutual(833‰)高于 800‰ 阈值但未到 1000‰,属于「几乎重复、偶尔分岔」
分不开配置turnMean(区分度 8‰)平均回合在 12 个配置里几乎不变——它在这个机制、这张网格上没有分辨力

turnMean 这一类最容易被忽略:它是机制量、可算、有物理意义,但 12 个配置的读数只在 3 到 4 之间晃(唯一例外是 c10/c11 的 11)。这不是说「平均回合」是坏指标,而是说它的分辨力出现在别的参数方向上——本节的网格把难度加在敌人血量和攻击上,没有加在回合预算上,于是它对回合数不敏感。指标的可用性依赖网格,这一点和 12.3 指标的置信区间 里「区间依赖样本」是同一类限制。

判据与阈值都要入档

检查项合格标准
候选可算每个候选都能从胜率矩阵直接数出来,不引入新数据源
判据全整数区分度与冗余度都是千分比整数,无浮点;阈值写进 config,改口径只改配置
代理指标声明每个指标声明为 proxy,只做相对比较、禁绝对化
网格入档配置网格写进 config;「分不开」是相对这张网格说的,不是绝对结论

筛选做完,手里有一组不冗余的指标——但它们还只是原始读数。从仿真快照的一个字段到一条能进决策的指标,中间还有一段管线要走:聚合、归一化、减基线。下一节把这段管线拆开。

阅读导航:上一节:11.3 天花板与瓶颈 · 下一节:12.2 从机制仿真到体验指标 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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