12.2 可玩性指标与测试方法

可玩性指标怎么测、测出来的数字有多大把握:用 S 层脚本对首局完成率、单局时长、重玩率做整数递推采样并配 Agresti–Coull 置信区间,再用 B 层脚本看半宽随样本量如何按 1/sqrt(n) 收缩,最后讲清模拟样本与真实测试的差距。

站内的 PRD 系列 会把「验收标准」写成一句功能描述——那是文档口径的约定;本章要的是可跑、可调参、可断言、可浏览器打开的工程口径:验收标准必须落成能算出来的数字,还要带「这个数字有多可信」。上一节我们把「测什么」用范围门禁钉死了:首局完成率、单局时长、重玩率、回放死锁。这一节解决「怎么测」。

为什么占比指标必须带置信区间

先看一个反面例子。你找 20 个朋友试玩,14 个人完成了第一局——完成率 70%。这个数字能不能下结论说「超过 50% 的目标线」?如果换一批 20 人再测,可能变成 11 个人(55%),也可能变成 17 个人(85%)。20 人的样本,一个百分点的波动都可能是随机的。

所以任何占比指标都不能只报一个点估计,必须报一个区间。区间告诉你的不是「结果是多少」,而是「结果的不确定性有多大」:样本越少,区间越宽,你越不该拿它做决定。这在 12.3 的迭代决策里是刚需——一个 70% 的点估计如果区间横跨 50% 的目标线,那这一版到底算不算过,根本判不了。

本节用的区间是 Agresti–Coull 区间(对标准 Wald 区间的小样本修正),取 z=2 近似 95% 置信。选它有两个理由:一是小样本表现比朴素公式稳,二是它可以纯整数实现——这正是本套「config 禁浮点、跨语言逐位一致」的硬要求。区间公式里的平方根用 math.isqrt(整数平方根),全程不碰浮点。

S 层:模拟采样与区间

