11.2 习得曲线与难度曲线

难度不该是固定的一条线,而该跟着技巧走。本节把 11.1 的习得曲线反过来用:在难度轴上逐格反推「需要多少熟练度才能把胜率做到 500‰」,得到一条必需能力曲线,再比较固定难度与按习得反推两种排布,用落在目标带内的关卡占比当作对齐度;B 层换四组种子池与三个目标带,看对齐度这个代理指标到底能引用到什么程度。

11.2 习得曲线与难度曲线

《核心循环与手感设计》 用 Unity csharp 片段讲的是手感参数——那是一条与玩家能力无关的曲线;《新手引导设计清单》 讲的是教什么、何时教,但它不回答「教完之后关卡该有多难」。本节把 11.1 的习得曲线反过来用:既然知道了第 t 个 trial 玩家有多少熟练度,就能问「这一关需要多少熟练度」。两条曲线——玩家能力曲线与关卡必需能力曲线——是否贴合,就是难度曲线设计要解的问题。11.3 接着找这条能力曲线的天花板。

难度排布有两种极端:固定难度(所有关卡同一个 enemyAttack)和跟着技巧走(越往后越难)。前者的问题是玩家一学会就一路碾压,后者的问题是「跟着走」到底该走多快没有依据。本节的做法是给「跟着走」一个可计算的定义:先在难度轴上逐格反推每一格需要多少熟练度才能把胜率做到 500‰,得到 required(v);第 t 关就取满足 required(v) ≤ q_t 的最大 v。这个「取最大可达难度」的规则只用整数比较,不需要任何调参手感。

S 层:反推每一关需要多少熟练度

sim/gd_play_11_alignment.py 分三步:算 q_t、在 enemyAttack ∈ [6,24] 上以步长 25 扫出 required(v)(扫不到就是 None,说明这一格再练也做不成 500‰)、两种排布各走一遍并统计对齐度。对齐度 = 落在目标带 [400,700]‰ 内的关卡占比(千分比)——这是一个代理指标,只用来比较两种排布谁更贴,不代表「好玩」。

#!/usr/bin/env python3
# file: sim/gd_play_11_alignment.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_11_alignment.py
"""卷二 11.2 习得曲线与难度曲线(S 层):把「难度跟着技巧走」写成一个可算的反推。

三步:(1)用 11.1 的指数逼近模型算出第 t 关的熟练度 q_t;(2)在难度轴(enemyAttack)上
逐格反推出每一格**需要多少熟练度**才能把胜率做到 500‰(required(v),扫不到即 None);
(3)两种排布各走一遍——固定难度 enemyAttack=14,与「按习得反推」:第 t 关取满足
required(v) ≤ q_t 的**最大** v。对齐度 = 落在目标带 [400,700]‰ 内的关卡占比(千分比)。
产出 out/play11_alignment.json + .svg。胜率是代理指标(proxy),只做相对比较。
全程整数、无浮点、无 random。
"""
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_11_alignment.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
import gd_play_11_policy as P  # noqa: E402


