6.1 相变:参数扫过临界点

参数扫过某个值之后系统的宏观性质整体改变,这就是相变。本节用二维粗扫画出《灰烬牌库》的相图(enemyHp × enemyAttack,每格 32 个显式种子、胜率千分比),再用一维二分把临界点钉到 ±1;B 层换四组种子池检验临界点与曲线高度是否稳。

6.1 相变:参数扫过临界点

《小游戏开发实战》卷一 7.2 难度曲线与心流 用 S 层算过一条心流模型曲线,并在 B 层量过「难度对参数有多敏感」——那是曲线上一点的斜率:参数动一格,难度指标动多少,是连续量的局部响应。本节换一个完全不同的问题:不是「动一格会怎样」,而是「参数扫过某个值之后,整张相图的性质有没有整体切换」。前者是斜率,后者是相变。数学上这是两件事,工程上也是两套做法:斜率用差分,相变要粗扫定位 + 二分细化。本节先把相变与临界点的定义、相图与相界的读法、以及怎么把临界点钉到一个整数上做完整;6.2 给序参量与临界指数,6.3 把扫描本身的工程量(步长、预算、采样判据)算清楚。

相变:宏观态在临界点两侧不同

物理学里的相变(phase transition)指控制参数扫过某个值时,系统的宏观态从一种整体性质切换到另一种——冰到水、磁体到非磁体。关键词是整体:临界点两侧不是「好一点/差一点」,而是质的不同。游戏里同样存在这种切换,只是它没有热力学那么显眼:

量《灰烬牌库》里的对应读法
控制参数enemyHp、enemyAttack设计者能调的旋钮
宏观态「打得过」与「打不过」一局结束后敌人的存活状态
序参量胜率千分比在胜相接近 1000‰、在败相接近 0‰
临界点胜率跨过 500‰ 的那个参数值本节要钉到 ±1 的数

序参量(order parameter)是能把两相区分开、且在一相里趋于零的宏观量。这里选胜率千分比:它不是「好玩」的度量——胜率是代理指标(proxy),只用于相对比较,说明「这套参数下玩家赢的概率」——但它能把「胜相」和「败相」分开,这就够当序参量用了。绝对胜率没有意义(它依赖固定策略与固定种子池),可比较的是它随参数怎么变。

为什么游戏设计要在意相变点?因为悬崖式的难度曲线不是设计目标。看卷一 6.1 的读数:enemyHp 从 20 扫到 70 胜率全是 1000‰,从 75 到 80 却一步从 937‰ 掉到 0‰。那道悬崖的位置就是临界点——它决定了「玩家在参数空间的哪一侧会输」。把临界点量出来,设计者才知道自己调的旋钮离悬崖有多远。

相图与相界:把两维一起扫

单参数扫描只能看到一条线。但难度通常是两个参数共同决定的:敌人血量高、攻击也高,与血量高、攻击低,是两种完全不同的局面。所以本节把 enemyHp 与 enemyAttack 一起扫,得到一个相图(phase diagram):横轴 enemyAttack、纵轴 enemyHp,每个格子是那一组参数下的胜率千分比。相图里胜率 ≥ 500‰ 的格子叫胜相、< 500‰ 的叫败相,两者的分界就是相界(phase boundary)。

粗扫的网格是 11 × 10:enemyHp 从 40 到 90、步长 5,enemyAttack 从 2 到 20、步长 2。每格跑 32 个显式种子——种子池写死,跨格复用,这样两次运行之间唯一的变量就是参数本身。32 个种子的分辨率是 31‰(1000÷32),够看出「32 个种子里掉 1 个」这种量级的缺口。

S 层:二维粗扫 + 一维二分

下面这段是 S 层脚本 sim/gd_play_06_phase.py。它读只读规格 config/play_spec.json 与扫描配置 config/gd_play_06_scan.json,先做二维粗扫写出相图,再把每列「首个胜率 < 500‰ 的 enemyHp」记成相界;最后在 enemyHp=75 这条切片上,对 enemyAttack 粗扫步长 2、再二分,把临界点钉到一个整数。全程整数、无浮点、无 random;计时用 perf_counter_ns 的整数纳秒;相图用 ashlib 的 svg_begin/svg_text/svg_save 与自写的 <rect>/<line> 画成纯 xml.etree SVG。

