14.3 相变图的读法

相变图要读三样东西:序参量、临界点、临界带宽。本节用粗扫定位跨越区间、二分把临界点收到整数、再在邻域量出过渡带宽,并用纯 xml.etree 手绘序参量曲线与两相着色的相图,两张图都回读重建逐格比对;胜率是代理指标,只作相对比较,禁绝对化。

14.3 相变图的读法

前两节的横轴都是离散的:曲线的参数点、热力图的档位,都只是几档采样。要读出「在哪个参数值上,胜负突然翻面」,就得让控制参数连续变化,把那条跃迁找出来——这就是相变图。/posts/game/industry/ 那一栏目的短文讲的是「难度该怎么调、平衡委员会怎么开」,是流程口径;本节只讲怎么把跃迁画出来、怎么量出它有多宽。

相变图要读三样东西:序参量(这里用胜率千分比,是代理指标 proxy,只作相对比较,禁绝对化)、临界点(序参量跨过阈值的那个参数值)、临界带宽(序参量在过渡区间内跨越的参数宽度)。读错的三种典型方式:① 把序参量当成绝对量(「胜率 812‰ 所以好玩」);② 带宽小于扫描步长却当成「一级跳变」;③ 只画两相着色,把过渡带抹掉。

三种读数:粗扫、二分、带宽

一条切片上的读法分三步。粗扫:控制参数从 30 到 90 每 5 走一格,记下每格的序参量,找到「上一格 ≥ 阈值、下一格 < 阈值」的那个跨越区间。二分:在跨越区间里对半折,bisectRounds 轮后把临界点收到宽度 1(整数,收敛快是因为序参量对参数单调)。带宽:在临界点邻域 ±bandSpan 内,数出序参量落在 [bandLow, bandHigh)(这里 400–600‰)里的格子,其跨度就是临界带宽——它告诉你这次跃迁是「几格内平滑过渡」还是「一格内硬跳」。

带宽 = 0 不是「没有相变」,而是相变比采样还陡:在 5 的步长下,序参量直接从 1000 掉到 0,中间没有落在 400–600‰ 的格。这正是「一级跳变」的样子,但也可能是步长太粗、把过渡带跳过去了——所以带宽为 0 时要么缩小步长复扫,要么明确标注「在当前分辨率下不可分辨」。本节的脚本把每一步的中间结果都打印出来,就是为了让这种判断有据可依。

{ "seeds": [401, "…", 432],
  "slices": [6, 12, 18], "swept": "enemyHp", "from": 30, "to": 90, "step": 5,
  "crossPermille": 500, "bandLow": 400, "bandHigh": 600, "bandSpan": 15,
  "bisectRounds": 6 }

S 层:三条切片的相变扫描

脚本对 3 条切片(enemyAttack = 6 / 12 / 18)各做一次「粗扫 + 二分 + 带宽」,画出序参量曲线与两相相图。同一个 (固定参数, 参数值) 只跑一次(结果进缓存),所以总 run 数是唯一格数 × 种子数,而不是「切片数 × 格数 × 种子数」。

#!/usr/bin/env python3
# file: sim/gd_play_14_phase_read.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_14_phase_read.py
"""卷二 14.3 相变图的读法(S 层):粗扫 + 二分定位临界点,再量出**临界带宽**。

相变图要读三样东西:**序参量**(这里是胜率千分比)、**临界点**(序参量跨过阈值的那个参数值)、
**临界带宽**(序参量在过渡区间 [bandLow, bandHigh) 内跨越的参数宽度)。读错的三种方式:
① 把序参量当成绝对量;② 带宽小于扫描步长却当成「一级跳变」;③ 只画两相着色,看不见过渡带。

本机无 matplotlib,所以序参量曲线、相区着色、临界点标记、阈值虚线全部由 `xml.etree` 手绘,
两张图(曲线图 + 相图)都回读重建并逐格比对。胜率是**代理指标(proxy)**,只做相对比较。
全整数、无浮点、无 random。产出 out/play14_phase_curve.svg + out/play14_phase_map.svg + .json。
"""
from __future__ import annotations

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_14_phase.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402


def win_permille(spec: dict, over: dict, seeds: list, strat: list) -> int:
    """序参量:固定种子池上的胜率千分比(代理指标,只做相对比较)。"""
    w = 0
    for sd in seeds:
        s = copy.deepcopy(spec)
        s["seed"] = sd
        s["params"].update(over)
        w += 1 if ashlib.run_spec(s, strat)["won"] else 0
    return 1000 * w // len(seeds)