def curve_exponential(q0, qmax, rate, n):
    qs = [q0]
    for _ in range(n - 1):
        qs.append(min(qmax, qs[-1] + (qmax - qs[-1]) * rate // 1000))
    return qs


def draw(stages, fixed_w, matched_w, lo, hi, path):
    x0, y0, cw, ph = 92, 46, 62, 200
    root = ashlib.svg_begin(780, 360)
    ashlib.svg_text(root, 16, 26, "习得曲线 vs 难度排布:实测胜率(固定 vs 按习得反推)", 15)
    yb0, yb1 = y0 + ph - hi * ph // 1000, y0 + ph - lo * ph // 1000
    ET.SubElement(root, "rect", {"x": str(x0), "y": str(yb0), "width": str(cw * stages),
                                 "height": str(yb1 - yb0), "fill": "#164e63", "opacity": "0.5"})
    for i in range(0, 1001, 250):
        ashlib.svg_text(root, 14, y0 + ph - i * ph // 1000 + 4, f"{i}", 11)
    for i in range(stages):
        ashlib.svg_text(root, x0 + i * cw + cw // 2, y0 + ph + 16, f"t{i + 1}", 10, anchor="middle")
    for series, color in ((fixed_w, "#f59e0b"), (matched_w, "#38bdf8")):
        pts = [(x0 + i * cw + cw // 2, y0 + ph - wr * ph // 1000) for i, wr in enumerate(series)]
        for a, b in zip(pts, pts[1:]):
            ET.SubElement(root, "line", {"x1": str(a[0]), "y1": str(a[1]), "x2": str(b[0]),
                                         "y2": str(b[1]), "stroke": color, "stroke-width": "3"})
    ashlib.svg_text(root, x0, y0 + ph + 36, f"黄=固定难度 蓝=按习得反推 底带=[{lo},{hi}]‰", 11)
    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)
    base = copy.deepcopy(spec)
    base["params"].update(cfg["difficulty"]["fixed"])
    seeds = cfg["seedPool"]
    D, T, L = cfg["difficulty"], cfg["target"], cfg["learning"]
    lo, hi, target = T["bandLow"], T["bandHigh"], T["winPermille"]
    vals = list(range(D["from"], D["to"] + 1, D["step"]))
    qs = curve_exponential(L["startQ"], L["qMax"], L["rateExponential"], L["stages"])

    def win(v, q):
        s = copy.deepcopy(base)
        s["params"][D["param"]] = v
        return P.win_permille(s, seeds, P.uniform(q))

    step = cfg["requiredStep"]
    required = {}
    for v in vals:
        q = 0
        while q <= L["qMax"] and win(v, q) < target:
            q += step
        required[v] = q if q <= L["qMax"] else None
    fixed_w = [win(cfg["schedule"]["fixedValue"], q) for q in qs]
    matched_v, matched_w = [], []
    for q in qs:
        ok = [v for v in vals if required[v] is not None and required[v] <= q]
        v = max(ok) if ok else vals[0]
        matched_v.append(v)
        matched_w.append(win(v, q))
    af = 1000 * sum(1 for x in fixed_w if lo <= x <= hi) // len(qs)
    am = 1000 * sum(1 for x in matched_w if lo <= x <= hi) // len(qs)
    elapsed = (time.perf_counter_ns() - t0) // 1000000
    report = {"id": "gd_play_11_alignment", "difficultyAxis": D, "band": [lo, hi],
              "targetPermille": target, "stages": len(qs), "qCurve": qs,
              "requiredCompetence": {str(v): required[v] for v in vals},
              "fixedScheduleValue": cfg["schedule"]["fixedValue"], "fixedWin": fixed_w,
              "matchedDifficulty": matched_v, "matchedWin": matched_w,
              "alignmentFixed": af, "alignmentMatched": am, "elapsedMs": elapsed}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play11_alignment.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    size = draw(len(qs), fixed_w, matched_w, lo, hi, os.path.join(OUT, "play11_alignment.svg"))

    print(f"难度轴 {D['param']} {D['from']}..{D['to']}  目标 {target}‰  带 [{lo},{hi}]‰")
    print("requiredCompetence " + " ".join(f"{v}:{required[v]}" for v in vals))
    print(f"{'t':>3} {'q':>5} {'固定难度':>8} {'反推难度':>8} {'胜率(反推)':>10}")
    for i, q in enumerate(qs):
        print(f"{i + 1:>3} {q:>5} {fixed_w[i]:>7}‰ {matched_v[i]:>8} {matched_w[i]:>9}‰")
    print(f"alignmentFixed={af}‰  alignmentMatched={am}‰  stages={len(qs)}")
    print(f"elapsed={elapsed} ms  svg={size} bytes")
    print("wrote out/play11_alignment.json out/play11_alignment.svg")
    ET.parse(os.path.join(OUT, "play11_alignment.svg"))
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_11_alignment.py(耗时随机器浮动,绝对数值不可移植):

难度轴 enemyAttack 6..24  目标 500‰  带 [400,700]‰
requiredCompetence 6:0 7:75 8:100 9:175 10:275 11:275 12:525 13:525 14:750 15:675 16:675 17:725 18:None 19:None 20:None 21:None 22:None 23:None 24:None
  t     q     固定难度     反推难度     胜率(反推)
  1   100       0‰        8       562‰
  2   370      62‰       11       656‰
  3   559     265‰       13       609‰
  4   691     453‰       16       500‰
  5   783     578‰       17       593‰
  6   848     718‰       17       703‰
  7   893     843‰       17       812‰
  8   925     921‰       17       875‰
  9   947     921‰       17       921‰
 10   962     953‰       17       953‰
alignmentFixed=200‰  alignmentMatched=500‰  stages=10
elapsed=576 ms  svg=3891 bytes
wrote out/play11_alignment.json out/play11_alignment.svg

两个读数合起来才说明问题。固定难度排布的对齐度只有 200‰:第 12 关(0‰、62‰)玩家还没学会,太简单的地方反而打不过;第 6 关之后(718‰ 起)一路碾压,两头的关卡都不在带内,10 关里只有 2 关落在 [400,700]。反推排布的对齐度是 500‰:第 15 关被拉到恰好可玩的位置,前段的「打不过」被消掉了。但后半段仍然溢出——required(v) 在 enemyAttack=18 之后全是 None,说明在 64 个种子、500‰ 的口径下,再高的难度也拉不住一个练满的玩家(第 710 关胜率 812953‰)。这条「难度轴到 17 就到顶」的发现,正是 11.3 要展开的天花板问题。

B 层:对齐度这个数能引用到什么程度

alignmentMatched=500‰ 看着像个可以写进文档的结论,但它同时依赖种子池(口径)和目标带(假设)。sim/gd_play_11_alignment_robust.py 换四组种子池各重跑一遍反推,再把目标带从 [300,600] 移到 [450,750],看对齐度怎么动。漂移超阈值只打印软告警、进程始终 exit 0。

#!/usr/bin/env python3
# file: sim/gd_play_11_alignment_robust.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_11_alignment_robust.py
"""卷二 11.2 习得曲线与难度曲线(B 层):对齐度对**种子池**与**目标带**的稳健性。

换四组不同的种子池各跑一遍「按习得反推难度」的排布,看对齐度会不会挪;再把目标带从
[300,600] 换到 [450,750],看结论对带宽假设有多敏感。漂移超阈值只**打印软告警**,进程
始终 exit 0。产出 out/play11_alignment_robust.json。全整数、无浮点、无 random。
"""
import copy
import json
import os
import sys
import time

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_11_alignment.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
import gd_play_11_policy as P  # noqa: E402


def curve_exponential(q0, qmax, rate, n):
    qs = [q0]
    for _ in range(n - 1):
        qs.append(min(qmax, qs[-1] + (qmax - qs[-1]) * rate // 1000))
    return qs


def run_pool(base, param, vals, seeds, qs, target, step, qmax, fixed_value):
    def win(v, q):
        s = copy.deepcopy(base)
        s["params"][param] = v
        return P.win_permille(s, seeds, P.uniform(q))

    required = {}
    for v in vals:
        q = 0
        while q <= qmax and win(v, q) < target:
            q += step
        required[v] = q if q <= qmax else None
    fixed_w = [win(fixed_value, q) for q in qs]
    matched_w = []
    for q in qs:
        ok = [v for v in vals if required[v] is not None and required[v] <= q]
        matched_w.append(win(max(ok) if ok else vals[0], q))
    return required, fixed_w, matched_w


def align(series, lo, hi):
    return 1000 * sum(1 for x in series if lo <= x <= hi) // len(series)


def main():
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    base = copy.deepcopy(spec)
    base["params"].update(cfg["difficulty"]["fixed"])
    D, T, L, rob = cfg["difficulty"], cfg["target"], cfg["learning"], cfg["robustness"]
    vals = list(range(D["from"], D["to"] + 1, D["step"]))
    qs = curve_exponential(L["startQ"], L["qMax"], L["rateExponential"], L["stages"])
    pool = cfg["seedPool"]
    pools = [("base", pool)] + [(f"+{o}", [s + o for s in pool]) for o in rob["altPoolOffsets"]]

    rows, curves = [], {}
    for lab, seeds in pools:
        req, fw, mw = run_pool(base, D["param"], vals, seeds, qs, T["winPermille"],
                               cfg["requiredStep"], L["qMax"], cfg["schedule"]["fixedValue"])
        reach = [v for v in vals if req[v] is not None]
        rows.append({"pool": lab, "alignmentFixed": align(fw, T["bandLow"], T["bandHigh"]),
                     "alignmentMatched": align(mw, T["bandLow"], T["bandHigh"]),
                     "maxReachable": max(reach) if reach else None})
        curves[lab] = (fw, mw)
    fw, mw = curves["base"]
    band_rows = [{"band": [lo, hi], "alignmentFixed": align(fw, lo, hi),
                  "alignmentMatched": align(mw, lo, hi)} for lo, hi in rob["bandCandidates"]]

    fv = [r["alignmentFixed"] for r in rows]
    mv = [r["alignmentMatched"] for r in rows]
    drift = max(max(fv) - min(fv), max(mv) - min(mv))
    spread = max(r["alignmentMatched"] for r in band_rows) - min(r["alignmentMatched"] for r in band_rows)
    warnings = []
    if drift > rob["alignmentDriftWarnPermille"]:
        warnings.append(f"对齐度跨种子池漂移 {drift}‰(阈值 {rob['alignmentDriftWarnPermille']}‰):"
                        f"「对齐度 = N‰」是口径相关读数")
    if spread >= 200:
        warnings.append(f"反推排布的对齐度随目标带在 {spread}‰ 范围内移动:带宽是**假设**,"
                        f"报绝对对齐度会误导")
    report = {"id": "gd_play_11_alignment_robust", "poolRows": rows, "bandRows": band_rows,
              "alignmentDrift": drift, "warnings": warnings,
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play11_alignment_robust.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    for r in rows:
        print(f"pool={r['pool']:<6} alignFixed={r['alignmentFixed']:>4}‰  "
              f"alignMatched={r['alignmentMatched']:>4}‰  可达最大难度={r['maxReachable']}")
    print(f"alignmentDrift={drift}‰")
    for r in band_rows:
        print(f"band={r['band']}  alignFixed={r['alignmentFixed']:>4}‰  alignMatched={r['alignmentMatched']:>4}‰")
    print(f"warnings={len(warnings)}")
    for wmsg in warnings:
        print(f"  [WARN] {wmsg}")
    print(f"elapsed={report['elapsedMs']} ms  exit=0(软告警不硬失败)")
    print("wrote out/play11_alignment_robust.json")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
pool=base   alignFixed= 200‰  alignMatched= 500‰  可达最大难度=17
pool=+100   alignFixed= 200‰  alignMatched= 400‰  可达最大难度=17
pool=+200   alignFixed= 200‰  alignMatched= 400‰  可达最大难度=17
pool=+400   alignFixed= 300‰  alignMatched= 400‰  可达最大难度=17
alignmentDrift=100‰
band=[300, 600]  alignFixed= 200‰  alignMatched= 300‰
band=[400, 700]  alignFixed= 200‰  alignMatched= 500‰
band=[450, 750]  alignFixed= 300‰  alignMatched= 600‰
warnings=1
  [WARN] 反推排布的对齐度随目标带在 300‰ 范围内移动:带宽是**假设**,报绝对对齐度会误导
elapsed=2337 ms  exit=0(软告警不硬失败)
wrote out/play11_alignment_robust.json

三个读数是同一件事的三面:排序稳定——反推排布在四组池子里都比固定排布高(400500‰ vs 200300‰),这条相对结论跨口径成立;绝对值不稳——alignMatched 在 400500‰ 之间晃,alignmentDrift=100‰ 已经等于阈值;对假设敏感——目标带一换,alignMatched 在 300600‰ 之间移动([300,600] 那行只有 300‰,因为反推排布把胜率整体抬高了,偏低的带反而漏掉;[450,750] 那行有 600‰,反而更贴)。所以正确的说法是「按习得反推比固定难度更贴」,而不是「对齐度达到 500‰」。换口径就换数,只有排序能跨口径引用——这是本节最该记住的一句。

自检表

检查项合格标准
反推可算required(v) 由「扫熟练度找首个达标点」得到,步长 25,全程整数
两排布同口径固定与反推用同一 base 规格、同一种子池、同一目标 500‰
代理指标声明对齐度写明是 proxy,只比较两种排布,不绝对化
难度到顶可见enemyAttack ≥ 18 全 None,说明难度轴存在上限
稳健性分开说区分「排序稳定」(跨池成立)与「绝对值不稳」(依赖带假设)
无浮点无随机扫描与统计只用整数除法,种子全部来自配置

反推能告诉我们对齐度更高,但它管不了「难度轴到 17 就到顶」这件事——后半段无论怎么调都拦不住练满的玩家。下一节用留一法给三个候选技巧维度排名,并找出哪些维度的边际增益会变号。

阅读导航:上一节:11.1 技巧习得的建模 · 下一节:11.3 天花板与瓶颈 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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