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 关落在 5 关被拉到恰好可玩的位置,前段的「打不过」被消掉了。但后半段仍然溢出——[400,700]。反推排布的对齐度是 500‰:第 1required(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‰ 之间晃,600‰ 之间移动(alignmentDrift=100‰ 已经等于阈值;对假设敏感——目标带一换,alignMatched 在 300[300,600] 那行只有 300‰,因为反推排布把胜率整体抬高了,偏低的带反而漏掉;[450,750] 那行有 600‰,反而更贴)。所以正确的说法是「按习得反推比固定难度更贴」,而不是「对齐度达到 500‰」。换口径就换数,只有排序能跨口径引用——这是本节最该记住的一句。
自检表
| 检查项 | 合格标准 |
|---|---|
| 反推可算 | required(v) 由「扫熟练度找首个达标点」得到,步长 25,全程整数 |
| 两排布同口径 | 固定与反推用同一 base 规格、同一种子池、同一目标 500‰ |
| 代理指标声明 | 对齐度写明是 proxy,只比较两种排布,不绝对化 |
| 难度到顶可见 | enemyAttack ≥ 18 全 None,说明难度轴存在上限 |
| 稳健性分开说 | 区分「排序稳定」(跨池成立)与「绝对值不稳」(依赖带假设) |
| 无浮点无随机 | 扫描与统计只用整数除法,种子全部来自配置 |
反推能告诉我们对齐度更高,但它管不了「难度轴到 17 就到顶」这件事——后半段无论怎么调都拦不住练满的玩家。下一节用留一法给三个候选技巧维度排名,并找出哪些维度的边际增益会变号。
阅读导航:上一节:11.1 技巧习得的建模 · 下一节:11.3 天花板与瓶颈 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。