def scan_slice(spec, swept, fixed, seeds, strat, cfg, cache):
    """一条切片:粗扫定位跨越区间 → 二分到宽度 1 → 在临界点邻域量出临界带宽。"""
    cross = cfg["crossPermille"]
    key = tuple(sorted(fixed.items()))
    w = lambda v: cache.setdefault((key, v), win_permille(spec, {**fixed, swept: v}, seeds, strat))
    coarse = [(v, w(v)) for v in range(cfg["from"], cfg["to"] + 1, cfg["step"])]
    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 {"coarse": coarse, "critical": None, "rounds": 0, "band": None}
    a, b, rounds = bracket[0], bracket[1], 0
    while b - a > 1 and rounds < cfg["bisectRounds"]:
        mid = (a + b) // 2
        rounds += 1
        a, b = (mid, b) if w(mid) >= cross else (a, mid)
    span = cfg["bandSpan"]
    inband = [v for v in range(b - span, b + span + 1)
              if cfg["bandLow"] <= w(v) < cfg["bandHigh"]]
    band = (min(inband), max(inband), max(inband) - min(inband) + 1) if inband else None
    return {"coarse": coarse, "critical": b, "rounds": rounds, "band": band}


def draw_frame(root, box, xticks, yticks, title, sub):
    """手绘坐标框:网格线、两条轴、刻度线与刻度值。"""
    x0, y0, x1, y1 = box
    ashlib.svg_text(root, x0, 24, title, 15)
    ashlib.svg_text(root, x0, 42, sub, 11, "#94a3b8")
    for lab, px in xticks:
        ET.SubElement(root, "line", {"x1": str(px), "y1": str(y0), "x2": str(px),
                                     "y2": str(y1), "stroke": "#1e293b", "stroke-width": "1"})
        ashlib.svg_text(root, px, y1 + 16, lab, 9, "#94a3b8", "middle")
    for lab, py in yticks:
        ET.SubElement(root, "line", {"x1": str(x0), "y1": str(py), "x2": str(x1),
                                     "y2": str(py), "stroke": "#1e293b", "stroke-width": "1"})
        ashlib.svg_text(root, x0 - 6, py + 3, lab, 9, "#94a3b8", "end")
    ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y1), "x2": str(x1),
                                 "y2": str(y1), "stroke": "#64748b", "stroke-width": "1"})
    ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y0), "x2": str(x0),
                                 "y2": str(y1), "stroke": "#64748b", "stroke-width": "1"})


def draw_curve(rows: list, cfg: dict, path: str) -> int:
    """序参量曲线:三条切片各一条 polyline;首条的临界带画成底纹矩形,阈值画成虚线。"""
    W, H, box = 700, 330, (78, 66, 656, 268)
    x0, y0, x1, y1 = box
    lo, hi = cfg["from"], cfg["to"]
    px = lambda v: x0 + (v - lo) * (x1 - x0) // (hi - lo)
    py = lambda v: y1 - v * (y1 - y0) // 1000
    root = ashlib.svg_begin(W, H)
    draw_frame(root, box, [(str(v), px(v)) for v in range(lo, hi + 1, cfg["step"])],
               [(str(v), py(v)) for v in (0, 250, 500, 750, 1000)],
               "相变图读法之一:序参量(胜率 ‰)随控制参数 enemyHp 变化",
               "粗扫步长 5 · 虚线=阈值 500‰ · 底纹=首个有过渡带的切片 · 轴/刻度/图例全部手绘")
    ET.SubElement(root, "line", {"x1": str(x0), "y1": str(py(500)), "x2": str(x1),
                                 "y2": str(py(500)), "stroke": "#64748b", "stroke-width": "1",
                                 "stroke-dasharray": "6 4"})
    shaded = next((r for r in rows if r["band"]), None)
    if shaded:
        b = shaded["band"]
        ET.SubElement(root, "rect", {"x": str(px(b[0])), "y": str(y0), "width": str(px(b[1]) - px(b[0])),
                                     "height": str(y1 - y0), "fill": cfg["palette"]["critical"],
                                     "fill-opacity": "0.18", "data-band": f"{b[0]}-{b[1]}",
                                     "data-band-slice": str(shaded["slice"])})
    for k, row in enumerate(rows):
        col = cfg["palette"]["series"][k]
        ET.SubElement(root, "polyline", {
            "points": " ".join(f"{px(v)},{py(val)}" for v, val in row["coarse"]),
            "fill": "none", "stroke": col, "stroke-width": "2", "data-slice": str(row["slice"])})
        if row["critical"] is not None:
            ET.SubElement(root, "circle", {"cx": str(px(row["critical"])), "cy": str(py(500)),
                                           "r": "5", "fill": col, "data-critical": str(row["critical"])})
        ET.SubElement(root, "rect", {"x": str(x1 - 96), "y": str(y0 + 8 + 18 * k), "width": "10",
                                     "height": "10", "fill": col})
        ashlib.svg_text(root, x1 - 82, y0 + 17 + 18 * k, f"enemyAttack={row['slice']}", 11, col)
    return ashlib.svg_save(root, path)


