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 从指标反推参数 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。