5.3 构筑的相变
《独立游戏平衡与经济设计》 把「平衡」写成一条条经验法则,读不出参数扫到哪里会换相;《小游戏开发实战》的核心循环拆解 量的是循环各阶段的时长占比,也不回答「构筑在哪一刻崩掉」。本节把构筑当成一个可扫的参数空间:固定策略与规则,只让牌组规模往上长,看胜率在哪里从「几乎必胜」塌到「几乎必败」,并把这个位置钉到一个整数上。
被扫的参数:牌组规模
牌组构筑的经典失败模式是牌组膨胀:往牌库里塞进一堆用不上的牌,起手摸到关键牌的概率被稀释。把这件事故意做极端——每次往基线牌库里加 N 张废牌(费用 1、伤害 0、格挡 0,既不打伤害也不挡伤害,只占手牌位与能量),牌组规模就从 12 张一路涨到 32 张:
| 牌组规模 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 废牌数 N | 0 | 2 | 4 | 6 | 8 | 10 | 12 | 14 | 16 | 18 | 20 |
序参量取 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
读数
把全网格摊成「牌组规模 → 胜率」的表,换相发生在哪里一眼可见:
| 牌组规模 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 胜率(‰) | 1000 | 1000 | 1000 | 992 | 945 | 937 | 875 | 734 | 625 | 609 | 492 |
| 牌组规模 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | |
| 胜率(‰) | 328 | 281 | 328 | 195 | 164 | 117 | 117 | 109 | 78 | 46 |
三个数值得记住。第一,临界牌组规模 = 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 种子) | 临界点 |
|---|---|---|---|
| 粗扫 + 二分(单独跑) | 8 | 1024 | 22 |
| 步长 1 全网格 | 21 | 2688 | 22 |
| 比值 | 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 塔防的机制解剖 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。