11.3 战斗的相变与支配

把 enemyAttack 从 8 扫到 48,序参量取「slot1 严格支配 slot4 的种子比例」(千分比):参数小时为 1000‰,调大后一路下滑。粗扫步长 4 框住跨越 500‰ 的区间,二分两轮到临界 enemyAttack=30,步长 1 全网格复验一致,相变带宽 13,全程 41 格 656 次 run。

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

读数:临界点、带宽与代价

读数值含义
临界 enemyAttack30边缘首次跌破 500‰ 的参数值
支配破除点20边缘首次跌破 1000‰(不再处处严格支配)
相变带宽(100‰..900‰)13边缘在中间带停留的格数,越宽过渡越缓
全网格8→48 步长 1,共 41 格真值曲线,二分结果与之一致
总 run 数656 = 41 × 1641 格各 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 手感不是渲染:输入-反馈的机制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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