5.3 构筑的相变

把牌组规模当成被扫的构筑参数:往《灰烬牌库》里加废牌,牌组从 12 张膨胀到 32 张,128 个种子上的胜率从 1000‰ 掉到 46‰。粗扫步长 4 框住跨越 500‰ 的区间 [20, 24],二分 2 轮钉出临界牌组规模 22,与步长 1 的全网格逐个一致;相变带宽 13,2688 次 run 实跑约 28 毫秒。

5.3 构筑的相变

《独立游戏平衡与经济设计》 把「平衡」写成一条条经验法则,读不出参数扫到哪里会换相;《小游戏开发实战》的核心循环拆解 量的是循环各阶段的时长占比,也不回答「构筑在哪一刻崩掉」。本节把构筑当成一个可扫的参数空间:固定策略与规则,只让牌组规模往上长,看胜率在哪里从「几乎必胜」塌到「几乎必败」,并把这个位置钉到一个整数上。

被扫的参数:牌组规模

牌组构筑的经典失败模式是牌组膨胀:往牌库里塞进一堆用不上的牌,起手摸到关键牌的概率被稀释。把这件事故意做极端——每次往基线牌库里加 N 张废牌(费用 1、伤害 0、格挡 0,既不打伤害也不挡伤害,只占手牌位与能量),牌组规模就从 12 张一路涨到 32 张:

牌组规模1214161820222426283032
废牌数 N02468101214161820

序参量取 128 个种子上的胜率(千分比)——这是代理指标(proxy),只做相对比较。扫法是标准的两段:粗扫步长 4 先框住跨越 500‰ 的相邻区间,再二分到 ±1,最后用步长 1 的全网格作真值逐点比对,确认扫描器没有一步跨过换相区间。

P 层:扫描器与全网格并排

测试把扫描器与全网格跑在同一份配置上,并报告格数、run 数与总耗时:

// file: core/test/gd_proto_05_phase.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_05_phase.test.ts'
//
// 5.3 构筑的相变(P 层):扫一个**构筑参数**——牌组规模。往基线牌库里加 N 张「废牌」
// (cost 1 / damage 0 / block 0),牌组从 12 张膨胀到 32 张,序参量取 128 个种子上的
// 胜率(千分比,proxy)。粗扫步长 4 定位跨越 500‰ 的相邻区间,再二分到 ±1,最后与步长 1
// 的全网格逐点比对,确认扫描器没有跨过换相点。脚本报告格数、run 数、相变带宽与总耗时。
// 全整数、无浮点、无 random;胜率是代理指标,只做相对比较,禁绝对化。

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

import { runSpec } from "../src/ashdeck/combat.ts";
import type { Spec } from "../src/ashdeck/types.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const SPEC: Spec = JSON.parse(readFileSync(join(ROOT, "config", "play_spec.json"), "utf8"));
const POOL = Array.from({ length: 128 }, (_, i) => 301 + i);
const DECK0 = SPEC.cards.reduce((a, c) => a + c.count, 0); // 12
const LO = DECK0, HI = DECK0 + 20, COARSE = 4, CROSS = 500;

/** 构筑 = 往牌库里加 N 张废牌,牌组规模 = 12 + N。 */
function withDuds(deckSize: number): Spec {
  const sp: Spec = JSON.parse(JSON.stringify(SPEC));
  sp.cards = sp.cards.concat([
    { id: "dud", cost: 1, damage: 0, block: 0, count: deckSize - DECK0, rarity: "common" },
  ]);
  return sp;
}

/** 序参量:种子池上的胜率(千分比)。代理指标,只做相对比较。 */
function winPermille(deckSize: number): number {
  const sp = withDuds(deckSize);
  let w = 0;
  for (const sd of POOL) {
    const s: Spec = JSON.parse(JSON.stringify(sp));
    s.seed = sd;
    if (runSpec(s).won) w += 1;
  }
  return Math.floor((1000 * w) / POOL.length);
}

