6.3 塔防的难度曲线

把波次强度 waveHpStep 从 0 往上推,用粗扫 + 二分找「防守被解开」的临界点:临界点是 14,两侧胜率 13→937‰、14→343‰、15→0‰,临界宽度只有 1;粗扫 + 二分只用 7 格 224 run 就钉到,占步长 1 全网格的 114‰,并给出二维网格超预算时退回采样的判据。胜率是代理指标,只做相对比较。

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种子池胜率读法
13937‰守得住(30 / 32 种子胜)
14343‰临界点(11 / 32 种子胜)
150‰全灭

13 → 14 掉 594‰,14 → 15 再掉 343‰;临界宽度只有 1——waveHpStep 每加 1,敌人每波多 1 点血,累积到第 14 档时刚好越过防守的余量,胜率断崖。这不是「线性变难」,是相变:临界点左侧胜率还在 900‰ 以上,右侧一格之内归零。同样的陡峭在 W 层页面的代理指标 proxy() 里也能看到——它扫出来的最小 waveHpStep 也是 14。

扫描的代价与预算判据

量值说明
粗扫格子40, 5, 10, 15,命中即停
二分轮数312 / 13 / 14
扫描总格子7粗扫 4 + 二分 3
扫描 run 数2247 格 × 32 种子
全网格格子61step=1,0..60
全网格 run 数195261 格 × 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种子池胜率意图
教学101000‰让玩家把五个字段都摸一遍
主关13937‰留一点容错,但要开始算升级次序
挑战14343‰逼玩家拿出 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 三消的机制解剖 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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