#!/usr/bin/env python3
# file: sim/gd_play_06_phase.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_06_phase.py
"""卷二 6.1 相变(S 层):二维粗扫得相图,一维二分把临界点钉到 ±1。

相 = 参数空间里宏观性质不变的一块;相图 = 逐格胜率千分比;相界 = 每列首个胜率 < 500‰ 的
enemyHp。再在 enemyHp=75 切片上对 enemyAttack 粗扫 + 二分求临界点。全整数、无浮点、无 random。
胜率是代理指标,只做相对比较。产出 out/play06_phase.json + out/play06_phase.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


def wp(spec, over, seeds):
    w = 0
    for sd in seeds:
        s = copy.deepcopy(spec)
        s["seed"] = sd
        s["params"].update(over)
        w += 1 if ashlib.run_spec(s)["won"] else 0
    return 1000 * w // len(seeds)


def draw(rows, cols, grid, boundary, critical, path):
    cw, ch, x0, y0 = 46, 22, 110, 64
    root = ashlib.svg_begin(x0 + cw * len(cols) + 40, y0 + ch * len(rows) + 56)
    ashlib.svg_text(root, 16, 30, "相图:enemyHp × enemyAttack(蓝=胜相,红=败相)", 15)
    for i in range(len(rows)):
        for j in range(len(cols)):
            p = grid[i][j]
            ET.SubElement(root, "rect", {"x": str(x0 + j * cw + 1), "y": str(y0 + i * ch + 1),
                                         "width": str(cw - 2), "height": str(ch - 2),
                                         "fill": "#38bdf8" if p >= 500 else "#ef4444"})
    for j, bi in enumerate(boundary):
        if bi is not None:
            ET.SubElement(root, "line", {"x1": str(x0 + j * cw), "y1": str(y0 + bi * ch),
                                         "x2": str(x0 + (j + 1) * cw), "y2": str(y0 + bi * ch),
                                         "stroke": "#f8fafc", "stroke-width": "2"})
    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 = cfg["seedPool"][: cfg["cellsPerScan"]]
    g, cross = cfg["grid"], cfg["bisect"]["crossPermille"]
    rows = list(range(g["row"]["from"], g["row"]["to"] + 1, g["row"]["step"]))
    cols = list(range(g["col"]["from"], g["col"]["to"] + 1, g["col"]["step"]))
    grid = [[wp(spec, {g["row"]["param"]: r, g["col"]["param"]: c}, seeds) for c in cols] for r in rows]
    boundary = [next((i for i in range(len(rows)) if grid[i][j] < cross), None) for j in range(len(cols))]

    sl = cfg["slice"]
    lo, hi, fixed = sl["from"], sl["to"], sl["fixed"]
    w = lambda v: wp(spec, {**fixed, sl["param"]: v}, seeds)
    coarse = [(v, w(v)) for v in range(lo, hi + 1, 2)]
    bracket = next(((coarse[i][0], coarse[i + 1][0]) for i in range(len(coarse) - 1)
                    if coarse[i][1] >= cross > coarse[i + 1][1]), None)
    a, b, rounds, trace = bracket[0], bracket[1], 0, []
    while b - a > 1 and rounds < cfg["bisect"]["maxRounds"]:
        mid = (a + b) // 2
        rounds += 1
        pm = w(mid)
        trace.append({"round": rounds, "mid": mid, "winPermille": pm,
                      "keep": "low" if pm >= cross else "high"})
        a, b = (mid, b) if pm >= cross else (a, mid)
    critical = b
    elapsed_ms = (time.perf_counter_ns() - t0) // 1000000
    report = {"id": "gd_play_06_phase", "seeds": len(seeds), "rows": rows, "cols": cols, "grid": grid,
              "boundaryRow": boundary, "slice": sl, "bracket": list(bracket), "bisectRounds": rounds,
              "bisectTrace": trace, "criticalPoint": critical, "cells": len(rows) * len(cols),
              "runs": len(rows) * len(cols) * len(seeds), "elapsedMs": elapsed_ms}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play06_phase.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    size = draw(rows, cols, grid, boundary, critical, os.path.join(OUT, "play06_phase.svg"))

    print(f"grid {len(rows)}×{len(cols)}  seeds={len(seeds)}  cells={len(rows) * len(cols)}"
          f"  runs={len(rows) * len(cols) * len(seeds)}")
    print(f"{'enemyHp\\atk':<11}" + "".join(f"{c:>6}" for c in cols))
    for i, r in enumerate(rows):
        print(f"{r:<11}" + "".join(f"{grid[i][j]:>6}" for j in range(len(cols))))
    print(f"boundaryRow={boundary}")
    for v, p in coarse:
        print(f"slice {sl['param']}={v:>3}  winRate={p:>4}‰")
    for t in trace:
        print(f"bisect round{t['round']}: mid={t['mid']:>3}  winRate={t['winPermille']:>4}‰  keep={t['keep']}")
    print(f"bracket={bracket}  criticalPoint={critical}  rounds={rounds}")
    print(f"elapsed={elapsed_ms} ms  svg={size} bytes")
    print("wrote out/play06_phase.json out/play06_phase.svg")
    ET.parse(os.path.join(OUT, "play06_phase.svg"))
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_06_phase.py(绝对数值不可移植,同机相对量级可信):

grid 11×10  seeds=32  cells=110  runs=3520
enemyHp\atk     2     4     6     8    10    12    14    16    18    20
40           1000  1000  1000  1000  1000  1000  1000  1000  1000  1000
45           1000  1000  1000  1000  1000  1000  1000  1000  1000  1000
50           1000  1000  1000  1000  1000  1000  1000  1000   968   906
55           1000  1000  1000  1000  1000  1000  1000  1000   906   843
60           1000  1000  1000  1000  1000  1000  1000  1000   687   625
65            968   968   968   968   968   968   968   968   312   218
70            937   937   937   937   937   937   937   906   218   125
75            906   906   906   906   906   906   906   875    31     0
80              0     0     0     0     0     0     0     0     0     0
85              0     0     0     0     0     0     0     0     0     0
90              0     0     0     0     0     0     0     0     0     0
boundaryRow=[8, 8, 8, 8, 8, 8, 8, 8, 5, 5]
slice enemyAttack= 10  winRate= 906‰
slice enemyAttack= 12  winRate= 906‰
slice enemyAttack= 14  winRate= 906‰
slice enemyAttack= 16  winRate= 875‰
slice enemyAttack= 18  winRate=  31‰
slice enemyAttack= 20  winRate=   0‰
slice enemyAttack= 22  winRate=   0‰
slice enemyAttack= 24  winRate=   0‰
slice enemyAttack= 26  winRate=   0‰
slice enemyAttack= 28  winRate=   0‰
slice enemyAttack= 30  winRate=   0‰
slice enemyAttack= 32  winRate=   0‰
slice enemyAttack= 34  winRate=   0‰
slice enemyAttack= 36  winRate=   0‰
slice enemyAttack= 38  winRate=   0‰
slice enemyAttack= 40  winRate=   0‰
bisect round1: mid= 17  winRate= 687‰  keep=low
bracket=(16, 18)  criticalPoint=18  rounds=1
elapsed=99 ms  svg=8312 bytes
wrote out/play06_phase.json out/play06_phase.svg

相图读出来有两条信息。第一,相界在低 enemyAttack 段是一条水平线:enemyAttack 从 2 到 16,相界都停在 enemyHp=80(boundaryRow 前八个值都是 8,对应第 9 行 enemyHp=80)。这说明在敌人攻击弱的时候,胜负只由血量决定——攻击从 2 加到 16,enemyHp 的临界值一格没动。第二,相界在高 enemyAttack 段向上翘:攻击到了 18、20,相界跳到 enemyHp=65、enemyHp=60(boundaryRow 末两位是 5、5,对应第 6 行 enemyHp=65)。也就是说,敌人攻击一旦强到能在一局的回合数内打死玩家,血量就不再是唯一的决定因素,两个参数开始耦合。

B 层:临界点对种子池稳不稳

相图和相界都建立在一组种子上。一个自然的质疑是:换一组种子,临界点会不会挪?如果会挪,那「临界点 = 18」这个结论就只是「这 32 个种子的临界点」,不是机制的性质。B 层脚本 sim/gd_play_06_phase_robust.py 正面回答这个问题:在同一条切片上,用四组不同的种子池(基准池 + 三个整体偏移池)各扫一遍,同时记录两个量——临界点本身与临界点前一格的胜率。漂移超过阈值只打印软告警,进程始终 exit 0(B 层的纪律:稳健性问题报出来,不拦构建)。

#!/usr/bin/env python3
# file: sim/gd_play_06_phase_robust.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_06_phase_robust.py
"""卷二 6.1 相变(B 层):临界点对**种子池**的稳健性——软告警,不硬失败。

