6.2 相变扫描:游戏被解开的点

把「游戏被解开的点」定义成序参量(胜率)跌破 500‰ 的参数值:先粗扫定位跨越区间,再二分细化到 ±1,最后在临界点邻域逐点细扫算出临界宽度。本节给出可跑的 S 层相变扫描脚本、实测临界点、临界宽度与总耗时,并解释临界点为什么恰好落在固定策略的伤害预算边界上。

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无人能达到 790‰

所以临界点 = 最大输出上限 + 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 稳健性与告警 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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