test("相变扫描:粗扫 + 二分定位「构筑被解开」的牌组规模", () => {
  const t0 = process.hrtime.bigint();
  const cache = new Map<number, number>();
  const wp = (d: number): number => {
    if (!cache.has(d)) cache.set(d, winPermille(d));
    return cache.get(d) as number;
  };

  // 粗扫:步长 4
  const coarse: [number, number][] = [];
  for (let d = LO; d <= HI; d += COARSE) coarse.push([d, wp(d)]);
  let bracket: [number, number] | null = null;
  for (let i = 0; i < coarse.length - 1; i += 1) {
    if (coarse[i][1] >= CROSS && coarse[i + 1][1] < CROSS) bracket = [coarse[i][0], coarse[i + 1][0]];
  }
  assert.ok(bracket, "粗扫应找到一个跨越 500‰ 的相邻区间");

  // 二分细化到 ±1
  let a = (bracket as [number, number])[0];
  let b = (bracket as [number, number])[1];
  let rounds = 0;
  while (b - a > 1) {
    const mid = (a + b) >> 1;
    rounds += 1;
    if (wp(mid) >= CROSS) a = mid; else b = mid;
  }
  const critical = b;

  // 全网格(步长 1)作真值
  const full: number[] = [];
  for (let d = LO; d <= HI; d += 1) full.push(wp(d));
  const fullCritical = LO + full.findIndex((v) => v < CROSS);
  const band = full.filter((v) => v >= 100 && v <= 900).length;
  const elapsedMs = Number(process.hrtime.bigint() - t0) / 1e6;

  console.log("=== 5.3 构筑的相变扫描(牌组规模 12 → 32)===");
  console.log("粗扫(步长 4): " + coarse.map(([d, v]) => d + ":" + v).join("  "));
  console.log("跨越 500‰ 的区间: [" + (bracket as [number, number])[0] + ", " +
    (bracket as [number, number])[1] + "]  二分轮数=" + rounds);
  console.log("全网格(步长 1): " + full.join(" "));
  console.log("临界牌组规模=" + critical + "  全网格临界=" + fullCritical +
    "  一致=" + (critical === fullCritical));
  console.log("相变带宽(100‰..900‰)=" + band + "  格数=" + cache.size +
    "  runs=" + cache.size * POOL.length);
  console.log("elapsed=" + elapsedMs.toFixed(1) + " ms");

  assert.equal(coarse.length, 6);
  assert.deepEqual(bracket, [20, 24]);
  assert.equal(rounds, 2);
  assert.equal(critical, 22);
  assert.equal(critical, fullCritical);
  assert.equal(cache.size, 21);
  assert.equal(cache.size * POOL.length, 2688);
  assert.equal(band, 13);
});

实跑 node --test 'core/test/gd_proto_05_phase.test.ts':

=== 5.3 构筑的相变扫描(牌组规模 12 → 32)===
粗扫(步长 4): 12:1000  16:945  20:625  24:281  28:117  32:46
跨越 500‰ 的区间: [20, 24]  二分轮数=2
全网格(步长 1): 1000 1000 1000 992 945 937 875 734 625 609 492 328 281 328 195 164 117 117 109 78 46
临界牌组规模=22  全网格临界=22  一致=true
相变带宽(100‰..900‰)=13  格数=21  runs=2688
elapsed=28.4 ms
✔ 相变扫描:粗扫 + 二分定位「构筑被解开」的牌组规模
ℹ tests 1
ℹ pass 1
ℹ fail 0

读数

把全网格摊成「牌组规模 → 胜率」的表,换相发生在哪里一眼可见:

牌组规模1213141516171819202122
胜率(‰)100010001000992945937875734625609492
牌组规模23242526272829303132
胜率(‰)3282813281951641171171097846

三个数值得记住。第一,临界牌组规模 = 22:12 到 21 张时胜率仍在 500‰ 以上,到 22 张就跌破,二分两轮给出的 22 与全网格「取第一个跌破 500‰ 的规模」完全一致。第二,相变带宽 = 13:胜率落在 100‰–900‰ 之间的规模有 13 个(18 到 30),这段就是「构筑正在被解开」的过渡区——它不窄,说明牌组膨胀不是一刀切的崩,而是一个逐渐失血的过程。第三,2688 次 run_spec 只花了 28 毫秒:扫描器用 21 格拿到与 21 格全网格相同的临界点,代价是分钟级上限的一个零头。

三种读法:点、带、坡

同一条曲线能读出三样东西,它们回答的是三个不同的问题:

读法取值回答的问题
临界点牌组规模 22构筑在哪一刻「被解开」
相变带宽13(规模 18–30)解开是突变,还是逐渐失血
曲线坡度从 492‰ 掉到 109‰ 用掉 9 个规模强度对牌组规模有多敏感