在同一条切片(enemyHp=75)上换四组**不同的**种子池(基准池 + 三个整体偏移池)各扫一遍,
同时记录两个量:临界点本身、临界点前一格的胜率。漂移超阈值只**打印软告警**,进程始终 exit 0。
产出 out/play06_phase_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


def wp(spec, over, seeds):
    w = 0
    for sd in seeds:
        s = copy.deepcopy(spec)
        s["seed"] = sd
        s["params"].update(over)
        w += 1 if ashlib.run_spec(s)["won"] else 0
    return 1000 * w // len(seeds)


def critical(spec, fixed, param, lo, hi, seeds, cross, max_rounds):
    w = lambda v: wp(spec, {**fixed, param: v}, seeds)
    coarse = [(v, w(v)) for v in range(lo, hi + 1, 2)]
    bracket = next(((coarse[i][0], coarse[i + 1][0]) for i in range(len(coarse) - 1)
                    if coarse[i][1] >= cross > coarse[i + 1][1]), None)
    if bracket is None:
        return None, 0
    a, b, rounds = bracket[0], bracket[1], 0
    while b - a > 1 and rounds < max_rounds:
        mid = (a + b) // 2
        rounds += 1
        a, b = (mid, b) if w(mid) >= cross else (a, mid)
    return b, rounds


