6.3 相变扫描的实现
《小游戏开发实战》卷一 7.2 难度曲线与心流
把难度做成一条可算的曲线,读的是曲线上每个点的值。相变扫描读的不是值,是值在哪里换相——一个位置,而不是一条曲线。这两件事的计算成本结构完全不同:曲线必须逐点算,换相位置却可以用粗扫定位 + 二分细化,只算少数几个点就逼近。本节把相变扫描当成一个工程问题来做:粗扫步长选多大、二分要几轮、总共跑多少次 run_spec、什么时候全网格会爆炸、爆炸时该怎么退回采样。前两节给的是「相变是什么」,本节给的是「怎么把它扫出来,且扫得起」。
扫描的三个旋钮
一个相变扫描器只暴露三个旋钮,其余都是它们的派生:
| 旋钮 | 配置字段 | 影响 | 调大的后果 |
|---|---|---|---|
| 粗扫步长 | boundary.coarseStep | 粗扫格数 | 越大越省,但可能跨过整个换相区间 |
| 每格种子数 | cellsPerScan | 每格分辨率与 run 数 | 越大越准,run 数线性涨 |
| 二分轮数 | bisect.maxRounds | 临界点精度 | 越大越准,最多 log2(步长) 轮 |
三者的乘积就是总 run_spec 次数。本节的目标是把它钉在分钟级:粗扫 5 条切片 × 13 格 + 每片 1 轮二分 = 78 格,乘 32 个种子 = 2496 次 run_spec,实跑 55 毫秒。下面这段是只读扫描配置 config/gd_play_06_scan.json,三个旋钮与阈值都写在里面,脚本只读不改:
{
"id": "gd_play_06_scan",
"note": "卷二第 6 章相变扫描:显式种子池 + 二维粗扫网格 + 二分细化 + 序参量/临界指数拟合窗口 + 预算与软告警阈值(全整数,禁浮点)",
"seedPool": [301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, 317, 318, 319, 320, 321, 322, 323, 324, 325, 326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, 364],
"cellsPerScan": 32,
"grid": {
"row": { "param": "enemyHp", "from": 40, "to": 90, "step": 5 },
"col": { "param": "enemyAttack", "from": 2, "to": 20, "step": 2 }
},
"slice": { "param": "enemyAttack", "fixed": { "enemyHp": 75 }, "from": 10, "to": 40, "step": 1 },
"exponentSweep": { "from": 10, "to": 60, "step": 1 },
"bisect": { "crossPermille": 500, "maxRounds": 8 },
"boundary": { "swept": "enemyHp", "fixedParam": "enemyAttack", "fixedValues": [10, 14, 18, 22, 26], "from": 40, "to": 100, "coarseStep": 5, "full2dColFrom": 2, "full2dColTo": 30 },
"exponent": { "logBase": 2, "windowMin": 1, "windowMax": 6, "windows": [[1, 4], [1, 6], [2, 8], [2, 10], [1, 10], [3, 12]] },
"budget": { "maxCells": 90, "maxRuns": 20000 },
"robustness": { "altPoolOffsets": [100, 200, 400], "criticalDriftWarn": 2, "winSpreadWarnPermille": 80, "exponentDriftWarnPermille": 500, "coarseStepCandidates": [2, 4, 6, 8, 12, 20] }
}
budget 是闸门:maxCells=90 是粗扫格数上限、maxRuns=20000 是总 run 数上限。脚本一旦算出发现在预算内的扫法不可行,就输出 needSampling=True 让调用方改用采样。下面这段 S 层脚本 sim/gd_play_06_scan.py 把扫描器与全网格并排跑:两者在同样的 5 条切片上各求一次临界 enemyHp,比较临界点是否一致、各跑多少格多少次、耗时多少;最后算二维全网格(步长 1)的 run 数,与 maxRuns 比,给出预算判据。
#!/usr/bin/env python3
# file: sim/gd_play_06_scan.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_06_scan.py
"""卷二 6.3 相变扫描的实现(S 层):粗扫 + 二分 vs 全网格,并把预算算清楚。
同一份相界,两种扫法:**扫描器**(每条切片先粗扫步长 5 定位跨越 500‰ 的相邻区间,再二分到
±1)与**全网格**(同样区间步长 1 逐点扫,当基准真值)。脚本对 5 条切片各求一次临界 enemyHp,
比较两种扫法的临界点是否一致、各跑多少格多少次、耗时多少,最后给出**预算判据**:当全网格的
run 数超过上限时改用「粗扫 + 二分 + 采样」。全整数、无浮点、无 random。
胜率是代理指标,只做相对比较。产出 out/play06_scan.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
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 scan(spec, swept, fixed, lo, hi, step, seeds, cross, max_rounds, cache):
key = lambda v: (swept, v, tuple(sorted(fixed.items())))
w = lambda v: cache.setdefault(key(v), wp(spec, {**fixed, swept: v}, seeds))
coarse = [(v, w(v)) for v in range(lo, hi + 1, 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 None, len(coarse), 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, len(coarse), rounds
def draw(points, path):
W, H, x0, y0, y1 = 620, 300, 70, 40, 240
xs = [p["fixed"] for p in points]
ys = [p["critical"] for p in points]
xlo, xhi, ylo, yhi = min(xs), max(xs), min(ys), max(ys)
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, x0, 24, "相界:临界 enemyHp 随 enemyAttack 变化", 15)
ET.SubElement(root, "polyline", {"points": " ".join(
f"{x0 + (p['fixed'] - xlo) * (W - 110) // (xhi - xlo or 1)},"
f"{y1 - (p['critical'] - ylo) * (y1 - y0) // (yhi - ylo or 1)}" for p in points),
"fill": "none", "stroke": "#38bdf8", "stroke-width": "2"})
for p in points:
x = x0 + (p["fixed"] - xlo) * (W - 110) // (xhi - xlo or 1)
y = y1 - (p["critical"] - ylo) * (y1 - y0) // (yhi - ylo or 1)
ET.SubElement(root, "circle", {"cx": str(x), "cy": str(y), "r": "4", "fill": "#38bdf8"})
ashlib.svg_text(root, x, y - 10, f"{p['fixed']}:{p['critical']}", 10, "#cbd5e1", "middle")
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, bd, budget = cfg["seedPool"][: cfg["cellsPerScan"]], cfg["boundary"], cfg["budget"]
cross, maxr = cfg["bisect"]["crossPermille"], cfg["bisect"]["maxRounds"]
swept, fp, lo, hi = bd["swept"], bd["fixedParam"], bd["from"], bd["to"]
cache, scan_pts, scan_cells = {}, [], 0
for fv in bd["fixedValues"]:
cp, cells, rounds = scan(spec, swept, {fp: fv}, lo, hi, bd["coarseStep"], seeds, cross, maxr, cache)
scan_pts.append({"fixed": fv, "critical": cp, "coarseCells": cells, "bisectRounds": rounds})
scan_cells += cells + rounds
scan_runs, t_scan = len(cache) * len(seeds), (time.perf_counter_ns() - t0) // 1000000
t1 = time.perf_counter_ns()
full_pts = [{"fixed": fv, "critical": next((v for v in range(lo, hi + 1)
if wp(spec, {fp: fv, swept: v}, seeds) < cross), None)} for fv in bd["fixedValues"]]
full_cells, full_runs = len(bd["fixedValues"]) * (hi - lo + 1), len(bd["fixedValues"]) * (hi - lo + 1) * len(seeds)
t_full = (time.perf_counter_ns() - t1) // 1000000
agree = all(a["critical"] == b["critical"] for a, b in zip(scan_pts, full_pts))
f2d_cells = (hi - lo + 1) * (bd["full2dColTo"] - bd["full2dColFrom"] + 1)
f2d_runs = f2d_cells * len(seeds)
need_sampling = f2d_runs > budget["maxRuns"]
elapsed_ms = (time.perf_counter_ns() - t0) // 1000000
report = {"id": "gd_play_06_scan", "boundary": bd, "seeds": len(seeds), "scanPoints": scan_pts,
"fullPoints": full_pts, "agree": agree, "scanCells": scan_cells, "scanRuns": scan_runs,
"scanMs": t_scan, "fullCells": full_cells, "fullRuns": full_runs, "fullMs": t_full,
"full2dCells": f2d_cells, "full2dRuns": f2d_runs,
"runRatioPermille": 1000 * scan_runs // full_runs, "budget": budget,
"needSampling": need_sampling, "elapsedMs": elapsed_ms}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play06_scan.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
size = draw(scan_pts, os.path.join(OUT, "play06_scan.svg"))
print(f"{fp:>12} 扫描临界{swept} 全网格临界{swept} 一致")
for a, b in zip(scan_pts, full_pts):
print(f"{a['fixed']:>12} {a['critical']:>11} {b['critical']:>13} "
f"{'是' if a['critical'] == b['critical'] else '否'}")
print(f"scan : cells={scan_cells} runs={scan_runs} {t_scan} ms")
print(f"full : cells={full_cells} runs={full_runs} {t_full} ms")
print(f"runRatio={1000 * scan_runs // full_runs}‰ agree={agree}")
print(f"full2d: cells={f2d_cells} runs={f2d_runs}(二维全网格,步长 1)")
print(f"needSampling={need_sampling}(full2d runs {f2d_runs} vs 上限 {budget['maxRuns']})")
print(f"elapsed={elapsed_ms} ms svg={size} bytes")
print("wrote out/play06_scan.json out/play06_scan.svg")
ET.parse(os.path.join(OUT, "play06_scan.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_06_scan.py:
enemyAttack 扫描临界enemyHp 全网格临界enemyHp 一致
10 79 79 是
14 79 79 是
18 65 65 是
22 56 56 是
26 48 48 是
scan : cells=78 runs=2496 55 ms
full : cells=305 runs=9760 87 ms
runRatio=255‰ agree=True
full2d: cells=1769 runs=56608(二维全网格,步长 1)
needSampling=True(full2d runs 56608 vs 上限 20000)
elapsed=142 ms svg=1339 bytes
wrote out/play06_scan.json out/play06_scan.svg
读数里最重要的一行是 agree=True:扫描器在 5 条切片上给出的临界点(79、79、65、56、48)与全网格逐个相同。这证明粗扫 + 二分没有因为跳过格子而漏掉换相位置。第二行是 runRatio=255‰:扫描器只花了全网格 255‰ 的 run 数(2496 对 9760)就得到同样的答案——省下的 745‰ 就是二分把「逐点扫」换成「逐层缩区间」的收益。第三行是预算判据:二维全网格(enemyHp 40–100 × enemyAttack 2–30,步长 1)要 56608 次 run_spec,超过 maxRuns=20000,所以 needSampling=True——当参数维数上去、步长又要求 1 的时候,全网格一定会爆炸,这时必须退回粗扫 + 二分 + 采样,而不是硬跑。
B 层:粗扫步长选多大
扫描器最贵的旋钮是粗扫步长:步长越小,粗扫格数越多(越贵);步长越大,越可能跨过整个换相区间(越险)。B 层脚本 sim/gd_play_06_scan_warn.py 把 5 条切片在六个候选步长(2/4/6/8/12/20)上各扫一遍,报出每条步长下的格数、找到换相区间的切片数与临界点。步长过细使格数超预算、或步长过粗漏掉区间,都只打印软告警,进程始终 exit 0。
#!/usr/bin/env python3
# file: sim/gd_play_06_scan_warn.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_06_scan_warn.py
"""卷二 6.3 相变扫描的实现(B 层):粗扫步长与预算的稳健性——软告警,不硬失败。
扫描器最贵的旋钮是**粗扫步长**:步长越小,粗扫格数越多(越贵);步长越大,越可能漏掉跨越
区间的相邻格(越险)。本脚本把 5 条切片在 6 个候选步长(2/4/6/8/12/20)上各扫一遍,报出:
找到区间的切片数、临界点、粗扫格数、耗时。步长过细使格数超预算、或步长过粗漏掉区间,都只
**打印软告警**,进程始终 exit 0。产出 out/play06_scan_warn.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 main():
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
seeds, bd, budget = cfg["seedPool"][: cfg["cellsPerScan"]], cfg["boundary"], cfg["budget"]
cross, maxr = cfg["bisect"]["crossPermille"], cfg["bisect"]["maxRounds"]
swept, fp, lo, hi = bd["swept"], bd["fixedParam"], bd["from"], bd["to"]
rows, warnings = [], []
for step in cfg["robustness"]["coarseStepCandidates"]:
cells, found, cps = 0, 0, []
for fv in bd["fixedValues"]:
w = lambda v: wp(spec, {fp: fv, swept: v}, seeds)
coarse = [(v, w(v)) for v in range(lo, hi + 1, step)]
cells += len(coarse)
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:
cps.append(None)
continue
a, b, r = bracket[0], bracket[1], 0
while b - a > 1 and r < maxr:
mid = (a + b) // 2
r += 1
a, b = (mid, b) if w(mid) >= cross else (a, mid)
cells, found = cells + r, found + 1
cps.append(b)
rows.append({"coarseStep": step, "cells": cells, "found": found, "critical": cps})
if found < len(bd["fixedValues"]):
warnings.append(f"步长 {step}:只有 {found}/{len(bd['fixedValues'])} 条切片找到跨越区间——步长过粗")
if cells > budget["maxCells"]:
warnings.append(f"步长 {step}:粗扫格数 {cells} 超预算 {budget['maxCells']}——步长过细")
report = {"id": "gd_play_06_scan_warn", "rows": rows, "maxCells": budget["maxCells"],
"warnings": warnings, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play06_scan_warn.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"{'步长':>5}{'格数':>7}{'找到':>7} 临界 enemyHp")
for r in rows:
print(f"{r['coarseStep']:>5}{r['cells']:>7}{r['found']:>5}/{len(bd['fixedValues']):<2} {r['critical']}")
print(f"maxCells={budget['maxCells']} warnings={len(warnings)}")
for wmsg in warnings:
print(f" [WARN] {wmsg}")
print(f"elapsed={report['elapsedMs']} ms exit=0(软告警不硬失败)")
print("wrote out/play06_scan_warn.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
步长 格数 找到 临界 enemyHp
2 160 5/5 [79, 79, 65, 56, 48]
4 90 5/5 [79, 79, 65, 56, 48]
6 68 5/5 [79, 79, 65, 56, 48]
8 55 5/5 [79, 79, 65, 56, 48]
12 48 5/5 [79, 79, 65, 56, 48]
20 43 5/5 [79, 79, 65, 56, 48]
maxCells=90 warnings=1
[WARN] 步长 2:粗扫格数 160 超预算 90——步长过细
elapsed=315 ms exit=0(软告警不硬失败)
wrote out/play06_scan_warn.json
六条步长给出同一组临界点([79, 79, 65, 56, 48]),全部 5/5 找到换相区间——说明这条相界的换相发生在很窄的参数窗口里,粗扫哪怕步长 20 也能框住它,因为步长 20 时相邻两格的胜率差已经足以跨越 500‰。但格数差异很大:步长 2 要 160 格(超预算 90,触发告警),步长 4 刚好 90 格(卡在预算线上),步长 20 只要 43 格。这条相界对步长不敏感,是因为它陡——如果换相区间比步长还宽,粗扫就会一步跨过去、直接漏掉,这才是步长过粗的真正风险。本节这条相界侥幸不宽,但脚本里的告警机制保证了:换个更缓的机制,超预算与漏区间都会被打印出来,而不是悄悄给一个错的临界点。
自检表
| 检查项 | 合格标准 |
|---|---|
| 扫描器有基准 | 与全网格逐条比对临界点,agree=True 才算扫描器可信 |
| 预算成文 | 格数与 run 数上限写进配置,超限输出 needSampling |
| 分钟级 | 全部扫描实跑百毫秒级,远低于分钟级上限 |
| 步长有告警 | 过细(超预算)与过粗(漏区间)都软告警,不硬失败 |
| 无浮点 | 格数、run 数、比值都是整数;比值写成千分比 |
| 代理指标声明 | 胜率是 proxy,只做相对比较 |
相变扫描给出了「参数扫到哪里换相」这个位置。但位置本身不是设计目标——设计者真正要的是「在换相之前,玩家还有多少可调空间」。从第 7 章开始,本卷转入玩家侧:不再只问机制怎么变,而问玩家为什么这样玩。
阅读导航:上一节:6.2 序参量与临界指数 · 下一节:7.1 自我决定理论:自主、胜任、关联 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。