6.1 塔防的机制解剖

把塔防的五个机制字段——路径、波次、索敌、塔位、升级——各绑到 core/genre/towerdefense.ts 里一段确定性代码,用一张字段表把机制名词与可断言实现对齐:规格骨架与 play_spec.json 同构、只覆盖 rules / params 两段,W 层把字段画进单文件 HTML 并真玩,P 层逐字段断言,两处 digest 与基线逐字一致。

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.mjsout/proto06.html3 模块内联,内核 6876 字符,页面 14236 B
node web/verify_mech.mjs --page out/proto06.html --shot out/shots/proto06.pngout/shots/proto06.png4 断言全绿,截图 81815 B
node --test 'core/test/gd_proto_06_1_anatomy.test.ts'断言结果15 测试全绿,0 失败

五个字段已经拆开、各自可断言,naming 又把它们投影回共享契约。但字段拆开只说明「机制是什么」,不说明「有多少种打法」——下一节把「塔位 × 塔种 × 升级次序」枚举成策略空间,量一量它到底有多大、熵有多高。

阅读导航:上一节:5.3 构筑的相变 · 下一节:6.2 塔防的策略空间 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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