def main():
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    pool, sl, n = cfg["seedPool"], cfg["slice"], cfg["cellsPerScan"]
    rob, cross, maxr = cfg["robustness"], cfg["bisect"]["crossPermille"], cfg["bisect"]["maxRounds"]
    pools = [("base", pool[:n])] + [(f"+{o}", [s + o for s in pool[:n]]) for o in rob["altPoolOffsets"]]

    rows = []
    for label, seeds in pools:
        cp, rounds = critical(spec, sl["fixed"], sl["param"], sl["from"], sl["to"], seeds, cross, maxr)
        probe = cp - 1 if cp else None
        rows.append({"pool": label, "criticalPoint": cp, "probe": probe, "bisectRounds": rounds,
                     "winAtProbePermille": wp(spec, {**sl["fixed"], sl["param"]: probe}, seeds) if probe else None})
    cps = [r["criticalPoint"] for r in rows if r["criticalPoint"] is not None]
    wps = [r["winAtProbePermille"] for r in rows if r["winAtProbePermille"] is not None]
    drift, spread = max(cps) - min(cps), max(wps) - min(wps)
    warnings = []
    if drift > rob["criticalDriftWarn"]:
        warnings.append(f"临界点随种子池漂移 {drift}(阈值 {rob['criticalDriftWarn']}):口径依赖")
    if spread > rob["winSpreadWarnPermille"]:
        warnings.append(f"临界点前一格胜率跨种子池散布 {spread}‰(阈值 {rob['winSpreadWarnPermille']}‰):"
                        f"临界点稳,但它附近的曲线高度不稳,读单点胜率会随种子池变")
    report = {"id": "gd_play_06_phase_robust", "slice": sl, "rows": rows, "criticalDrift": drift,
              "winSpreadPermille": spread, "warnings": warnings,
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play06_phase_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} criticalPoint={r['criticalPoint']}  "
              f"winAtProbe({r['probe']})={r['winAtProbePermille']}‰  bisectRounds={r['bisectRounds']}")
    print(f"criticalDrift={drift}  winSpread={spread}‰")
    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_phase_robust.json")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
pool=base   criticalPoint=18  winAtProbe(17)=687‰  bisectRounds=1
pool=+100   criticalPoint=18  winAtProbe(17)=718‰  bisectRounds=1
pool=+200   criticalPoint=18  winAtProbe(17)=750‰  bisectRounds=1
pool=+400   criticalPoint=18  winAtProbe(17)=781‰  bisectRounds=1
criticalDrift=0  winSpread=94‰
warnings=1
  [WARN] 临界点前一格胜率跨种子池散布 94‰(阈值 80‰):临界点稳,但它附近的曲线高度不稳,读单点胜率会随种子池变
elapsed=47 ms  exit=0(软告警不硬失败)
wrote out/play06_phase_robust.json

四组种子池给出同一个临界点 18(criticalDrift=0)——这说明「临界点在 18」不是某一组种子的偶然,而是机制在这一参数区间的性质。但同一次运行也暴露了另一面:临界点前一格的胜率从 687‰ 一路漂到 781‰(winSpread=94‰),超过了 80‰ 的告警阈值。两个读数合起来才是完整的结论:临界点的位置稳,但临界点附近的曲线高度不稳。所以「在 enemyAttack=17 时胜率是 687‰」这句话只在给定种子池下成立,换一组种子就变;而「临界点在 18」这句话跨种子池成立。能跨口径引用的量与不能跨口径引用的量,必须分开说——这正是 B 层存在的意义。

自检表

检查项合格标准
相图真跑11 × 10 逐格跑满 32 个种子,非纸面推演
种子写死32 个种子在配置里显式列出,不是「1..32」的生成式描述
临界点有定义临界点 = 胜率跨过 500‰ 的首个整数参数值,粗扫 + 二分求得
无浮点胜率是整数地板除;计时用整数纳秒
代理指标声明胜率写明是 proxy,只做相对比较,不绝对化
稳健性分开说区分「跨种子池稳定」的结论与「单点读数」的结论

相图告诉我们临界点在哪个整数上,但没告诉我们「参数靠近临界点时,系统是怎么变的」——是缓缓过渡,还是像悬崖一样跳下去?下一节换一组序参量,用整数二进对数斜率估出临界指数,把「变化的陡峭程度」也变成一个可比较的数。

阅读导航:上一节:5.3 平衡与多样性 · 下一节:6.2 序参量与临界指数 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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