下面这段是 S 层脚本,对 sampleSize 个模拟测试者逐人推进一条整数递推。配置在 config/ch12_playability.json:每个测试者先掷一次「是否完成首局」(state % 1000 < firstRunCompletePerMille),再进入「是否再来一局」的循环,每续一局单局时长按整数比 durGrowthNum/durGrowthDen 递增(round_sec * 106 // 100,整数地板除)。三个占比/时长指标算完后,用 Agresti–Coull 区间给占比配 95% 置信区间,产出 JSON + CSV + SVG。

# file: sim/ch12_playability.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch12_playability.py --config config/ --out out/
"""可玩性指标的模拟采样与置信区间(全整数)。

对 sampleSize 个模拟测试者逐人推进一条整数递推:
  完成首局?  state % 1000 < firstRunCompletePerMille
  再来一局?  state % 1000 < continuePerMille(否则本会话结束)
  单局时长    round_sec = round_sec * durGrowthNum // durGrowthDen(每轮递增,整数地板除)

占比用 Agresti–Coull 区间(z=2,全整数、math.isqrt)。真实测试数据不可得:这是**显式标注的模拟样本**。
产出 out/ch12_playability.json + .csv + .svg。
"""

from __future__ import annotations

import argparse
import csv
import json
import math
import os
import xml.etree.ElementTree as ET


def load(cfg_dir):
    with open(os.path.join(cfg_dir, "ch12_playability.json"), encoding="utf-8") as f:
        return json.load(f)


def lcg(s, mul, inc):
    return (s * mul + inc) & 0xFFFFFFFF


def ac(k, n, z, scale=100000):
    """Agresti–Coull 区间 (lo, center, hi),以 scale 为单位,全整数。"""
    na = n + z * z
    c = (k + z * z // 2) * scale // na
    half = z * math.isqrt(c * (scale - c) // na)
    return c - half, c, c + half


def simulate(cfg):
    mul, inc = cfg["rngMultiplier"], cfg["rngIncrement"]
    n = cfg["sampleSize"]
    s = cfg["seed"] & 0xFFFFFFFF
    done = replay = total = 0
    secs = []
    for _ in range(n):
        s = lcg(s, mul, inc)
        if s % 1000 < cfg["firstRunCompletePerMille"]:
            done += 1
        rounds, rsec = 1, cfg["baseRoundSec"]
        sec = rsec
        while rounds < cfg["maxRounds"]:
            s = lcg(s, mul, inc)
            if s % 1000 >= cfg["continuePerMille"]:
                break
            rounds += 1
            rsec = rsec * cfg["durGrowthNum"] // cfg["durGrowthDen"]
            sec += rsec
        if rounds >= cfg["minReplayRounds"]:
            replay += 1
        total += sec
        secs.append(sec)
    z = cfg["zScore"]
    mean = total // n
    var = sum((x - mean) ** 2 for x in secs) // n
    lo, cf, hi = ac(done, n, z)
    lr, cr, hr = ac(replay, n, z)
    return {"n": n, "completed": done, "replay": replay, "state": s,
            "first": cf * 1000 // 100000, "firstLo": lo * 1000 // 100000, "firstHi": hi * 1000 // 100000,
            "meanSec": mean, "secHalf": z * math.isqrt(var // n),
            "replayPm": cr * 1000 // 100000, "replayLo": lr * 1000 // 100000, "replayHi": hr * 1000 // 100000}


def draw(path, m, target):
    W, H = 720, 200
    x0, x1 = 60, W - 40
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H),
                             "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = \
        f"首局完成率(千分)· 模拟样本 n={m['n']}"
    ax = 120
    sx = lambda v: x0 + (x1 - x0) * v // 1000
    ET.SubElement(svg, "line", {"x1": str(x0), "y1": str(ax), "x2": str(x1), "y2": str(ax), "stroke": "#9ca3af"})
    for t in range(0, 1001, 250):
        ET.SubElement(svg, "text", {"x": str(sx(t)), "y": str(ax + 20), "font-size": "11",
                                    "text-anchor": "middle", "fill": "#6b7280"}).text = str(t)
    ET.SubElement(svg, "line", {"x1": str(sx(target)), "y1": "46", "x2": str(sx(target)), "y2": str(ax),
                                "stroke": "#dc2626", "stroke-dasharray": "5 4"})
    ET.SubElement(svg, "text", {"x": str(sx(target) + 6), "y": "58", "font-size": "12", "fill": "#dc2626"}).text = \
        f"目标 {target}"
    ET.SubElement(svg, "rect", {"x": str(sx(m["firstLo"])), "y": "86",
                                "width": str(max(1, sx(m["firstHi"]) - sx(m["firstLo"]))), "height": "18",
                                "fill": "#bfdbfe", "rx": "4"})
    ET.SubElement(svg, "circle", {"cx": str(sx(m["first"])), "cy": "95", "r": "6", "fill": "#2563eb"})
    ET.SubElement(svg, "text", {"x": "60", "y": "180", "font-size": "12", "fill": "#374151"}).text = \
        f"点估计 {m['first']} 千分 · 95% 区间 [{m['firstLo']}, {m['firstHi']}] · 单局均值 {m['meanSec']}s ± {m['secHalf']}s"
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg = load(a.config)
    m = simulate(cfg)
    t = cfg["targets"]
    rows = [
        {"metric": "firstRunCompletionPerMille", "value": m["first"], "ciLo": m["firstLo"], "ciHi": m["firstHi"],
         "target": t["firstRunCompletionPerMille"], "unit": "per-mille",
         "pass": m["first"] >= t["firstRunCompletionPerMille"]},
        {"metric": "meanSessionSec", "value": m["meanSec"], "ciLo": m["meanSec"] - m["secHalf"],
         "ciHi": m["meanSec"] + m["secHalf"], "target": t["meanSessionSec"], "unit": "second",
         "pass": 90 <= m["meanSec"] <= 240},
        {"metric": "replayPerMille", "value": m["replayPm"], "ciLo": m["replayLo"], "ciHi": m["replayHi"],
         "target": t["replayPerMille"], "unit": "per-mille", "pass": m["replayPm"] >= t["replayPerMille"]},
        {"metric": "softlockCount", "value": 0, "ciLo": 0, "ciHi": 0, "target": t["softlockCount"],
         "unit": "count", "pass": True},
    ]
    os.makedirs(a.out, exist_ok=True)
    with open(os.path.join(a.out, "ch12_playability.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        w.writeheader()
        w.writerows(rows)
    svg_bytes = draw(os.path.join(a.out, "ch12_playability.svg"), m, t["firstRunCompletionPerMille"])
    report = {"version": cfg["version"], "simulatedSample": True,
              "disclaimer": "模拟样本,非真实用户数据;需真实用户测试,命令行不可复现",
              "seed": cfg["seed"], "sampleSize": m["n"], "rngState": m["state"],
              "metrics": {r["metric"]: r["value"] for r in rows},
              "intervals": {r["metric"]: [r["ciLo"], r["ciHi"]] for r in rows},
              "passCount": sum(1 for r in rows if r["pass"]), "total": len(rows),
              "svg": os.path.join(a.out, "ch12_playability.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(a.out, "ch12_playability.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps({"sampleSize": m["n"], "firstRunCompletionPerMille": m["first"],
                      "firstRunCi": [m["firstLo"], m["firstHi"]], "meanSessionSec": m["meanSec"],
                      "sessionCiHalf": m["secHalf"], "replayPerMille": m["replayPm"],
                      "replayCi": [m["replayLo"], m["replayHi"]], "passCount": report["passCount"]},
                     ensure_ascii=False))
    return 0


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

跑 /opt/homebrew/bin/python3 sim/ch12_playability.py --config config/ --out out/,输出(同机相对量级,绝对数值不可移植):

{"sampleSize": 400, "firstRunCompletionPerMille": 616, "firstRunCi": [567, 664], "meanSessionSec": 113, "sessionCiHalf": 8, "replayPerMille": 542, "replayCi": [492, 591], "passCount": 4}

三项占比/时长的模拟结果:首局完成率 616 千分(95% 区间 [567, 664])、单局均值 113 秒(±8 秒)、重玩率 542 千分(区间 [492, 591])。四点结论。

第一,区间下界才是决策依据。首局完成率的点估计 616 千分远高于 500 的目标线,但真正该问的是「区间的下界 567 是否也在目标线之上」——这里 567 > 500,说明即便按最保守的估计这一版也过了。如果下界掉到 500 以下,那这个「过」就是统计噪声,不能采信。

第二,单局时长 113 秒落在 [90, 240] 的心流区间内,但它离下界 90 只差 23 秒,±8 秒的波动还够不到下界,属于「安全但不高」的区间。这提示节奏偏快,是下一版可以考虑放大的方向。

第三,重玩率 542 千分说明超过一半的测试者至少重开了一局,这比完成率更能反映「循环有没有吸引力」。

第四,softlockCount 为 0——它不由本脚本模拟,而由 12.3 的确定性回放断言覆盖,这里只占位对齐目标表。

B 层:样本量敏感性

上面的区间有多宽,取决于样本量。下面这段是 B 层基准脚本,把样本量从 50 拉到 3200,看首局完成率的区间半宽怎么变。它复用 S 层的 simulate 与 ac,不重复实现。B 层只做软告警:半宽超过 80 千分(8 个百分点)只打 WARN,不返回非 0。

# file: sim/ch12_sample_sensitivity.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch12_sample_sensitivity.py --config config/ --out out/
"""B 基准:样本量敏感性。半宽随 n 大致按 1/sqrt(n) 收缩;超容忍只软告警,不返回非 0。"""

from __future__ import annotations

import argparse
import json
import os
import xml.etree.ElementTree as ET

from ch12_playability import ac, load, simulate

TOL = 80  # 半宽超过 80 千分(8 个百分点)软告警
GRID = [50, 100, 200, 400, 800, 1600, 3200]


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    base = load(a.config)
    z = base["zScore"]
    rows = []
    for n in GRID:
        cfg = dict(base)
        cfg["sampleSize"] = n
        m = simulate(cfg)
        lo, _, hi = ac(m["completed"], n, z)
        rows.append({"n": n, "completed": m["completed"], "pointPerMille": m["first"],
                     "halfWidthPerMille": (hi - lo) * 1000 // 100000 // 2})
    chosen = base["sampleSize"]
    ch = next(r["halfWidthPerMille"] for r in rows if r["n"] == chosen)
    W, H = 720, 280
    x0, x1, y0, y1 = 64, W - 40, H - 44, 48
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H),
                             "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = \
        "样本量 → 首局完成率 95% 区间半宽(千分)"
    mx = max(r["halfWidthPerMille"] for r in rows) or 1
    sx = lambda n: x0 + (x1 - x0) * (n - GRID[0]) // (GRID[-1] - GRID[0])
    sy = lambda h: y1 + (y0 - y1) * (1 - h / mx)
    ET.SubElement(svg, "line", {"x1": str(x0), "y1": str(y1), "x2": str(x1), "y2": str(y1), "stroke": "#9ca3af"})
    ET.SubElement(svg, "line", {"x1": str(x0), "y1": str(y0), "x2": str(x0), "y2": str(y1), "stroke": "#9ca3af"})
    ET.SubElement(svg, "polyline", {"points": " ".join(f"{sx(r['n']):.1f},{sy(r['halfWidthPerMille']):.1f}" for r in rows),
                                    "fill": "none", "stroke": "#2563eb", "stroke-width": "2"})
    for r in rows:
        ET.SubElement(svg, "circle", {"cx": f"{sx(r['n']):.1f}", "cy": f"{sy(r['halfWidthPerMille']):.1f}", "r": "4",
                                      "fill": "#2563eb"})
        ET.SubElement(svg, "text", {"x": f"{sx(r['n']):.1f}", "y": str(y1 + 18), "font-size": "10",
                                    "text-anchor": "middle", "fill": "#6b7280"}).text = str(r["n"])
    ET.SubElement(svg, "line", {"x1": str(x0), "y1": str(sy(TOL)), "x2": str(x1), "y2": str(sy(TOL)),
                                "stroke": "#dc2626", "stroke-dasharray": "5 4"})
    os.makedirs(a.out, exist_ok=True)
    ET.ElementTree(svg).write(os.path.join(a.out, "ch12_sample_sensitivity.svg"),
                              encoding="utf-8", xml_declaration=True)
    report = {"version": base["version"], "chosenSampleSize": chosen, "chosenHalfWidthPerMille": ch,
              "tolerancePerMille": TOL, "withinTolerance": ch <= TOL, "grid": rows}
    with open(os.path.join(a.out, "ch12_sample_sensitivity.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps({"chosenSampleSize": chosen, "chosenHalfWidthPerMille": ch, "tolerance": TOL,
                      "withinTolerance": report["withinTolerance"],
                      "grid": {r["n"]: r["halfWidthPerMille"] for r in rows}}, ensure_ascii=False))
    if not report["withinTolerance"]:
        print(f"WARN 样本量 {chosen} 处半宽 {ch} > {TOL} 千分")
    return 0  # B 层软告警:恒返回 0


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

跑 /opt/homebrew/bin/python3 sim/ch12_sample_sensitivity.py --config config/ --out out/,输出(同机相对量级,绝对数值不可移植):

{"chosenSampleSize": 400, "chosenHalfWidthPerMille": 48, "tolerance": 80, "withinTolerance": true, "grid": {"50": 133, "100": 91, "200": 67, "400": 48, "800": 34, "1600": 24, "3200": 17}}

半宽随样本量的序列是 50→133、100→91、200→67、400→48、800→34、1600→24、3200→17(千分)。看两个特征:一是单调收缩,样本越大区间越窄;二是收缩变慢,从 50 到 100(翻倍)半宽从 133 掉到 91,但从 1600 到 3200(同样翻倍)只从 24 掉到 17——这是 1/sqrt(n) 的直接后果:样本量翻四倍,区间才减半。本套配置选的 400 人对应半宽 48 千分,低于 80 的容忍线,所以 withinTolerance 为 true,不告警。

这条基准的实际用途是反推样本量。如果你要求半宽不超过 40 千分,从序列看 400 不够(48),得往 800(34)靠——也就是把试玩人数从 400 提到 800 左右。它把「要招多少人」从一个拍脑袋的数字,变成一个能算出来的数字。

模拟样本不等于真实数据(X)

必须说清楚:以上所有数字都来自本机模拟样本,不是真实用户。脚本用可播种 LCG 造出的「测试者」行为是配置里写死的概率,它只能验证统计方法本身(区间怎么算、半宽怎么随样本量变),不能告诉你《合成小岛》的真实完成率是多少。真实的可玩性测试需真实用户测试,命令行不可复现(X)——那需要在真机上招募测试者、埋点采集、剔除异常样本,本节的脚本只提供「拿到数据后该怎么算」的工具链。

把「怎么测」固定下来之后,最后一节要回答的是:测出来的数字,怎么变成「继续 / 调整 / 砍掉」的决策。12.2 给的是带区间的观测,12.3 给的是把这些观测翻译成决策的确定性内核。

阅读导航:上一节:原型范围与验证目标 · 下一节:原型迭代与决策 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制