def draw_map(rows: list, cfg: dict, path: str) -> int:
    """相图:行=切片、列=参数分箱;格按两相着色,临界点连成相界线。"""
    cw, ch, left, top = 40, 48, 96, 92
    cols = list(range(cfg["from"], cfg["to"] + 1, cfg["step"]))
    W, H = left + cw * len(cols) + 40, top + ch * len(rows) + 60
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, 20, 28, "相变图读法之二:相图(行=切片 · 列=enemyHp 分箱 · 格=胜率‰)", 15)
    ashlib.svg_text(root, 20, 46, "两相着色只标 500‰ 两侧 · 相界线由二分临界点连成 · 过渡带在图上是渐变色", 11, "#94a3b8")
    for j, c in enumerate(cols):
        ashlib.svg_text(root, left + cw * j + cw // 2, top - 8, str(c), 9, "#cbd5e1", "middle")
    pts = []
    for i, row in enumerate(rows):
        ashlib.svg_text(root, left - 8, top + ch * i + ch // 2 + 4, f"ea{row['slice']}", 11, "#cbd5e1", "end")
        for j, (v, val) in enumerate(row["coarse"]):
            fill = cfg["palette"]["solvable"] if val >= cfg["crossPermille"] else cfg["palette"]["unsolvable"]
            ET.SubElement(root, "rect", {"x": str(left + cw * j + 1), "y": str(top + ch * i + 1),
                                         "width": str(cw - 2), "height": str(ch - 2), "fill": fill,
                                         "data-row": str(row["slice"]), "data-col": str(v),
                                         "data-value": str(val)})
            ashlib.svg_text(root, left + cw * j + cw // 2, top + ch * i + ch // 2 + 4, str(val),
                            8, "#e2e8f0", "middle")
        if row["critical"] is not None:
            pts.append(f"{left + (row['critical'] - cols[0]) * cw // cfg['step'] + cw // 2},"
                       f"{top + ch * i + ch // 2}")
    if len(pts) >= 2:
        ET.SubElement(root, "polyline", {"points": " ".join(pts), "fill": "none",
                                         "stroke": cfg["palette"]["critical"], "stroke-width": "2",
                                         "stroke-dasharray": "5 4"})
    return ashlib.svg_save(root, path)


def readback(path: str):
    text = open(path, encoding="utf-8").read()
    root = ET.fromstring(text)
    tags: dict = {}
    for e in root.iter():
        t = e.tag.split("}")[-1]
        tags[t] = tags.get(t, 0) + 1
    return len(text.encode("utf-8")), tags, root


def main() -> int:
    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"]]
    strat = list(cfg["strategyPattern"]) * cfg["strategyRepeats"]

    cache: dict = {}
    rows = []
    for ea in cfg["slices"]:
        r = scan_slice(spec, cfg["swept"], {"enemyAttack": ea}, seeds, strat, cfg, cache)
        rows.append({"slice": ea, **r})
    runs = len(cache) * len(seeds)

    print(f"seeds={len(seeds)}  切片 enemyAttack={cfg['slices']}  控制参数 {cfg['swept']} "
          f"{cfg['from']}..{cfg['to']} step {cfg['step']}  阈值={cfg['crossPermille']}‰")
    print(f"{'切片':>6}{'粗扫格':>7}{'二分轮':>7}{'临界':>6}{'临界带':>14}{'带宽':>6}  粗扫序参量")
    for r in rows:
        band = f"[{r['band'][0]},{r['band'][1]}]" if r["band"] else "无"
        wid = r["band"][2] if r["band"] else 0
        print(f"{r['slice']:>6}{len(r['coarse']):>7}{r['rounds']:>7}{r['critical']:>6}"
              f"{band:>14}{wid:>6}  {[v for _, v in r['coarse']]}")
    print(f"总 run 数={runs}(唯一格 {len(cache)} × {len(seeds)} 种子)")

    csize = draw_curve(rows, cfg, os.path.join(OUT, "play14_phase_curve.svg"))
    msize = draw_map(rows, cfg, os.path.join(OUT, "play14_phase_map.svg"))
    cbytes, ctags, croot = readback(os.path.join(OUT, "play14_phase_curve.svg"))
    mbytes, mtags, mroot = readback(os.path.join(OUT, "play14_phase_map.svg"))
    polys = [e for e in croot.iter() if e.tag.endswith("polyline")]
    crits = [e for e in croot.iter() if e.get("data-critical")]
    cells = [e for e in mroot.iter() if e.tag.endswith("rect") and e.get("data-value")]
    recon = {}
    for e in cells:
        recon.setdefault(int(e.get("data-row")), {})[int(e.get("data-col"))] = int(e.get("data-value"))
    mism = sum(1 for r in rows for v, val in r["coarse"] if recon[r["slice"]][v] != val)

    print(f"\n回读 phase_curve : ET.fromstring ok  bytes={cbytes}  tags={ctags}")
    print(f"回读 phase_map   : ET.fromstring ok  bytes={mbytes}  tags={mtags}")
    print(f"曲线 polyline={len(polys)} 条(每条点数={[len(p.get('points').split()) for p in polys]})"
          f"  临界点标记={len(crits)}")
    print(f"相图 格 rect(带 data-value)={len(cells)}(应为 {len(rows) * len(rows[0]['coarse'])})"
          f"  回读重建逐格不符={mism}")

    report = {"id": "gd_play_14_phase", "seeds": len(seeds), "runs": runs, "uniqueCells": len(cache),
              "rows": [{k: v for k, v in r.items() if k != "coarse"} |
                       {"coarse": [list(x) for x in r["coarse"]]} for r in rows],
              "svg": {"curveBytes": cbytes, "mapBytes": mbytes, "curveTags": ctags,
                      "mapTags": mtags, "reconMismatch": mism},
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play14_phase.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"\nelapsed={report['elapsedMs']} ms")
    print("wrote out/play14_phase_curve.svg out/play14_phase_map.svg out/play14_phase.json")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_14_phase_read.py:

seeds=32  切片 enemyAttack=[6, 12, 18]  控制参数 enemyHp 30..90 step 5  阈值=500‰
    切片    粗扫格    二分轮    临界           临界带    带宽  粗扫序参量
     6     13      3    79             无     0  [1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 0, 0, 0]
    12     13      3    79             无     0  [1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 0, 0, 0]
    18     13      2    67       [65,69]     5  [1000, 1000, 1000, 1000, 968, 968, 812, 500, 375, 93, 0, 0, 0]
总 run 数=3648(唯一格 114 × 32 种子)

回读 phase_curve : ET.fromstring ok  bytes=5919  tags={'svg': 1, 'rect': 5, 'text': 23, 'line': 21, 'polyline': 3, 'circle': 3}
回读 phase_map   : ET.fromstring ok  bytes=11626  tags={'svg': 1, 'rect': 40, 'text': 57, 'polyline': 1}
曲线 polyline=3 条(每条点数=[13, 13, 13])  临界点标记=3
相图 格 rect(带 data-value)=39(应为 39)  回读重建逐格不符=0

elapsed=187 ms
wrote out/play14_phase_curve.svg out/play14_phase_map.svg out/play14_phase.json

读数

第一件事看临界点在哪、是不是同一条。三条切片的二分临界点分别是 79、79、67(enemyHp,越大越难)。ea=6 与 ea=12 的临界点同为 79——在 5 的粗扫步长下它们分辨不出差别,因为两条粗扫序参量一模一样(都在第 11 格从 1000 掉到 0)。到 ea=18 才前移到 67。临界点相同不代表两条曲线相同,只代表在当前分辨率下它们的跃迁位置一致。

第二件事看临界带宽。ea=6 与 ea=12 的带宽都是 0——序参量从 1000 直接跳到 0,中间没有任何格落在 400–600‰。这是一级跳变的样子,也可能是步长太粗跳过了过渡带。ea=18 不一样:粗扫序参量是 1000,1000,1000,1000,968,968,812,500,375,93,0,其中 812、500、375 落在带宽窗口里,量出临界带 [65, 69]、带宽 5——过渡是平滑的。同一张图上,一条切片硬跳、一条平滑,这就是相变图要让你看见的差异。

第三件事看回读与去重。曲线 SVG 解析成功(5919 字节,3 条 polyline 各 13 点、3 个临界点圆点),相图 39 格(3 切片 × 13 档)重建回源矩阵后不符 = 0。总 run 数是 3648,而不是「3 切片 × 13 格 × 32 种子 = 1248… 」直接相乘——因为缓存让同一个 (固定参数, 参数值) 只算一次,唯一的格只有 114 个。这一点很重要:相变扫描的算力全花在重复格上,去重后能省掉大半。

自检表

检查项合格标准
回读与逐格比对两张 SVG 都 ET.fromstring 解析成功,相图 39 格重建回源矩阵后不符数为 0
读数三件套每条切片都给出序参量、临界点、临界带宽;带宽为 0 时明确标注「当前分辨率下不可分辨」
整数纪律粗扫、二分、带宽全用整数地板除,无浮点、无 random;轴、刻度、图例、相界线全部手绘
代理指标声明序参量胜率是 proxy,只作相对比较,禁绝对化

本章的三族图到齐了:一维的曲线与谱、二维的热力图、带跃迁的相变图。它们共享同一套纪律——整数、手绘、可回读。下一章进入另一个方向:不是把指标画出来,而是反过来,从你想要的指标形状去推该设什么参数。

阅读导航:上一节:14.2 策略空间的热力图 · 下一节:15.1 从指标反推参数 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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