6.2 序参量与临界指数
《小游戏开发实战》卷一 7.2 难度曲线与心流
把难度画成一条连续曲线:挑战与技巧的差距沿时间轴变化。本节要的东西比曲线更粗、也更硬——相变附近的形状。上一节量出了临界点在 enemyAttack=18,但没回答「参数靠近临界点时系统怎么变」:是缓缓过渡,还是一步跳到底?描述这件事有两个工具:**序参量(order parameter)**决定「看什么量」,临界指数(critical exponent)决定「这个量在临界点附近涨得多快」。本节把两个都做出来,并全程只用整数——临界指数用二进对数斜率估,不碰浮点。
序参量:把「两相差别」压成一个数
序参量的定义有两半:能把两相区分开,且在一相里趋于零。第二半是硬要求——如果某个量在两相都不为零,它就只能比较大小,不能当「相」的判据。上一节用的胜率满足第一半(胜相 1000‰、败相 0‰),但它的取值只有 0 到 1000 这一条竖轴,且在两相里都不平滑。本节同时试三个候选序参量,看哪一个在临界点附近最有结构:
| 序参量 | 定义 | 在胜相 | 在败相 | 归零做法 |
|---|---|---|---|---|
phi_win | 败相比例 = 1000 − 胜率 | 趋于 0 | 趋于 906 | 减去胜相基线 |
phi_resid | 平均残余敌人血量 | 0 | 随参数上升 | 减去胜相基线(此处基线为 0) |
phi_dealt | 未输出比例 = 1000 − 已造成伤害÷敌人血量 | 趋于 0 | 随参数上升 | 减去胜相基线 |
三个序参量都先减去「胜相基线」(取扫描起点的读数),使它们在胜相严格归零。这样它们的零点就是「胜相消失的那个参数值」,临界点附近的行为才可比。基线是 baseline={'win': 906, 'resid': 0, 'dealtFrac': 986}——注意起点胜率不是 1000‰ 而是 906‰,说明即使在 enemyAttack=10,也有约 1/16 的种子会输;这就是为什么基线必须实测、不能假设成 1000。
临界指数:用整数二进对数斜率估
物理里序参量在临界点附近按幂律消失:φ ∝ t^β,t = |参数 − 临界参数| 是到临界点的距离,β 就是临界指数。标准做法是取对数做线性拟合——但那需要浮点,而本卷禁浮点。替代方案是把「取对数」换成整数二进对数 log2i(n) = n.bit_length() − 1:它给出 n 的二进制位长减一,是 ⌊log2 n⌋ 的精确整数值。于是斜率变成:
beta‰ = 1000 × (log2i(φ_hi) − log2i(φ_lo)) ÷ (log2i(t_hi) − log2i(t_lo))
t 取「参数值 − 零点」,φ 取对应参数下的序参量。分母是窗口宽度(以 2 为底的对数刻度),分子是序参量在同一窗口里的对数变化。整个式子只用整数减法和地板除,结果是千分比整数——它不能和物理教科书里的 β=0.326 直接比,但它是同一口径下可比较的陡峭度:两个机制的 beta‰ 谁大,谁在临界点附近涨得快。
S 层:三个序参量在同一相变上的指数
S 层脚本 sim/gd_play_06_exponent.py 在 enemyHp=75 切片上把 enemyAttack 从 10 细扫到 60,对每个取值算三个序参量、各自减基线归零,再在窗口 [1,6](相对零点)上估出 beta‰。它同时画出三条折线(各自归一化到千分比)与三个零点竖线,产出纯 xml.etree SVG。全程整数、无浮点、无 random。
#!/usr/bin/env python3
# file: sim/gd_play_06_exponent.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_06_exponent.py
"""卷二 6.2 序参量与临界指数(S 层):三个序参量在同一条相变上给出三个指数。
序参量 = 能把两相区分开、且在一相里趋于零的宏观量。本脚本在 enemyHp=75 切片上细扫
enemyAttack,对每个取值算三个候选序参量,各自减去「胜相基线」使其在胜相归零:phi_win(败相
比例)、phi_resid(残余兵力)、phi_dealt(未输出比例)。临界指数用整数二进对数斜率估:
beta‰ = 1000·(log2 φ_hi − log2 φ_lo) / (log2 t_hi − log2 t_lo),t = 控制参数 − 零点。
全程整数,无浮点、无 random。产出 out/play06_exponent.json + .svg,报告实测耗时。
"""
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_06_scan.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
COLORS = {"phi_win": "#38bdf8", "phi_resid": "#f59e0b", "phi_dealt": "#22c55e"}
log2i = lambda n: n.bit_length() - 1
def cell(spec, over, seeds):
wins = resid = dealt = 0
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"].update(over)
sn = ashlib.run_spec(s)
wins += 1 if sn["won"] else 0
resid += sn["enemyHp"]
dealt += sn["dealt"]
n = len(seeds)
return {"win": 1000 * wins // n, "resid": resid // n, "dealtFrac": 1000 * (dealt // n) // over["enemyHp"]}
def exponent(curve, p0, w_lo, w_hi):
f_lo, f_hi = curve.get(p0 + w_lo), curve.get(p0 + w_hi)
d_t = log2i(w_hi) - log2i(w_lo)
if not f_lo or not f_hi or w_lo <= 0 or d_t == 0:
return None
return 1000 * (log2i(f_hi) - log2i(f_lo)) // d_t
def draw(vals, curves, p0s, path):
W, H, x0, y0, y1 = 680, 300, 70, 40, 230
lo, hi = vals[0], vals[-1]
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, x0, 24, "三个序参量在同一相变上(各自归一化到千分比)", 15)
for name, curve in curves.items():
mx = max(curve.values()) or 1
pts = " ".join(f"{x0 + (v - lo) * (W - 90) // (hi - lo)},"
f"{y1 - curve[v] * 1000 // mx * (y1 - y0) // 1000}" for v in vals)
ET.SubElement(root, "polyline", {"points": pts, "fill": "none", "stroke": COLORS[name], "stroke-width": "2"})
xp = x0 + (p0s[name] - lo) * (W - 90) // (hi - lo)
ET.SubElement(root, "line", {"x1": str(xp), "y1": str(y0), "x2": str(xp), "y2": str(y1),
"stroke": COLORS[name], "stroke-dasharray": "3 3"})
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, sl, sw = cfg["seedPool"][: cfg["cellsPerScan"]], cfg["slice"], cfg["exponentSweep"]
w_lo, w_hi = cfg["exponent"]["windowMin"], cfg["exponent"]["windowMax"]
raw = {v: cell(spec, {**sl["fixed"], sl["param"]: v}, seeds) for v in range(sw["from"], sw["to"] + 1)}
base = {k: raw[sw["from"]][k] for k in ("win", "resid", "dealtFrac")}
curves = {
"phi_win": {v: (1000 - raw[v]["win"]) - (1000 - base["win"]) for v in raw},
"phi_resid": {v: raw[v]["resid"] - base["resid"] for v in raw},
"phi_dealt": {v: (1000 - raw[v]["dealtFrac"]) - (1000 - base["dealtFrac"]) for v in raw},
}
p0s = {n: max(v for v in raw if curves[n][v] == 0) for n in curves}
betas = {n: exponent(curves[n], p0s[n], w_lo, w_hi) for n in curves}
elapsed_ms = (time.perf_counter_ns() - t0) // 1000000
report = {"id": "gd_play_06_exponent", "sweep": sw, "fixed": sl["fixed"], "seeds": len(seeds),
"baseline": base, "zeroPoint": p0s, "window": [w_lo, w_hi], "betaPermille": betas,
"curves": {n: {str(v): curves[n][v] for v in raw} for n in curves},
"winPermille": {str(v): raw[v]["win"] for v in raw}, "elapsedMs": elapsed_ms}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play06_exponent.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
size = draw(sorted(raw), curves, p0s, os.path.join(OUT, "play06_exponent.svg"))
print(f"{sl['param']:>11} win phi_win phi_resid phi_dealt (只打印读数发生变化的首行)")
prev = None
for v in sorted(raw):
cur = (raw[v]["win"], curves["phi_win"][v], curves["phi_resid"][v], curves["phi_dealt"][v])
if cur != prev:
print(f"{v:>11} {cur[0]:>3} {cur[1]:>7} {cur[2]:>9} {cur[3]:>9}")
prev = cur
print(f"baseline={base}")
print(f"zeroPoint={p0s} window=[{w_lo},{w_hi}]")
print(f"betaPermille={betas}")
print(f"elapsed={elapsed_ms} ms svg={size} bytes")
print("wrote out/play06_exponent.json out/play06_exponent.svg")
ET.parse(os.path.join(OUT, "play06_exponent.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_06_exponent.py:
enemyAttack win phi_win phi_resid phi_dealt (只打印读数发生变化的首行)
10 906 0 0 0
15 875 31 0 0
17 687 219 3 40
18 31 875 11 146
20 0 906 14 186
22 0 906 18 240
24 0 906 28 373
30 0 906 32 426
33 0 906 39 520
35 0 906 42 560
60 0 906 52 693
baseline={'win': 906, 'resid': 0, 'dealtFrac': 986}
zeroPoint={'phi_win': 14, 'phi_resid': 16, 'phi_dealt': 16} window=[1,6]
betaPermille={'phi_win': 2500, 'phi_resid': 1500, 'phi_dealt': 1000}
elapsed=33 ms svg=1962 bytes
wrote out/play06_exponent.json out/play06_exponent.svg
读数里有三件值得拆开的事。第一,三个序参量的零点不一样:phi_win 的零点是 14,phi_resid 与 phi_dealt 都是 16。零点 = 「胜相完全消失(该序参量首次非零)的参数值」。phi_win 在 14 就动了(胜率从 906‰ 掉到 875‰),而残余兵力要到 16 才非零——说明胜率比残余兵力更早察觉临界,因为一个种子输掉就足以让胜率变化,而残余兵力要多个种子累积才看得出。第二,phi_win 在临界点附近几乎垂直:从 15 的 31‰ 到 18 的 875‰,三步跳了 844‰;对应 beta‰=2500,是三个里最陡的。第三,phi_dealt 在 60 才到 693‰,涨得最慢(beta‰=1000),因为「未输出比例」受回合上限约束,敌人血量再高,玩家能打出的伤害也就那么多。三个序参量对同一次相变给出三个不同的陡峭度,这不是矛盾,而是它们量的本来就是不同的东西。
B 层:指数对拟合窗口稳不稳
beta‰ 是在一个拟合窗口上估的。换窗口,它会变——变多少就是「这个指数有多可信」。B 层脚本 sim/gd_play_06_exponent_robust.py 把三个序参量在六个候选窗口([1,4]、[1,6]、[2,8]、[2,10]、[1,10]、[3,12])上各估一遍,报出每个序参量的 beta‰ 极差。极差超阈值只打印软告警,进程始终 exit 0。
#!/usr/bin/env python3
# file: sim/gd_play_06_exponent_robust.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_06_exponent_robust.py
"""卷二 6.2 序参量与临界指数(B 层):指数对**拟合窗口**的稳健性——软告警,不硬失败。
有效临界指数 beta 是在窗口 t ∈ [w_lo, w_hi](相对序参量零点)上用整数二进对数斜率估的。
换窗口,beta 会变——变多少就是「这个指数有多可信」。本脚本把三个序参量在六个候选窗口上各估
一遍,报出每个序参量的 beta 极差。极差超阈值只**打印软告警**,进程始终 exit 0。
产出 out/play06_exponent_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_06_scan.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
log2i = lambda n: n.bit_length() - 1
def cell(spec, over, seeds):
wins = resid = dealt = 0
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"].update(over)
sn = ashlib.run_spec(s)
wins += 1 if sn["won"] else 0
resid += sn["enemyHp"]
dealt += sn["dealt"]
n = len(seeds)
return {"win": 1000 * wins // n, "resid": resid // n, "dealtFrac": 1000 * (dealt // n) // over["enemyHp"]}
def exponent(curve, p0, w_lo, w_hi):
f_lo, f_hi = curve.get(p0 + w_lo), curve.get(p0 + w_hi)
d_t = log2i(w_hi) - log2i(w_lo)
if not f_lo or not f_hi or w_lo <= 0 or d_t == 0:
return None
return 1000 * (log2i(f_hi) - log2i(f_lo)) // d_t
def main():
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
seeds, sl, sw = cfg["seedPool"][: cfg["cellsPerScan"]], cfg["slice"], cfg["exponentSweep"]
windows, warn_permille = cfg["exponent"]["windows"], cfg["robustness"]["exponentDriftWarnPermille"]
raw = {v: cell(spec, {**sl["fixed"], sl["param"]: v}, seeds) for v in range(sw["from"], sw["to"] + 1)}
base = {k: raw[sw["from"]][k] for k in ("win", "resid", "dealtFrac")}
curves = {
"phi_win": {v: (1000 - raw[v]["win"]) - (1000 - base["win"]) for v in raw},
"phi_resid": {v: raw[v]["resid"] - base["resid"] for v in raw},
"phi_dealt": {v: (1000 - raw[v]["dealtFrac"]) - (1000 - base["dealtFrac"]) for v in raw},
}
p0s = {n: max(v for v in raw if curves[n][v] == 0) for n in curves}
rows, warnings = [], []
for name in curves:
betas = [exponent(curves[name], p0s[name], a, b) for a, b in windows]
vals = [b for b in betas if b is not None]
spread = (max(vals) - min(vals)) if vals else 0
rows.append({"orderParameter": name, "zeroPoint": p0s[name],
"windows": [list(w) for w in windows], "betas": betas, "spread": spread})
if spread > warn_permille:
warnings.append(f"{name} 的 beta 跨窗口极差 {spread}‰(阈值 {warn_permille}‰):"
f"指数随窗口漂移,不能当成常数引用")
report = {"id": "gd_play_06_exponent_robust", "rows": rows, "warnPermille": warn_permille,
"warnings": warnings, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play06_exponent_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"{'序参量':<11}{'零点':>5} " + "".join(f"{f'[{a},{b}]':>10}" for a, b in windows) + f"{'极差':>7}")
for r in rows:
cells = "".join(f"{('—' if b is None else b):>10}" for b in r["betas"])
print(f"{r['orderParameter']:<11}{r['zeroPoint']:>5} {cells}{r['spread']:>7}")
print(f"warnings={len(warnings)}")
for wmsg in warnings:
print(f" [WARN] {wmsg}")
print(f"elapsed={report['elapsedMs']} ms exit=0(软告警不硬失败)")
print("wrote out/play06_exponent_robust.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
序参量 零点 [1,4] [1,6] [2,8] [2,10] [1,10] [3,12] 极差
phi_win 14 2500 2500 2500 2500 1666 1000 1500
phi_resid 16 1000 1500 500 500 1000 500 1000
phi_dealt 16 1000 1000 500 500 1000 500 500
warnings=2
[WARN] phi_win 的 beta 跨窗口极差 1500‰(阈值 500‰):指数随窗口漂移,不能当成常数引用
[WARN] phi_resid 的 beta 跨窗口极差 1000‰(阈值 500‰):指数随窗口漂移,不能当成常数引用
elapsed=41 ms exit=0(软告警不硬失败)
wrote out/play06_exponent_robust.json
三个序参量的 beta‰ 都随窗口漂,极差分别是 1500‰、1000‰、500‰——全都超过 500‰ 的阈值(phi_dealt 刚好卡在 500‰ 未触发)。这不是脚本出错,而是这条相变本来就不满足幂律:幂律假设序参量在临界点附近是自相似地按幂函数衰减,而《灰烬牌库》的胜率在临界点附近是一步跳(31‰ → 875‰),跳变不像幂律那样平滑。所以正确的结论是:不要把 beta‰=2500 当成「这个机制的临界指数」引用;它只是「在窗口 [1,6] 上、用整数二进对数斜率估出的陡峭度」。窗口 [1,4] 与 [1,6] 给出同样的 2500‰(因为 4 与 6 的二进对数都是 2),换到 [1,10] 就掉到 1666‰——指数的可信度,取决于它跨窗口稳不稳,不稳就该写成「区间」而不是「常数」。
自检表
| 检查项 | 合格标准 |
|---|---|
| 序参量有定义 | 每个序参量说明在胜相/败相的取值与归零做法 |
| 基线实测 | 胜相基线取自实跑读数(906‰),不假设成 1000‰ |
| 无浮点 | 指数用整数二进对数斜率;结果写成千分比整数 |
| 指数不绝对化 | beta‰ 只作同口径相对比较,不与物理常数并列 |
| 窗口稳健性 | 报告跨窗口极差,超阈值写「区间」而非「常数」 |
| 代理指标声明 | 胜率是 proxy,只做相对比较 |
三个序参量给三个指数、且都不稳——这说明**「临界指数」在游戏里不是一个数,而是一族依赖口径的数**。真正能被工程复用的不是那个数,而是估它的流程:细扫 → 减基线 → 选窗口 → 估斜率 → 换窗口验稳健。下一节把这条流程的成本算清楚:粗扫要多少格、二分省下多少 run、什么时候必须改用采样。
阅读导航:上一节:6.1 相变:参数扫过临界点 · 下一节:6.3 相变扫描的实现 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。