6.2 相变扫描:游戏被解开的点
《小游戏开发实战》卷一 7.2 难度曲线与心流 那篇的 B 层问的是「难度指标对参数有多敏感」,落点在一根曲线的斜率;本节换一个问法:参数扫过某个值之后,系统性质会不会整体改变。两者不是深浅之别而是类型之别——敏感度是局部量,相变是整体量。本节要回答的是玩家嘴里的那句「到某个数值,这游戏忽然就被解开了」,并把它写成一个可以二分出来的整数。
序参量与临界点:两个必须先下定义的东西
「相变」这个词不能直接借来用,得先指定序参量——一个能把两相区分开的宏观量。
| 概念 | 本节的定义 | 为什么这么定 |
|---|---|---|
| 序参量 | 胜率,千分比整数 | 两相天然分开:一相是「怎么打都赢」,另一相是「怎么打都输」 |
| 临界点 | 首个胜率 < 500‰ 的参数值 | 二分法需要「左真右假」的单调判据,整数阈值最省事 |
| 临界宽度 | 首个胜率 ≤ 100‰ 的值 − 最后一个胜率 ≥ 900‰ 的值 | 用两个远离中位的横切面量过渡带,避免被 500‰ 附近的抖动带偏 |
为什么不用平均剩余 HP 当序参量?因为它在两相里都不是零:赢的一相剩 44,输的一相剩 39,相差只有 5。序参量的判别力要够大,否则二分会在噪声里来回跳。胜率从 875‰ 到 0‰ 是一步到底的跳变,判别力足够。
三步走:粗扫定位、二分细化、邻域细扫
全网格逐点扫(20 到 80 共 61 格 × 16 种子)当然也能得到答案,但那是没有理由的暴力。本节把计算量拆成三段,每一段都只买它需要的信息:
| 阶段 | 格子数 | 目的 | 计算量控制 |
|---|---|---|---|
| 粗扫 | 13(步长 5) | 找到跨越 500‰ 的相邻区间 | 步长 ≥ 4 |
| 二分 | ≤ 6(每轮 1 格) | 把区间压到 ±1 | 最多 6 轮 |
| 邻域细扫 | ≤ 21(步长 1) | 量出临界宽度 | 半径 10 |
下面这段是 S 层脚本 sim/gd_mech_06_phase.py。它把这三步串起来,用 cache 保证同一个参数值只跑一次种子集合,最后把临界点、临界宽度、二分轨迹与耗时一起写进 out/mech06_phase.json,并画一张临界点邻域的相变图。
#!/usr/bin/env python3
# file: sim/gd_mech_06_phase.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_06_phase.py
"""卷一 6.2 相变扫描(S 层):游戏被解开的那个参数值。
序参量 = 胜率(千分比);临界点 = 胜率从「接近 1000」掉到「接近 0」的参数值,
本脚本把它定义成**首个胜率 < 500‰ 的参数值**。三步走,全部整数:
1. 粗扫(步长 5)定位跨越 500‰ 的相邻区间;
2. 二分细化(每轮只加 1 个新格子,最多 6 轮)压到 ±1;
3. 临界点邻域细扫(步长 1)算**临界宽度** = 首个胜率 ≤ 100‰ 的参数值
− 最后一个胜率 ≥ 900‰ 的参数值。
产出 out/mech06_phase.json + out/mech06_phase.svg;报告实测总耗时(整数毫秒)。
计算量控制:粗扫步长 ≥ 4、每格种子数 ≤ 16、二分 ≤ 6 轮——分钟级跑完。
"""
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_mech_06_perturb.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
def win_permille(spec, param, value, seeds):
wins = 0
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"][param] = value
if ashlib.run_spec(s)["won"]:
wins += 1
return 1000 * wins // len(seeds)
def draw(fine, critical, param, path):
"""临界点邻域的相变图:每个取值一根竖条(高度 = 胜率),临界点处一条红竖线。"""
W, H, x0, y0, y1, bw = 620, 300, 60, 40, 240, 40
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, x0, 24, f"{param} 相变:临界点 = {critical}(红竖线)", 15)
for i, (v, p) in enumerate(fine):
h = p * (y1 - y0) // 1000
x = x0 + i * bw
ET.SubElement(root, "rect", {"x": str(x + 6), "y": str(y1 - h), "width": str(bw - 12),
"height": str(h), "fill": "#38bdf8"})
ashlib.svg_text(root, x + bw // 2, y1 + 18, v, 11, "#94a3b8", "middle")
ashlib.svg_text(root, x + bw // 2, y1 - h - 6, p, 10, "#cbd5e1", "middle")
xi = x0 + (critical - fine[0][0]) * bw + bw // 2
ET.SubElement(root, "line", {"x1": str(xi), "y1": str(y0), "x2": str(xi), "y2": str(y1),
"stroke": "#ef4444", "stroke-width": "2"})
ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y1), "x2": str(x0 + len(fine) * bw),
"y2": str(y1), "stroke": "#64748b"})
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)
param, seeds = cfg["param"], cfg["seeds"]
lo, hi, step = cfg["coarse"]["from"], cfg["coarse"]["to"], cfg["coarse"]["step"]
cross = cfg["bisect"]["crossPermille"]
hi_band, lo_band = cfg["bisect"]["bandHighPermille"], cfg["bisect"]["bandLowPermille"]
max_rounds, radius = cfg["bisect"]["maxRounds"], cfg["fine"]["radius"]
cache = {}
def w(v):
if v not in cache:
cache[v] = win_permille(spec, param, v, seeds)
return cache[v]
coarse = [(v, w(v)) for v in range(lo, hi + 1, step)]
bracket = None
for i in range(len(coarse) - 1):
a, b = coarse[i], coarse[i + 1]
if a[1] >= cross > b[1]:
bracket = (a[0], b[0])
break
if bracket is None:
print("no bracket: 粗扫未发现跨越 500‰ 的相邻区间,请扩大网格")
return 1
lo_b, hi_b, rounds = bracket[0], bracket[1], 0
trace = []
while hi_b - lo_b > 1 and rounds < max_rounds:
mid = (lo_b + hi_b) // 2
rounds += 1
pm = w(mid)
trace.append({"round": rounds, "mid": mid, "winPermille": pm,
"keep": "low" if pm >= cross else "high"})
if pm >= cross:
lo_b = mid
else:
hi_b = mid
critical = hi_b
f_lo, f_hi = max(lo, critical - radius), min(hi, critical + radius)
fine = [(v, w(v)) for v in range(f_lo, f_hi + 1)]
h_high = max(v for v, p in fine if p >= hi_band)
h_low = min(v for v, p in fine if p <= lo_band)
width = h_low - h_high
elapsed_ms = (time.perf_counter_ns() - t0) // 1000000
report = {"id": "gd_mech_06_phase", "param": param, "seeds": len(seeds),
"coarse": {"from": lo, "to": hi, "step": step},
"coarseCurve": [{"value": v, "winPermille": p} for v, p in coarse],
"bracket": [bracket[0], bracket[1]], "bisectRounds": rounds, "bisectTrace": trace,
"criticalPoint": critical, "bandHighPermille": hi_band, "bandLowPermille": lo_band,
"hHigh": h_high, "hLow": h_low, "criticalWidth": width,
"fineCurve": [{"value": v, "winPermille": p} for v, p in fine],
"runs": len(cache) * len(seeds), "cells": len(cache), "elapsedMs": elapsed_ms}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "mech06_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(fine, critical, param, os.path.join(OUT, "mech06_phase.svg"))
for v, p in coarse:
print(f"coarse {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']}")
for v, p in fine:
print(f"fine {param}={v:>3} winRate={p:>4}‰")
print(f"criticalPoint={critical} hHigh={h_high}(≥{hi_band}‰) hLow={h_low}(≤{lo_band}‰) width={width}")
print(f"cells={len(cache)} runs={len(cache) * len(seeds)} elapsed={elapsed_ms} ms")
print(f"svg={size} bytes")
print("wrote out/mech06_phase.json out/mech06_phase.svg")
ET.parse(os.path.join(OUT, "mech06_phase.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_06_phase.py:
coarse enemyHp= 20 winRate=1000‰
coarse enemyHp= 25 winRate=1000‰
coarse enemyHp= 30 winRate=1000‰
coarse enemyHp= 35 winRate=1000‰
coarse enemyHp= 40 winRate=1000‰
coarse enemyHp= 45 winRate=1000‰
coarse enemyHp= 50 winRate=1000‰
coarse enemyHp= 55 winRate=1000‰
coarse enemyHp= 60 winRate=1000‰
coarse enemyHp= 65 winRate=1000‰
coarse enemyHp= 70 winRate=1000‰
coarse enemyHp= 75 winRate= 937‰
coarse enemyHp= 80 winRate= 0‰
bisect round1: mid= 77 winRate= 875‰ keep=low
bisect round2: mid= 78 winRate= 875‰ keep=low
bisect round3: mid= 79 winRate= 0‰ keep=high
fine enemyHp= 69 winRate=1000‰
fine enemyHp= 70 winRate=1000‰
fine enemyHp= 71 winRate=1000‰
fine enemyHp= 72 winRate=1000‰
fine enemyHp= 73 winRate= 937‰
fine enemyHp= 74 winRate= 937‰
fine enemyHp= 75 winRate= 937‰
fine enemyHp= 76 winRate= 875‰
fine enemyHp= 77 winRate= 875‰
fine enemyHp= 78 winRate= 875‰
fine enemyHp= 79 winRate= 0‰
fine enemyHp= 80 winRate= 0‰
criticalPoint=79 hHigh=75(≥900‰) hLow=79(≤100‰) width=4
cells=22 runs=352 elapsed=12 ms
svg=4182 bytes
wrote out/mech06_phase.json out/mech06_phase.svg
临界点为什么恰好是 79
三行数字就把这个 79 解释清楚了。把敌人血量设成无穷大再跑同一套种子,每个种子在固定策略下能打出的伤害上限是:14 个种子 78、1 个种子 75、1 个种子 72。于是胜率随 enemyHp 的曲线就是一个阶梯函数:
enemyHp | 谁还赢 | 胜率 |
|---|---|---|
| 20–72 | 全部 16 个 | 1000‰ |
| 73–75 | 输出上限 ≥ 75 的 15 个 | 937‰ |
| 76–78 | 输出上限 = 78 的 14 个 | 875‰ |
| 79–80 | 无人能达到 79 | 0‰ |
所以临界点 = 最大输出上限 + 1 = 78 + 1 = 79;临界宽度 = 4 = 75 到 79 的端点差,而这个 4 正是种子之间输出上限的散布(78 与 75 之间的差)。换句话说:临界宽度不是机制的性质,是种子集合的性质。换成 64 个种子,散布可能变大,宽度就变宽;这不是噪声,是必须说清楚的口径依赖。
诚实边界:这是阈值现象,不是涌现式相变
必须承认:这里的相变是**「固定策略的输出上限」与「敌人血量」两条线的相交**,而不是自组织临界那类涌现相变。它之所以看起来像相变,是因为序参量在两相之间一步到底(875‰ → 0‰),而不是因为系统内部产生了新的结构。
这个区别有实际后果。如果策略是自适应的(玩家会根据局面换打法),那么输出上限本身会随参数变化,「上限 vs 血量」的交叉就不再是一个固定的整数,过渡带会显著变宽——那才是 6.3 要处理的脆弱性来源。本节的 79 属于上界情形:它给出的是「最坏情况下游戏在第几格被解开」,是个有用的下界,但不是全部真相。
自检表
| 检查项 | 合格标准 |
|---|---|
| 序参量先定义 | 正文写明序参量是什么、为什么不用另一个候选量 |
| 判据可二分 | 临界点是「首个 < 500‰ 的值」,左右单调、整数可比 |
| 宽度有定义 | 临界宽度写成两个横切面之差,不用「差不多」描述 |
| 计算量受控 | 粗扫 ≥ 4、每格 ≤ 16 种子、二分 ≤ 6 轮,并报实测耗时 |
| 说法诚实 | 写「阈值现象」并给出反例条件,不冒充涌现相变 |
临界点定下来了,但它只回答了「在哪儿变」。设计上更要紧的是**「变的那一格有多宽」**——下一节把临界点附近的胜率逐格分类,把稳健区间与脆弱区间分开,并给出软告警清单。
阅读导航:上一节:6.1 参数敏感度 · 下一节:6.3 稳健性与告警 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。