11.3 战斗的相变与支配
站内 《输入、帧缓冲与打击感》 关心的是战斗打起来爽不爽;本节关心的是战斗的支配结构在哪一点翻转——把 11.2 那个静态的策略空间沿一个参数拉出一条曲线,看「一条策略压住另一条」什么时候成立、什么时候失效。这属于机制层的相变,与 《独立游戏平衡与经济设计》 的散文式难度讨论是两件事。
11.2 已经量过:在默认参数(enemyAttack = 6)下 slot1 严格支配 slot4。但那是一个点。把 enemyAttack 一路调大,敌方攻击越来越狠,slot4 的劣势会被放大还是被淹没?答案是一条单调下滑的支配边缘,而不是突变——支配关系是被参数磨掉的。
序参量与 500‰ 约定
序参量取支配边缘:在 16 个种子上,slot1 的得分严格高于 slot4 的比例(千分比)。1000‰ 表示 slot1 在每个种子上都赢 slot4(严格支配);0‰ 表示完全不再支配。「跨越 500‰」作为相变的约定判据——边缘从 ≥500‰ 跌到 <500‰ 的那一格即临界点。
支配边缘是代理指标(proxy):500‰ 是人为约定,不是物理常量;边缘值只用于相对比较(比较不同 enemyAttack、比较不同参数),禁绝对化。
粗扫 + 二分:把曲线压成几个数
全网格从 8 到 48 每步长 1 要跑 41 格 ×16 种子;粗扫步长 4 先定位跨越 500‰ 的区间,再在区间内二分细化到 ±1,最后用步长 1 全网格作真值逐点复验。脚本报告格数、run 数与相变带宽。
#!/usr/bin/env python3
# file: sim/gd_proto_11_3_phase_scan.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_11_3_phase_scan.py
"""卷三 11.3 战斗的相变与支配(S 层):粗扫 + 二分,找「支配关系翻转」的临界参数。
被扫参数 = `enemyAttack`(敌方每回合攻击力,8 → 48)。序参量 = **支配边缘(千分比)**:
在种子池上,策略 `slot1` 的得分**严格高于** `slot4` 的比例。当它为 1000‰ 时,slot1 在这批
环境上**严格支配** slot4;参数一路调大,边缘下滑,某个参数上翻转。
扫法两段:粗扫步长 4 先框住跨越 500‰ 的相邻区间,再二分到 ±1,最后用步长 1 全网格作真值
逐点比对,确认扫描器没有一步跨过翻转点。脚本报告格数、run 数、相变带宽与总耗时。
支配边缘是**代理指标(proxy)**:只做相对比较,禁绝对化。全整数、无浮点、无 random。
产出 out/gd_proto_11_phase.json + out/gd_proto_11_phase.svg。
"""
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_proto_11_combat.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
def slot_seq(k: int, turns: int, plays: int) -> list:
out = []
for _ in range(turns):
out += ["p" + str(k)] * plays + ["e"]
return out
def edge_permille(spec: dict, ea: int, seq_a: list, seq_b: list, seeds: list, scale: int) -> int:
"""支配边缘:seed 上 score(A) > score(B) 的比例(千分比)。"""
c = 0
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"]["enemyAttack"] = ea
sa = ashlib.run_spec(s, seq_a)
sb = ashlib.run_spec(s, seq_b)
va = scale * (1 if sa["won"] else 0) + sa["playerHp"]
vb = scale * (1 if sb["won"] else 0) + sb["playerHp"]
if va > vb:
c += 1
return 1000 * c // len(seeds)
def draw(points, path):
W, H, x0, y0, y1 = 640, 300, 70, 40, 240
xs = [p["param"] for p in points]
ys = [p["edge"] for p in points]
xlo, xhi = min(xs), max(xs)
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, x0, 24, "支配边缘随 enemyAttack 变化(全网格步长 1)", 15)
ET.SubElement(root, "polyline", {"points": " ".join(
f"{x0 + (p['param'] - xlo) * (W - 110) // (xhi - xlo or 1)},"
f"{y1 - p['edge'] * (y1 - y0) // 1000}" for p in points),
"fill": "none", "stroke": "#38bdf8", "stroke-width": "2"})
ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y1 - 500 * (y1 - y0) // 1000),
"x2": str(W - 40), "y2": str(y1 - 500 * (y1 - y0) // 1000),
"stroke": "#f472b6", "stroke-dasharray": "4 3"})
return ashlib.svg_save(root, path)
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, ps, ss = cfg["seeds"], cfg["phaseScan"], cfg["strategySpace"]
lo, hi, step = ps["from"], ps["to"], ps["coarseStep"]
cross, maxr = ps["crossPermille"], ps["maxRounds"]
seq_a = slot_seq(ps["challenger"], ss["turns"], ss["playsPerTurn"])
seq_b = slot_seq(ps["incumbent"], ss["turns"], ss["playsPerTurn"])
cache: dict = {}
def edge(v: int) -> int:
if v not in cache:
cache[v] = edge_permille(spec, v, seq_a, seq_b, seeds, ss["scoreScale"])
return cache[v]
coarse = [(v, edge(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)
critical, rounds = None, 0
if bracket is not None:
a, b = bracket
while b - a > 1 and rounds < maxr:
mid = (a + b) // 2
rounds += 1
a, b = (mid, b) if edge(mid) >= cross else (a, mid)
critical = b
full = [(v, edge(v)) for v in range(lo, hi + 1)]
full_critical = next((v for v, e in full if e < cross), None)
dominance_break = next((v for v, e in full if e < 1000), None)
band = sum(1 for _, e in full if 100 <= e <= 900)
elapsed = (time.perf_counter_ns() - t0) // 1000000
report = {
"id": "gd_proto_11_phase", "swept": ps["swept"], "from": lo, "to": hi,
"coarseStep": step, "seeds": len(seeds), "crossPermille": cross,
"challenger": "slot" + str(ps["challenger"]), "incumbent": "slot" + str(ps["incumbent"]),
"coarse": coarse, "bracket": bracket, "bisectRounds": rounds, "critical": critical,
"fullCritical": full_critical, "agree": critical == full_critical,
"dominanceBreak": dominance_break, "band": band,
"cells": len(cache), "runs": len(cache) * len(seeds), "elapsedMs": elapsed,
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "gd_proto_11_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([{"param": v, "edge": e} for v, e in full], os.path.join(OUT, "gd_proto_11_phase.svg"))
print(f"粗扫(步长 {step}): " + " ".join(f"{v}:{e}" for v, e in coarse))
print(f"跨越 {cross}‰ 的区间: {bracket} 二分轮数={rounds} 临界 {ps['swept']}={critical}")
print(f"全网格(步长 1): " + " ".join(f"{e}" for _, e in full))
print(f"全网格临界={full_critical} 一致={critical == full_critical}"
f" 严格支配破除点={dominance_break}")
print(f"相变带宽(100‰..900‰)={band} 格数={len(cache)} runs={len(cache) * len(seeds)}")
print(f"elapsed={elapsed} ms svg={size} bytes")
print("wrote out/gd_proto_11_phase.json out/gd_proto_11_phase.svg")
ET.parse(os.path.join(OUT, "gd_proto_11_phase.svg"))
return 0
if __name__ == "__main__":
sys.exit(main())
实跑输出(/opt/homebrew/bin/python3 sim/gd_proto_11_3_phase_scan.py):
粗扫(步长 4): 8:1000 12:1000 16:1000 20:937 24:750 28:750 32:437 36:0 40:0 44:0 48:0
跨越 500‰ 的区间: (28, 32) 二分轮数=2 临界 enemyAttack=30
全网格(步长 1): 1000 1000 1000 1000 1000 1000 1000 1000 1000 1000 1000 1000 937 937 812 812 750 750 750 750 750 750 437 437 437 250 250 0 0 0 0 0 0 0 0 0 0 0 0 0 0
全网格临界=30 一致=True 严格支配破除点=20
相变带宽(100‰..900‰)=13 格数=41 runs=656
elapsed=41 ms svg=828 bytes
wrote out/gd_proto_11_phase.json out/gd_proto_11_phase.svg
读数:临界点、带宽与代价
| 读数 | 值 | 含义 |
|---|---|---|
临界 enemyAttack | 30 | 边缘首次跌破 500‰ 的参数值 |
| 支配破除点 | 20 | 边缘首次跌破 1000‰(不再处处严格支配) |
| 相变带宽(100‰..900‰) | 13 | 边缘在中间带停留的格数,越宽过渡越缓 |
| 全网格 | 8→48 步长 1,共 41 格 | 真值曲线,二分结果与之一致 |
| 总 run 数 | 656 = 41 × 16 | 41 格各 16 种子,缓存去重 |
曲线是单调的:enemyAttack ≤ 16 时边缘恒为 1000‰,20 起跌破 1000‰,一路滑到 36 之后为 0‰。带宽 13 说明这不是一刀切的突变,而是一段渐变——这正是「相变」在本案例里的形态:临界点存在,但过渡有宽度。
P 层:把临界点钉成断言
P 层复算同一条扫描,把「粗扫区间恰为 [28, 32]、二分两轮、临界=30、与全网格一致、带宽=13、总格数=41、总 run=656」逐条断言。
// snippet: 11.3 P 层节选(core/test/gd_proto_11_3_phase.test.ts)
/** 支配边缘:seed 上 score(A) > score(B) 的比例(千分比)。 */
function edge(ea: number): number {
const hit = cache.get(ea);
if (hit !== undefined) return hit;
let c = 0;
for (const sd of SEEDS) {
const sp: Spec = JSON.parse(JSON.stringify(SPEC));
sp.seed = sd;
sp.params.enemyAttack = ea;
const a = runSpec(sp, SEQ_A), b = runSpec(sp, SEQ_B);
const va = SS.scoreScale * (a.won ? 1 : 0) + a.playerHp;
const vb = SS.scoreScale * (b.won ? 1 : 0) + b.playerHp;
if (va > vb) c += 1;
}
const v = Math.floor((1000 * c) / SEEDS.length);
cache.set(ea, v);
return v;
}
test("相变扫描:粗扫 + 二分定位「slot1 不再支配 slot4」的 enemyAttack", () => {
const coarse: [number, number][] = [];
for (let v = PS.from; v <= PS.to; v += PS.coarseStep) coarse.push([v, edge(v)]);
assert.deepEqual(coarse.map(([v]) => v), [8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48]);
let bracket: [number, number] | null = null;
for (let i = 0; i < coarse.length - 1; i += 1) {
if (coarse[i][1] >= PS.crossPermille && coarse[i + 1][1] < PS.crossPermille) {
bracket = [coarse[i][0], coarse[i + 1][0]];
}
}
assert.deepEqual(bracket, [28, 32]);
assert.equal(b, 30); // 二分两轮后的临界 enemyAttack
assert.equal(b, fullCritical); // 与步长 1 全网格真值一致
});
实跑输出(节选):
=== 11.3 战斗的相变与支配(enemyAttack 8 → 48)===
粗扫(步长 4): 8:1000 12:1000 16:1000 20:937 24:750 28:750 32:437 36:0 40:0 44:0 48:0
跨越 500‰ 的区间: [28, 32] 二分轮数=2 临界 enemyAttack=30
全网格(步长 1): 1000 1000 1000 1000 1000 1000 1000 1000 1000 1000 1000 1000 937 937 812 812 750 750 750 750 750 750 437 437 437 250 250 0 0 0 0 0 0 0 0 0 0 0 0 0 0
全网格临界=30 一致=true 严格支配破除点=20
相变带宽(100‰..900‰)=13 格数=41 runs=656
✔ 相变扫描:粗扫 + 二分定位「slot1 不再支配 slot4」的 enemyAttack (9.472209ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 342.787208
带宽与临界点都是代理指标:换一组策略、换种子池、换得分口径,数值都会变。它们只在「同一套口径下比较不同参数」时有意义,不能当成「战斗在第 30 点变难」这样的绝对结论。
阅读导航:上一节:11.2 战斗的策略空间与平衡 · 下一节:12.1 手感不是渲染:输入-反馈的机制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。