6.2 序参量与临界指数

序参量是把两相区分开、且在一相里趋于零的宏观量。本节在 enemyHp=75 切片上细扫 enemyAttack,对胜率、残余兵力、未输出比例三个候选序参量各减胜相基线使其归零,再用整数二进对数斜率估出临界指数 beta;B 层换六个拟合窗口检验 beta 有多可信。

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 相变扫描的实现 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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