6.3 相变扫描的实现

相变扫描的工程做法:先用粗扫定位跨越 500‰ 的相邻区间,再用二分把临界点钉到 ±1。本节比较扫描器与全网格的格数、run 数与临界点是否一致,算出扫描只花全网格 255‰ 的算力;B 层再扫六个候选步长,检验步长过细与过粗各自的代价与软告警阈值。

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 自我决定理论:自主、胜任、关联 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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