6.3 塔防的难度曲线
《Phaser 塔防》
讲索敌怎么在引擎里实现、怎么随帧推进;本节是机制口径,问一个可算的问题:把波次强度 waveHpStep 从 0 往上推,防守到底在哪一步被解开?若要看品类分类与商业矩阵,《小游戏开发实战》的品类分类
已经写过;本节只扫塔防一个品类的难度参数。
6.1 拆字段、6.2 量空间,本节扫参数。扫出来的「胜率」是代理指标(proxy),只做相对比较,禁绝对化:胜率高只说明这批种子下守得住,不等于「这个难度更好玩」。
序参量与临界判据
难度曲线要有序参量。这里取「种子池胜率」(千分比):把 32 个固定种子各跑一遍,胜的种子数占 32 的千分比就是这一格的值。临界判据 crossPermille=500——胜率跌破 500‰ 的最小 waveHpStep 就是「防守被解开」的点。之所以取 500‰ 而不是 0,是因为「全部种子都失守」是一个更晚、更钝的点,而「过半失守」才是玩家真的会感到难度翻面的地方。
粗扫 + 二分:7 格钉到临界点
全网格逐点扫(step=1,从 0 到 60)要 61 格,太浪费。做法是粗扫 + 二分:先以 step=5 粗扫,找到第一段「前一点 ≥ 500‰、后一点 < 500‰」的相邻区间;再在区间内二分,直到区间宽度为 1。扫描配置全部落在 config/gd_proto_06_scan.json 的 scan 段:
"scan": {
"swept": "waveHpStep",
"from": 0,
"to": 60,
"coarseStep": 5,
"crossPermille": 500,
"maxRounds": 8,
"seedPool": [60601, 60602, 60603, 60604, 60605, 60606, 60607, 60608,
60609, 60610, 60611, 60612, 60613, 60614, 60615, 60616,
60617, 60618, 60619, 60620, 60621, 60622, 60623, 60624,
60625, 60626, 60627, 60628, 60629, 60630, 60631, 60632],
"grid2d": { "rowTo": 60, "colParam": "enemiesStep", "colFrom": 0, "colTo": 6 },
"budget": { "maxCells": 40, "maxRuns": 8000 }
}
粗扫在 0, 5, 10, 15 处停下(0→1000‰、5→1000‰、10→1000‰、15→0‰),锁定区间 [10, 15];二分三轮:12(≥500‰,左边界推到 12)、13(≥500‰,左边界 13)、14(<500‰,右边界 14),收敛到 14。
结果:临界宽度只有 1
waveHpStep | 种子池胜率 | 读法 |
|---|---|---|
| 13 | 937‰ | 守得住(30 / 32 种子胜) |
| 14 | 343‰ | 临界点(11 / 32 种子胜) |
| 15 | 0‰ | 全灭 |
13 → 14 掉 594‰,14 → 15 再掉 343‰;临界宽度只有 1——waveHpStep 每加 1,敌人每波多 1 点血,累积到第 14 档时刚好越过防守的余量,胜率断崖。这不是「线性变难」,是相变:临界点左侧胜率还在 900‰ 以上,右侧一格之内归零。同样的陡峭在 W 层页面的代理指标 proxy() 里也能看到——它扫出来的最小 waveHpStep 也是 14。
扫描的代价与预算判据
| 量 | 值 | 说明 |
|---|---|---|
| 粗扫格子 | 4 | 0, 5, 10, 15,命中即停 |
| 二分轮数 | 3 | 12 / 13 / 14 |
| 扫描总格子 | 7 | 粗扫 4 + 二分 3 |
| 扫描 run 数 | 224 | 7 格 × 32 种子 |
| 全网格格子 | 61 | step=1,0..60 |
| 全网格 run 数 | 1952 | 61 格 × 32 种子 |
| 扫描 / 全网格 | 114‰ | 省掉 88.6% 的 run |
| 二维网格 run 数 | 13664 | (60−0+1)×(6−0+1)×32,超过 maxRuns=8000 |
粗扫 + 二分只用 7 格 224 run 就钉到 14,全网格要 61 格 1952 run——省掉约 88.6%。但判据不只针对一维:当扫描升到二维(waveHpStep × enemiesStep),格数变成 61 × 7 = 427,run 数 427 × 32 = 13664,超过预算上限 8000,此时必须退回按索引采样(与 6.2 同一套判据:空间超预算就采样)。这是「扫描实现」里最容易忽略的一格:一维能精确,二维未必。
从临界点反推设计余量
临界点 14 不是「第 14 关」,它是参数上的一个坐标。把 waveHpStep 定在 13,防守还有余量;定在 14,已经过半失守;定在 15,全灭。同一份规格里,这三档就是三种难度:
| 档位 | waveHpStep | 种子池胜率 | 意图 |
|---|---|---|---|
| 教学 | 10 | 1000‰ | 让玩家把五个字段都摸一遍 |
| 主关 | 13 | 937‰ | 留一点容错,但要开始算升级次序 |
| 挑战 | 14 | 343‰ | 逼玩家拿出 6.2 里那条 4 步最优布防 |
再说一次口径:这三档只是同一套种子池下的相对难度,胜率是代理指标;换一批种子、换一个 enemiesStep,绝对数值都会变。能跨实现复用的不是「937‰」这个数,而是「临界宽度只有 1」这个结构性结论——它说明这个塔防变体的难度在临界处是断崖而非斜坡,调参时几乎没有缓冲带。
P 层:把扫描写成整数
core/test/gd_proto_06_3_difficulty_curve.test.ts 复刻了上面全部步骤,并把临界点与全网格逐点比对:
// file: core/test/gd_proto_06_3_difficulty_curve.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_06_3_difficulty_curve.test.ts'
//
// 卷三 6.3 塔防的难度曲线(P 层):扫波次强度参数 waveHpStep,找「防守被解开」的临界点。
// 粗扫定位跨越 500‰ 的相邻区间,再二分钉到 ±1,并与步长 1 的全网格逐点比对。
// 胜率是**代理指标**,只做相对比较;脚本报总耗时与 run 数,并给出「何时改用采样」的预算判据。
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 { tdRun } from "../genre/towerdefense.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const SCAN = JSON.parse(readFileSync(join(ROOT, "config", "gd_proto_06_scan.json"), "utf8"));
const S = SCAN.scan;
let runs = 0;
const cache = new Map<number, number>();
const winPermille = (v: number): number => {
const hit = cache.get(v);
if (hit !== undefined) return hit;
let w = 0;
for (const sd of S.seedPool) {
const sp = JSON.parse(JSON.stringify(SCAN));
sp.seed = sd;
sp.params.waveHpStep = v;
runs += 1;
if (tdRun(sp).won) w += 1;
}
const p = Math.floor((1000 * w) / S.seedPool.length);
cache.set(v, p);
return p;
};
const t0 = process.hrtime.bigint();
const coarse: number[] = [];
for (let v = S.from; v <= S.to; v += S.coarseStep) coarse.push(v);
let bracket = -1;
for (let i = 0; i + 1 < coarse.length; i += 1) {
if (winPermille(coarse[i]) < S.crossPermille) break;
if (winPermille(coarse[i + 1]) < S.crossPermille) { bracket = i; break; }
}
const coarseCells = cache.size;
let scanCritical: number | null = null;
let rounds = 0;
if (bracket >= 0) {
let a = coarse[bracket];
let b = coarse[bracket + 1];
while (b - a > 1 && rounds < S.maxRounds) {
const mid = Math.floor((a + b) / 2);
rounds += 1;
if (winPermille(mid) >= S.crossPermille) a = mid;
else b = mid;
}
scanCritical = b;
}
const scanCells = coarseCells + rounds;
const scanRuns = runs;
const scanMs = Number((process.hrtime.bigint() - t0) / 1000000n);
const t1 = process.hrtime.bigint();
let fullCritical: number | null = null;
let lastAbove = -1;
for (let v = S.from; v <= S.to; v += 1) {
if (winPermille(v) >= S.crossPermille) lastAbove = v;
else if (fullCritical === null) fullCritical = v;
}
const fullCells = S.to - S.from + 1;
const fullRuns = fullCells * S.seedPool.length;
const fullMs = Number((process.hrtime.bigint() - t1) / 1000000n);
const g = S.grid2d;
const grid2dCells = (g.rowTo - S.from + 1) * (g.colTo - g.colFrom + 1);
const grid2dRuns = grid2dCells * S.seedPool.length;
const needSampling = grid2dRuns > S.budget.maxRuns;
const elapsedMs = Number((process.hrtime.bigint() - t0) / 1000000n);
test("粗扫 + 二分找到临界点,且与步长 1 的全网格一致", () => {
assert.equal(scanCritical, 14);
assert.equal(fullCritical, 14);
assert.equal(scanCritical, fullCritical);
});
test("临界宽度只有 1:防守被解开的点很陡", () => {
assert.equal(lastAbove, 13);
assert.equal(fullCritical! - lastAbove, 1);
});
test("扫描比全网格省:扫描 run 数只占全网格的 114‰", () => {
assert.equal(coarseCells, 4);
assert.equal(rounds, 3);
assert.equal(scanCells, 7);
assert.equal(scanRuns, 224);
assert.equal(fullCells, 61);
assert.equal(fullRuns, 1952);
assert.equal(Math.floor((1000 * scanRuns) / fullRuns), 114);
});
test("预算判据:二维全网格 13664 run 超过上限 8000,须退回采样", () => {
assert.equal(grid2dCells, 427);
assert.equal(grid2dRuns, 13664);
assert.equal(S.budget.maxRuns, 8000);
assert.equal(needSampling, true);
});
test("扫描是确定性的,且总耗时在分钟级以内", () => {
assert.equal(scanMs + fullMs <= elapsedMs + 1, true);
assert.equal(elapsedMs < 60000, true);
const again = winPermille(14);
assert.equal(again, 343);
assert.equal(cache.get(13), 937);
});
test("临界点两侧的胜率单调:13 → 937‰、14 → 343‰、15 → 0‰", () => {
assert.equal(winPermille(13), 937);
assert.equal(winPermille(14), 343);
assert.equal(winPermille(15), 0);
assert.equal(winPermille(13) > winPermille(14), true);
});
✔ 粗扫 + 二分找到临界点,且与步长 1 的全网格一致
✔ 临界宽度只有 1:防守被解开的点很陡
✔ 扫描比全网格省:扫描 run 数只占全网格的 114‰
✔ 预算判据:二维全网格 13664 run 超过上限 8000,须退回采样
✔ 扫描是确定性的,且总耗时在分钟级以内
✔ 临界点两侧的胜率单调:13 → 937‰、14 → 343‰、15 → 0‰
ℹ tests 6
ℹ pass 6
ℹ fail 0
六条全绿。本机实测:粗扫 + 二分 39 ms,步长 1 全网格 148 ms,合计 190 ms——远在分钟级以内,正是粗扫 + 二分把 1952 run 压到 224 run 的结果。winPermille 带缓存,所以第 6 条再次取 13 / 14 时是命中缓存而非重算,这本身也是一条确定性证据:同一格重复取,值不变。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
node --test 'core/test/gd_proto_06_3_difficulty_curve.test.ts' | 断言结果 | 6 测试全绿,0 失败 |
| 粗扫 + 二分 | 临界点 | waveHpStep=14,7 格 / 224 run / 39 ms |
| 步长 1 全网格 | 对照临界点 | waveHpStep=14,61 格 / 1952 run / 148 ms |
三个品类小节到此收束:6.1 把机制拆成字段,6.2 把字段枚举成空间,6.3 把参数扫成相变。塔防与贯穿案例(卡牌构筑)共用同一份六段骨架、同一个 digestSnap,只在 rules / params 上分岔——这正是卷三要证明的「同一份规格实例化到别的品类」。下一章换三消,重做一遍同样的解剖、随机与节奏。
阅读导航:上一节:6.2 塔防的策略空间 · 下一节:7.1 三消的机制解剖 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。