11.1 技巧习得的建模
《核心循环与手感设计》
是一篇约 690 行的散文,用 Unity csharp 片段讲手感参数体系——输入延迟、缓动、镜头跟随这些不可跑的参数手感;《新手引导设计清单》
给的是引导流程清单:什么时候教什么、怎么提示。两篇都停在「怎么调、教什么」。本节问一个它们都没问的问题:玩家「越练越强」这件事本身,能不能写成一个可播种、可实测的模型? 能。本章把技巧拆成一组 0..1000 的整数熟练度,用自实现的 PRNG 决定「这一次决策走不走老练做法」,于是同一份规格在不同熟练度下会打出不同的胜率——习得曲线就是「熟练度模型 → 实测胜率」这条映射。11.2 把这条曲线和难度曲线对上,11.3 找它的天花板与瓶颈。
技巧不是一个数,是一条曲线
新手和高手打同一局《灰烬牌库》,差别不在「血多血少」,而在每一个决策点上做什么选择。所以技巧不该建模成一个标量「水平」,而该建模成一组决策倾向:每次该出牌时,是「出到没能量为止」还是「打一张就收手」;血量危险时,是「先叠格挡」还是「继续抢伤害」;手里有零费牌时,是「先白嫖三点伤害」还是「无视它」。
| 候选维度 | 老练做法 | 新手做法 | 影响 |
|---|---|---|---|
thrift 出牌纪律 | 一直出到能量/手牌用尽 | 打完第一张就结束回合 | 每回合输出张数 |
defense 防御时机 | 血量 ≤ 敌人攻击×4 时叠格挡 | 完全不打格挡 | 能不能活过高压回合 |
tempo 免费牌 | 优先打零费攻击牌 | 无视零费牌 | 每回合白嫖的伤害 |
每个维度是一个 0..1000 的整数熟练度。决策时用可播种 PRNG 掷一次 0..999 的整数骰:点数小于熟练度就走老练做法,否则走新手做法。全 0 是纯新手,全 1000 是纯老练。注意「技巧」与「胜率」都是代理指标(proxy):它们只用来做相对比较(这个维度比那个维度重要、第 5 个 trial 比第 1 个 trial 强),不做任何绝对化断言——胜率依赖固定策略模型与固定种子池,换一套口径就换一个数。
把技巧写成可播种的策略模型
模型本体在 sim/gd_play_11_policy.py:它按 ashlib.run_spec 的机制逐条重打一局,只是「打几张、要不要格挡、打不打免费牌」由熟练度向量决定。下面摘出决策核心(完整文件在工作区,可 verify 命令直接跑):
# snippet: sim/gd_play_11_policy.py 的决策核心(完整文件见工作区)
DIMS = ("thrift", "defense", "tempo")
EXPERT = {"thrift": 1000, "defense": 1000, "tempo": 1000}
NOVICE = {"thrift": 0, "defense": 0, "tempo": 0}
def _roll(st: int) -> tuple:
st = ashlib.rng_next(st) # Lehmer MINSTD,无浮点
return st, st % 1000 # 一次 0..999 的整数骰
# 决策(每回合、每张牌前各掷一次)
if guards and php <= atk * 4 and def_roll < acc["defense"]:
pick = guards[0] # 老练:危险时先格挡
elif frees and tmp_roll < acc["tempo"]:
pick = frees[0] # 老练:先白嫖零费牌
else:
pick = max(attacks, key=lambda c: c["damage"] * 1000 // max(1, c["cost"]))
if thrift_roll >= acc["thrift"]: # 纪律不够:打完一张就收手
break
关键在最后一行:thrift 的骰子每回合掷一次,掷不中就提前结束回合,于是剩余能量被浪费掉。这不是「打错牌」,而是「少打牌」——一个完全由整数骰控制的、可复现的行为差异。
S 层:两种习得模型 + 实测胜率
接下来把「越练越强」写成整数递推。两种模型都只用整数、无浮点:指数逼近 q_{t+1} = q_t + (qMax − q_t) × rate ÷ 1000 前慢后快、逐步逼近上限;幂律 q_{t+1} = q_t + gain ÷ t 前快后慢、逼近得很慢(这正是练习曲线的经典形状)。每个 trial 的熟练度 q_t 三候选维度取同一个值,喂给策略模型,在固定评测场景(enemyHp=76、enemyAttack=14)与固定种子池上实测胜率千分比。
#!/usr/bin/env python3
# file: sim/gd_play_11_acquisition.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_11_acquisition.py
"""卷二 11.1 技巧习得的建模(S 层):把「越练越强」写成整数曲线并**实测**它的效果。
两种习得模型都是整数递推、无浮点:指数逼近 q_{t+1}=q_t+(qMax−q_t)×rate÷1000;幂律
q_{t+1}=q_t+gain÷t。每个 trial 的熟练度 q_t 喂给 gd_play_11_policy 的策略模型,在固定
评测场景(enemyHp=76, enemyAttack=14)与固定种子池上实测胜率千分比,得到习得曲线,再求
两条曲线跨过 500‰ 的 trial 序号。产出 out/play11_acquisition.json + .svg。胜率与熟练度
都是代理指标(proxy),只做相对比较。全程整数、无浮点、无 random。
"""
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_acquisition.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, trials):
qs = [q0]
for _ in range(trials - 1):
qs.append(min(qmax, qs[-1] + (qmax - qs[-1]) * rate // 1000))
return qs
def curve_power(q0, qmax, gain, trials):
qs = [q0]
for t in range(1, trials):
qs.append(min(qmax, qs[-1] + gain // t))
return qs
def first_cross(win, thr):
for i, w in enumerate(win):
if w >= thr:
return i + 1
return None
def draw(qs, w_exp, w_pow, path):
w, h, x0, y0, cw, ph = 760, 340, 96, 48, 52, 200
root = ashlib.svg_begin(w, h)
ashlib.svg_text(root, 16, 26, "习得曲线:熟练度模型 → 实测胜率(固定评测场景)", 15)
for i in range(0, 1001, 250):
y = y0 + ph - i * ph // 1000
ashlib.svg_text(root, 14, y + 4, f"{i}", 11)
ET.SubElement(root, "line", {"x1": str(x0 - 6), "y1": str(y), "x2": str(x0 + cw * len(qs)),
"y2": str(y), "stroke": "#1e293b", "stroke-width": "1"})
for i, q in enumerate(qs):
ashlib.svg_text(root, x0 + i * cw, y0 + ph + 16, f"t{i + 1}", 10, anchor="middle")
ET.SubElement(root, "rect", {"x": str(x0 + i * cw - 5), "y": str(y0 + ph - q * ph // 1000),
"width": "10", "height": "6", "fill": "#f59e0b"})
for series, color, sw in ((w_exp, "#38bdf8", "3"), (w_pow, "#a78bfa", "2")):
pts = [(x0 + i * cw, 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": sw})
ashlib.svg_text(root, x0, y0 + ph + 34, "蓝=指数逼近 紫=幂律 黄块=熟练度模型值", 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)
spec["params"].update(cfg["scenario"])
seeds, L = cfg["seedPool"], cfg["learning"]
thr = L["thresholdPermille"]
qs_exp = curve_exponential(L["startQ"], L["qMax"], L["rateExponential"], L["trials"])
qs_pow = curve_power(L["startQ"], L["qMax"], L["gainPower"], L["trials"])
w_exp = [P.win_permille(spec, seeds, P.uniform(q)) for q in qs_exp]
w_pow = [P.win_permille(spec, seeds, P.uniform(q)) for q in qs_pow]
cx, cp = first_cross(w_exp, thr), first_cross(w_pow, thr)
elapsed = (time.perf_counter_ns() - t0) // 1000000
report = {"id": "gd_play_11_acquisition", "scenario": cfg["scenario"], "seeds": len(seeds),
"thresholdPermille": thr, "qExponential": qs_exp, "winExponential": w_exp,
"qPower": qs_pow, "winPower": w_pow, "crossExponential": cx, "crossPower": cp,
"elapsedMs": elapsed}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play11_acquisition.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
size = draw(qs_exp, w_exp, w_pow, os.path.join(OUT, "play11_acquisition.svg"))
print(f"scenario enemyHp={cfg['scenario']['enemyHp']} enemyAttack={cfg['scenario']['enemyAttack']}"
f" seeds={len(seeds)} trials={L['trials']}")
print(f"{'t':>3} {'q_exp':>6} {'win_exp':>8} {'q_pow':>6} {'win_pow':>8}")
for i in range(L["trials"]):
print(f"{i + 1:>3} {qs_exp[i]:>6} {w_exp[i]:>7}‰ {qs_pow[i]:>6} {w_pow[i]:>7}‰")
print(f"crossExponential={cx} crossPower={cp} threshold={thr}‰")
print(f"elapsed={elapsed} ms svg={size} bytes")
print("wrote out/play11_acquisition.json out/play11_acquisition.svg")
ET.parse(os.path.join(OUT, "play11_acquisition.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_11_acquisition.py(耗时随机器浮动,绝对数值不可移植,同机相对量级可信):
scenario enemyHp=76 enemyAttack=14 seeds=64 trials=12
t q_exp win_exp q_pow win_pow
1 100 0‰ 100 0‰
2 370 62‰ 300 62‰
3 559 265‰ 400 78‰
4 691 453‰ 466 109‰
5 783 578‰ 516 187‰
6 848 718‰ 556 250‰
7 893 843‰ 589 296‰
8 925 921‰ 617 343‰
9 947 921‰ 642 390‰
10 962 953‰ 664 421‰
11 973 968‰ 684 453‰
12 981 984‰ 702 468‰
crossExponential=5 crossPower=None threshold=500‰
elapsed=42 ms svg=5529 bytes
wrote out/play11_acquisition.json out/play11_acquisition.svg
指数逼近那条曲线是标准的 S 形:trial 12 胜率还在 062‰,trial 5 跨过 500‰,trial 12 到 984‰。幂律那条则一路趴在底部——12 个 trial 后熟练度才 702,胜率 468‰,从没跨过 500‰ 阈值(crossPower=None)。这不是幂律「更差」,而是它描述的是另一种成长形状:前几个 trial 涨得快、之后几乎不动。两条曲线在 trial 1~2 重合、trial 3 之后分开,正是「前快后慢」与「前慢后快」的分水岭。同一份机制、同一组种子,只换习得曲线的形状假设,就能得到「练得会」与「练不会」两种完全相反的结论——所以形状参数必须当成显式假设写在配置里,不能藏在代码里。
B 层:达标 trial 数稳不稳
上一步的结论是「第 5 个 trial 学会」。这个数可信吗?有两个完全不同的怀疑方向,B 层分开回答:口径问题(换一组种子池,这个序号会不会挪)与模型假设问题(换一个速率 rate,这个序号会挪多少)。脚本对四组种子池(基准池 + 三个整体偏移池)与三个速率候选各扫一遍,漂移超阈值只打印软告警、进程始终 exit 0。
#!/usr/bin/env python3
# file: sim/gd_play_11_acquisition_robust.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_11_acquisition_robust.py
"""卷二 11.1 技巧习得建模(B 层):达标 trial 数对**种子池**与**速率假设**的稳健性。
两个问题分开问:(1)换四组不同的种子池,同一个指数逼近模型跨过 500‰ 的 trial 序号
会不会挪?(2)把速率 rate 从 150 换到 600,这个序号会挪多少?前者是口径问题,后者是
**模型假设**问题。任一项漂移超阈值只**打印软告警**,进程始终 exit 0。产出
out/play11_acquisition_robust.json。全整数、无浮点、无 random。
"""
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_acquisition.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, trials):
qs = [q0]
for _ in range(trials - 1):
qs.append(min(qmax, qs[-1] + (qmax - qs[-1]) * rate // 1000))
return qs
def cross(spec, seeds, qs, thr):
for i, q in enumerate(qs):
if P.win_permille(spec, seeds, P.uniform(q)) >= thr:
return i + 1
return None
def main():
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
spec["params"].update(cfg["scenario"])
L, rob = cfg["learning"], cfg["robustness"]
thr = L["thresholdPermille"]
pool, trials = cfg["seedPool"], L["trials"]
pools = [("base", pool)] + [(f"+{o}", [s + o for s in pool]) for o in rob["altPoolOffsets"]]
qs = curve_exponential(L["startQ"], L["qMax"], L["rateExponential"], trials)
pool_rows = [{"pool": lab, "cross": cross(spec, sd, qs, thr)} for lab, sd in pools]
xs = [r["cross"] for r in pool_rows if r["cross"] is not None]
drift = (max(xs) - min(xs)) if xs else None
rate_rows = []
for rate in rob["rateCandidates"]:
qr = curve_exponential(L["startQ"], L["qMax"], rate, trials)
rate_rows.append({"rate": rate, "cross": cross(spec, pool, qr, thr),
"qEnd": qr[-1]})
warnings = []
if drift is not None and drift > rob["trialsToThresholdDriftWarn"]:
warnings.append(f"达标 trial 数随种子池漂移 {drift}(阈值 {rob['trialsToThresholdDriftWarn']}):"
f"「第几个 trial 学会」是口径相关的读数")
known = [r["cross"] for r in rate_rows if r["cross"] is not None]
if known and (max(known) - min(known)) >= 3:
warnings.append(f"达标 trial 数随速率假设在 {min(known)}~{max(known)} 之间移动:"
f"习得曲线的**形状参数是假设**,不是实测")
report = {"id": "gd_play_11_acquisition_robust", "thresholdPermille": thr,
"poolRows": pool_rows, "crossDrift": drift, "rateRows": rate_rows,
"warnings": warnings, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play11_acquisition_robust.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"阈值 {thr}‰ 指数逼近模型 rate={L['rateExponential']}")
for r in pool_rows:
print(f"pool={r['pool']:<6} 达标 trial={r['cross']}")
print(f"crossDrift={drift}")
for r in rate_rows:
print(f"rate={r['rate']:>4} 达标 trial={r['cross']} 末值q={r['qEnd']}")
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_acquisition_robust.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
阈值 500‰ 指数逼近模型 rate=300
pool=base 达标 trial=5
pool=+100 达标 trial=5
pool=+200 达标 trial=5
pool=+400 达标 trial=5
crossDrift=0
rate= 150 达标 trial=9 末值q=847
rate= 300 达标 trial=5 末值q=981
rate= 600 达标 trial=3 末值q=999
warnings=1
[WARN] 达标 trial 数随速率假设在 3~9 之间移动:习得曲线的**形状参数是假设**,不是实测
elapsed=49 ms exit=0(软告警不硬失败)
wrote out/play11_acquisition_robust.json
两个读数指向完全相反的结论,合起来才是完整的:「第 5 个 trial 学会」跨种子池成立(crossDrift=0,四组池子都是 5),但它对速率假设极不稳(rate 从 150 换到 600,达标 trial 数在 3~9 之间移动)。所以「第几个 trial 学会」这句话只有在给定习得模型时才有意义;而「达标 trial 数随速率单调前移」这条排序结论,才是跨假设成立的。能跨口径引用的量与不能跨口径引用的量必须分开说——这与 6.1 在相变点上得到的教训是同一件事。
自检表
| 检查项 | 合格标准 |
|---|---|
| 技巧可播种 | 三维熟练度全为 0..1000 整数,决策骰来自 Lehmer PRNG |
| 习得模型可复现 | 指数逼近与幂律都是整数递推,无浮点、无 random |
| 曲线真跑 | 12 个 trial 各在 64 个固定种子上实测,非纸面推演 |
| 阈值有定义 | 达标 = 胜率首次 ≥ 500‰ 的那个 trial 序号 |
| 代理指标声明 | 技巧与胜率写明是 proxy,只做相对比较,不绝对化 |
| 稳健性分开说 | 区分「跨种子池稳定」与「依赖形状假设」两类结论 |
有了「熟练度 → 胜率」这条映射,下一个问题是难度该排在哪:如果玩家的技巧沿指数逼近往上走,难度曲线要跟着走到哪一格,才能让胜率始终落在能玩的区间里?下一节把这条反推做出来。
阅读导航:上一节:10.3 带宽与难度设计 · 下一节:11.2 习得曲线与难度曲线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。