序参量为什么取胜率

扫相变要先定序参量。候选不止一个,但只有一个同时满足「单调、整数、可跨品类复用」:

候选序参量怎么算采用与否
胜率(千分比)128 个种子上取胜的比例采用:整数、单调、六个品类通用
平均剩余 HP种子池上 playerHp 的均值不用:胜局里它被「打得多干净」主导,掩盖「打不打得死」
平均结束回合种子池上 turn 的均值不用:败局的 turn 被策略长度截断,均值被压平
平均伤害 dealt种子池上 dealt 的均值备选:与胜率同向,但在 enemyHp 处封顶,高段失去分辨率

扫描预算:省在哪里

扫描器单独跑只需要 6 个粗扫格 + 2 轮二分 = 8 格,全网格要 21 格。上面那行 格数=21 是两者共用同一份缓存后的并集,不是扫描器单独的开销:

扫法格数run 数(×128 种子)临界点
粗扫 + 二分(单独跑)8102422
步长 1 全网格21268822
比值380‰380‰一致

噪声:曲线不是严格单调的

必须诚实指出,全网格在局部是非单调的:牌组 23 张胜率 328‰、24 张反而降到 281‰、25 张又回到 328‰。原因是 128 个种子的样本量下,胜率的格间差(约 50‰)与采样噪声同量级,而废牌只改变手牌构成、不改变任何规则,所以相邻规模之间并没有严格的单调保证。

这件事对扫描器有两个后果。其一,二分依赖局部单调性:在 [20, 24] 这个区间里胜率是单调下降的(625 → 492),所以二分能收敛到 22;若换一个噪声更大的参数,二分可能落在 21 或 23,与全网格差 ±1——这是方法的边界,不是 bug。其二,判据必须写成「取第一个跌破阈值的规模」,而不是「找最小值」,否则噪声会把它引到 24 张那个局部低点上。

风险现象本节的处置
采样噪声23/24/25 张胜率非单调临界判据写「首个 < 500‰」,不写「最小胜率」
二分跨过区间粗扫步长过大粗扫步长 4,且与步长 1 全网格比对 agree=true
带宽被误读为临界点13 个规模都在 100–900‰带宽单独报告,不当作「相变点」
代理指标绝对化胜率被当成「好玩」显式声明为 proxy,只做相对比较
扫描器与全网格不一致两者临界点相差 ±1本次一致(都是 22);若不一致须如实报告差值
缓存掩盖开销并集格数被当成扫描器开销扫描器单独开销另算:8 格 / 1024 run

自检表

检查项合格标准本节实测
扫描器有基准与全网格逐点比对临界点agree=true,两边都是 22
预算可算格数与 run 数都是整数扫描器 8 格 / 1024 run
分钟级远低于分钟级上限28.4 ms
带宽单列不把带宽当临界点带宽 13,临界点 22,分开报告
噪声如实非单调必须写出来23/24/25 张非单调,已列出
无浮点比值写成千分比380‰ / 46‰ / 1000‰ 全是整数
代理指标声明胜率是 proxy首段与「读数」各声明一次

扫描器的复用:换品类要改什么

这套扫描器的骨架与品类无关。第 6–10 章换成塔防 / 三消 / 平台跳跃 / 自走棋 / 放置时,只需要替换「造参数」这一处:

复用点5.3 的实现换品类要改什么
序参量128 种子上的胜率千分比一般不改
参数网格牌组规模 12→32,粗扫步长 4换成该品类的构筑参数与区间
造参数withDuds(n):往牌库加 N 张废牌换成该品类的构筑函数
临界判据首个跌破 500‰ 的规模阈值按品类的可接受胜率调整

本节实跑清单

命令产物实测
node --test 'core/test/gd_proto_05_phase.test.ts'断言结果1 测试全绿,0 失败;21 格 / 2688 run / 28.4 ms

第 5 章到此把贯穿案例的卡牌构筑讲完:5.1 拆字段、5.2 量方差、5.3 扫相变。下一章换一个品类,用同一份规格、同一套闸门,去解剖塔防——从「牌组规模」换成「塔位与索敌」,看这套方法还成不成立。

阅读导航:上一节:5.2 构筑的方差与平衡 · 下一节:6.1 塔防的机制解剖 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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