6.1 塔防的机制解剖
《Phaser 塔防》 讲的是引擎实现口径:索敌在引擎里怎么写成帧循环、怎么渲染、怎么和输入对接。本节是机制口径:把「索敌规则」写成可执行、可枚举、可算熵的规格字段,一行渲染代码都不写。若要看品类分类与商业矩阵,《小游戏开发实战》的品类分类 已经写过;本节只解剖塔防一个品类,而且每个字段都绑到一段能跑、能断言的确定性代码上。
《灰烬牌库》是这套三卷本的贯穿案例。卷三要回答的是「同一份规格换成别的品类还成不成立」,所以本章不新造规格:照搬 config/play_spec.json 的六段骨架,新建 core/genre/towerdefense.ts,只 import 共享底座的 rng.ts 与 digest.ts,只覆盖 rules / params 两段——同一份规格,另一个品类。
机制不是名词,是字段
「路径」「波次」「索敌」这些词在策划案里到处都是,但词本身不可断言。可断言的形态是字段:一个字段 = 一段确定性的代码 + 一条可检验的性质。把塔防拆开,恰好五个字段:
| 机制字段 | 内核位置 | 语义 | 可断言的性质 | W 层证据 |
|---|---|---|---|---|
| 路径 | tdRun 的 pathLength 与 params.moveEvery | 敌人每 moveEvery 个 tick 前进 1 格,走到 pathLength 即漏怪 | 漏怪数 = 走满全程的敌人数;路径越短越早漏 | 画布顶部 12 格路径 |
| 波次 | tdRun 外层 for (w = 1; w <= waveCount) | 第 w 波敌人 HP = waveHpBase + waveHpStep×(w-1),数量 = enemiesBase + enemiesStep×(w-1) | 每波生成 HP 单调递增;spawnHp = Σ n×hp | 底部每波击杀/漏怪堆叠条 |
| 索敌 | tdPickTarget 的 first / last / strongest / weakest | 射程内按规则取目标,平手取先出现者 | 同规格同种子索敌序列确定;四规则在临界难度给出四个不同摘要 | 塔位方块(箭塔 / 炮塔配色) |
| 塔位 | rules.slots 的 {id, at} 与 tdRun 的 towers | 每个塔位最多一座塔,at 决定射程覆盖的路径区间 | towerCount <= slotCount;撤掉一个塔位会改变结果 | 四个塔位方块(实心 = 已建) |
| 升级 | rules.upgrade 与 tdRun 的 u 分支 | 升级 +1 级,伤害 ×(1000+500×(level-1))/1000,金币先到先得 | 升级单调增伤;到 maxLevel 不再升 | 方块上的等级数字 |
这张表就是「机制解剖」的全部产出:五个字段,五行可断言的性质。后面两节都在这张表上做文章——6.2 把「塔位 × 塔种 × 升级次序」枚举成策略空间并算熵,6.3 扫波次强度找「防守被解开」的临界点。
规格骨架与 play_spec.json 同构
塔防变体的规格不是另起炉灶,而是把贯穿案例的六段骨架原样搬过来,只重写 rules / params:
id/spec_version/name/seed:标识与随机源,seed=60601;naming:把共享摘要契约的八个键映射到品类词——turn→wave、energy→gold、playerHp→baseHp、enemyHp→waveHp、block→towerCount、played→shots、hand→towers、cardCount→slotCount;state:{gold: 30, baseHp: 20, wave: 1};rules:pathLength/slots/towerTypes/upgrade/targeting/jitterPermille/rewardPerKill/rewardPerWave;params:waveCount/waveHpBase/waveHpStep/enemiesBase/enemiesStep/moveEvery/spawnGap/leakDamage;invariant:slotCount==slots.length/damageConserved/gold>=0/baseHp>=0/towerCount<=slotCount。
naming 那一段是关键:它让塔防状态能投影回共享的 12 键摘要契约(tdCommonState),于是同一个 digestSnap 能摘要两个品类。这是「同一份规格实例化到别的品类」最硬的证据——不是文档里说同构,是代码里共用同一个摘要函数。
W 层:把五个字段画出来
web/build_proto06.mjs 复用 web/pack.mjs 的 bundle(),把 rng.ts / digest.ts / towerdefense.ts 三个模块擦类型后内联进单文件 out/proto06.html,再把五个字段画到 canvas 上:路径 12 格、四个塔位方块(实心 = 已建、颜色分箭塔 / 炮塔、数字为等级)、每波击杀与漏怪的堆叠条。页面暴露 globalThis.__ashdeck,供 Playwright 注入布防序列并取回 digest。
// file: web/build_proto06.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto06.mjs
//
// 卷三 6.1 塔防的机制解剖(W 层):把品类内核 core/genre/towerdefense.ts 与共享底座
// rng.ts / digest.ts 按手工拓扑序擦类型后,连同 config/gd_proto_06_td.json 一起内联进
// **一个** HTML(out/proto06.html)。页面暴露 globalThis.__ashdeck:
// run(seq?) -> 品类摘要(与 P 层测试冻结的那条逐字相同);snapshot() -> 最近快照;
// proxy() -> 临界波次强度(代理指标,只做相对比较,禁绝对化);canvasDrawn。
// 构建期顺手在 Node 侧跑一遍内核,把 nodeDigest 落进 out/proto06_build.json 作为「P 侧」证据。
// 擦类型复用 web/pack.mjs 的 bundle(),本章不重写任何擦除逻辑。
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { tdRun, tdDigest } from "../core/genre/towerdefense.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, ".."); // $HOME/.gdbook/ashdeck
// 手工拓扑序:先声明后使用。去掉跨文件 import 后同一作用域内不得重名。
const MODULES = [
"core/src/ashdeck/rng.ts",
"core/src/ashdeck/digest.ts",
"core/genre/towerdefense.ts",
];
const spec = JSON.parse(readFileSync(join(ROOT, "config/gd_proto_06_td.json"), "utf8"));
const POOL = Array.from({ length: 16 }, (_, i) => 60601 + i); // 与 P 层同一批种子
const core = bundle(ROOT, MODULES);
const OUT = join(ROOT, "out", "proto06.html");
const script = core + `
const SPEC = ` + inlineConfig(spec) + `;
const POOL = ` + inlineConfig(POOL) + `;
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
let snap = null;
// 代理指标:默认布防的**临界波次强度**——最小的 waveHpStep 使种子池胜率跌破 500‰。
// 它只做相对比较,禁绝对化:数值大只说明防守余量大,不等于更好玩。
function criticalStep(spec) {
const seeds = POOL.slice(0, 8);
for (let v = 0; v <= 40; v += 1) {
let w = 0;
for (const sd of seeds) {
const sp = JSON.parse(JSON.stringify(spec));
sp.seed = sd;
sp.params.waveHpStep = v;
if (tdRun(sp).won) w += 1;
}
if (Math.floor((1000 * w) / seeds.length) < 500) return v;
}
return -1;
}
// 五个机制字段的画法:路径 12 格、塔位(实心=已建 + 等级)、每波击杀/漏怪堆叠条。
function render(s) {
const W = cv.width, H = cv.height, r = SPEC.rules;
const cw = Math.floor((W - 40) / r.pathLength);
ctx.fillStyle = "#111827";
ctx.fillRect(0, 0, W, H);
ctx.font = "13px system-ui, sans-serif";
for (let i = 0; i < r.pathLength; i += 1) {
ctx.fillStyle = i % 2 === 0 ? "#1e293b" : "#243044";
ctx.fillRect(20 + i * cw, 120, cw - 2, 22);
}
for (const slot of r.slots) {
const x = 20 + slot.at * cw;
const t = s.towers.find((h) => h.startsWith(slot.id + ":"));
ctx.strokeStyle = t ? "#38bdf8" : "#475569";
ctx.lineWidth = t ? 2 : 1;
ctx.strokeRect(x + 0.5, 78.5, cw - 3, 34);
if (t) {
const parts = t.split(":");
ctx.fillStyle = parts[1] === "cannon" ? "#f59e0b" : "#38bdf8";
ctx.fillRect(x + 4, 82, cw - 11, 27);
ctx.fillStyle = "#0f172a";
ctx.fillText(parts[1] === "cannon" ? "C" + parts[2] : "A" + parts[2], x + 8, 100);
} else {
ctx.fillStyle = "#64748b";
ctx.fillText("空", x + 8, 100);
}
}
const bw = Math.floor((W - 40) / s.waveLeaks.length) - 8;
for (let i = 0; i < s.waveLeaks.length; i += 1) {
const x = 20 + i * (bw + 8);
const kh = s.waveKilled[i] * 9, lh = s.waveLeaks[i] * 9;
ctx.fillStyle = "#334155";
ctx.fillRect(x, 178, bw, 96);
ctx.fillStyle = "#34d399";
ctx.fillRect(x, 274 - kh, bw, kh);
ctx.fillStyle = "#f87171";
ctx.fillRect(x, 274 - kh - lh, bw, lh);
ctx.fillStyle = "#94a3b8";
ctx.fillText("W" + (i + 1), x + 2, 292);
}
ctx.fillStyle = "#e2e8f0";
ctx.fillText("波次 " + s.wave + "/" + SPEC.params.waveCount + " 金币 " + s.gold +
" 基地 " + s.baseHp + "/" + SPEC.state.baseHp + " 塔 " + s.towerCount + "/" + s.slotCount, 20, 40);
ctx.fillText("漏怪 " + s.leaked + " 击杀 " + s.killed + " 开火 " + s.shots +
" 已打 " + s.dealt + " / 生成 " + s.spawnHp + " " + (s.won ? "守住了" : "失守"), 20, 62);
ctx.fillText("绿=击杀 红=漏怪(每波堆叠,1 格 = 1 只)", 20, 320);
document.getElementById("out").textContent = tdDigest(s);
document.getElementById("leak").textContent = String(criticalStep(SPEC));
}
globalThis.__ashdeck = {
spec: SPEC,
run(seq) {
const sp = JSON.parse(JSON.stringify(SPEC));
snap = tdRun(sp, Array.isArray(seq) ? seq : undefined);
render(snap);
return tdDigest(snap);
},
snapshot() { return snap; },
proxy() { return criticalStep(SPEC); },
canvasDrawn: false,
};
globalThis.__ashdeck.run();
// 冷启动首个 page 的 canvas 在首帧合成前读像素恒为空白;推迟到两次 rAF 之后再置真,
// 让通用验证器的 waitForFunction 等到「已合成一帧」而不是「已调用 draw」。
requestAnimationFrame(() => requestAnimationFrame(() => {
globalThis.__ashdeck.canvasDrawn = true;
}));
`;
const body = `<h1>《灰烬牌库》· 塔防变体(第 6 章)</h1>
<div class="sub">同一份规格骨架,只覆盖 rules / params 两段:路径、波次、索敌、塔位、升级</div>
<canvas id="cv" width="640" height="360"></canvas>
<div class="card"><div class="k">品类摘要 digest(与 P 层测试冻结的那条逐字相同)</div><div id="out" class="k">—</div></div>
<div class="card"><div class="k">代理指标 · 临界波次强度 waveHpStep(只做相对比较,禁绝对化)</div><div class="big" id="leak">—</div></div>`;
const html = page({
title: "《灰烬牌库》· 第 6.1 节塔防的机制解剖",
subtitle: "品类内核 + 共享底座:读同一份骨架,产出同一条摘要",
body,
script,
});
const bytes = writePage(OUT, html);
const nodeDigest = tdDigest(tdRun(spec));
mkdirSync(join(ROOT, "out"), { recursive: true });
writeFileSync(join(ROOT, "out", "proto06_build.json"),
JSON.stringify({
spec: spec.id, modules: MODULES.length, kernelChars: core.length,
page: "out/proto06.html", pageBytes: bytes, pool: POOL.length, nodeDigest,
}, null, 2) + "\n");
console.log("modules=" + MODULES.length + " kernelChars=" + core.length);
console.log("built " + OUT + " " + bytes + " B");
console.log("NODE_DIGEST=" + nodeDigest);
构建与真玩分两步跑——先构建、再断言,只跑断言等于在验上一版页面:
# snippet: 构建 + 无头真玩
NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto06.mjs
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs --page out/proto06.html --shot out/shots/proto06.png
modules=3 kernelChars=6876
built /Users/bingrong.yan/.gdbook/ashdeck/out/proto06.html 14236 B
NODE_DIGEST={"block":4,"cardCount":4,"damageConserved":true,"dealt":772,"enemyHp":0,"energy":70,"hand":["0:cannon:1","1:arrow:2","2:arrow:1","3:cannon:1"],"over":true,"played":124,"playerHp":20,"turn":6,"won":true}
=== 通用断言 ===
ok 两遍独立加载 digest 逐字一致(确定性)
ok canvas 已绘制(230400 px >= 1)
ok 截图已落盘(81815 B)
ok digest 与 --expect-digest 一致
PAGE_DIGEST={"block":4,"cardCount":4,"damageConserved":true,"dealt":772,"enemyHp":0,"energy":70,"hand":["0:cannon:1","1:arrow:2","2:arrow:1","3:cannon:1"],"over":true,"played":124,"playerHp":20,"turn":6,"won":true}
OK W 层通过
四条断言全绿:PAGE_DIGEST 与构建期 Node 侧算出的 NODE_DIGEST 一字不差——页面与内核本来就是同一段源码的两个宿主。proxy() 另暴露一个代理指标:默认布防下使种子池胜率跌破 500‰ 的最小 waveHpStep,实测为 14,与 6.3 扫出来的临界点一致。
P 层:把机制字段接到断言上
W 层把字段画出来,P 层把字段断言掉。core/test/gd_proto_06_1_anatomy.test.ts 的做法是:改一个字段,最终摘要必须变;改不动,就说明那一格是文档装饰而不是机制。基线配置下防守太轻松(0 漏怪),路径长度 / 漏怪伤害 / 索敌三格观测不到,所以这三格的断言先把 waveHpStep 推到 12 让防守刚好被压住,才可观测。内核的索敌与状态机如下:
// snippet: core/genre/towerdefense.ts(节选:idiv / tdPickTarget / tdRun)
/** 整数地板除:JS 的 `/` 是浮点除,凡派生量一律走这里,跨语言才逐位一致。 */
export function idiv(a: number, b: number): number { return Math.floor(a / b); }
/** 索敌:first / last / strongest / weakest;平手取先出现者,保证确定性。 */
export function tdPickTarget(enemies: TdEnemy[], tw: TdTower, rule: string): TdEnemy | null {
const lo = tw.at - tw.type.range;
const hi = tw.at + tw.type.range;
let best: TdEnemy | null = null;
for (const e of enemies) {
if (!e.alive || e.pos < lo || e.pos > hi) continue;
if (best === null) { best = e; continue; }
if (rule === "last") { if (e.pos < best.pos) best = e; }
else if (rule === "strongest") { if (e.hp > best.hp || (e.hp === best.hp && e.pos > best.pos)) best = e; }
else if (rule === "weakest") { if (e.hp < best.hp || (e.hp === best.hp && e.pos > best.pos)) best = e; }
else if (e.pos > best.pos) best = e;
}
return best;
}
/** 规格驱动的确定性状态机(节选:波次循环里的开火结算与漏怪结算两段)。 */
export function tdRun(spec: TdSpec, strategy?: TdOp[]): TdSnapshot {
// …(前置:读 rules / params,按波次取出该波的建造与升级指令,金币先到先得)
for (const tw of towers) {
if (tick % tw.type.period !== 0) continue;
const tgt = tdPickTarget(enemies, tw, r.targeting);
if (tgt === null) continue;
rngState = rngNext(rngState);
const j = (rngState % (2 * r.jitterPermille + 1)) - r.jitterPermille;
const lvl = 1000 + r.upgrade.damagePermillePerLevel * (tw.level - 1);
const dmg = idiv(idiv(tw.type.damage * lvl, 1000) * (1000 + j), 1000);
const d = Math.min(dmg, tgt.hp);
tgt.hp -= d; dealt += d; shots += 1;
if (tgt.hp <= 0) { tgt.alive = false; killed += 1; gold += r.rewardPerKill; }
}
// …(敌人前进:每 moveEvery 个 tick 前进 1 格)
for (const e of enemies) {
if (e.alive && e.pos >= r.pathLength) {
e.alive = false; leaked += 1; leakedHp += e.hp; baseHp -= p.leakDamage;
}
}
}
✔ 基线摘要冻结:与实跑逐字一致
✔ 确定性:同规格同种子两次运行摘要相同
✔ 不变量在基线、空布防、扰动三种策略下都成立
✔ 伤害守恒:已打 + 存活 + 漏掉 == 已生成(三种策略各验一次)
✔ 路径长度是机制:临界难度下 12 → 8 会改变结果
✔ 波次强度是机制:waveHpBase 8 → 12 会改变结果
✔ 索敌规则是机制:临界难度下四种索敌给出四个不同摘要
✔ 塔位是机制:撤掉一个塔位会改变结果
✔ 升级是机制:maxLevel 3 → 1 会改变结果
✔ 速度、抖动、塔参数都是机制:改哪个摘要都变
✔ 漏怪伤害是机制:临界难度下 leakDamage 2 → 5 会改变结果
✔ 基线配置下防守过轻松:路径长度与索敌三格观测不到(临界难度才可观测)
✔ 品类状态投影回共享的 12 键摘要契约
✔ 塔位守恒:投影里的 cardCount 恒等于塔位总数
✔ 规格骨架与 play_spec.json 同构:六段一个不缺
ℹ tests 15
ℹ pass 15
ℹ fail 0
十五条全绿。基线摘要冻结在 dealt=772、played=124、energy=70、turn=6、won=true,四个塔位建在 0:cannon:1、1:arrow:2、2:arrow:1、3:cannon:1——与 W 层页面里那条 PAGE_DIGEST 逐字相同。最后一条把 naming 的八个键与 play_spec.json 的键集对上,等于用断言证明了「同一份骨架」。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
node web/build_proto06.mjs | out/proto06.html | 3 模块内联,内核 6876 字符,页面 14236 B |
node web/verify_mech.mjs --page out/proto06.html --shot out/shots/proto06.png | out/shots/proto06.png | 4 断言全绿,截图 81815 B |
node --test 'core/test/gd_proto_06_1_anatomy.test.ts' | 断言结果 | 15 测试全绿,0 失败 |
五个字段已经拆开、各自可断言,naming 又把它们投影回共享契约。但字段拆开只说明「机制是什么」,不说明「有多少种打法」——下一节把「塔位 × 塔种 × 升级次序」枚举成策略空间,量一量它到底有多大、熵有多高。
阅读导航:上一节:5.3 构筑的相变 · 下一节:6.2 塔防的策略空间 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。