5.1 卡牌构筑的机制解剖

把《灰烬牌库》的五个机制字段——洗牌袋、抽牌、出牌、能量、弃牌——各绑到确定性内核里的一段代码,用一张字段表把机制名词与可断言的实现对齐:W 层把五个字段画进单文件 HTML 并真玩,P 层逐字段断言,两处 digest 与基线逐字一致,W 层 3 断言、P 层 7 断言全绿。

5.1 卡牌构筑的机制解剖

《独立游戏平衡与经济设计》 讲的是通用平衡与经济,全篇是散文加 14 段 Unity csharp 片段,读得到结论但跑不出数字;本章是卡牌构筑专章,只解剖一个品类的机制,而且每个字段都绑到一段能跑、能断言的确定性代码上。若只想看核心循环的时长占比,《小游戏开发实战》的核心循环拆解 已经写过;本节不重复那件事,本节问的是「卡牌构筑由哪几个字段构成,每个字段凭什么可断言」。

《灰烬牌库》是这套三卷本的贯穿案例,所以本章不需要新建品类内核——它就是案例本体。core/src/ashdeck/ 的五个模块、config/play_spec.json 的四个卡种与八个参数,从卷一用到现在一个字没改。

机制不是名词,是字段

「抽牌」「洗牌」「能量」这些词在策划案里到处都是,但词本身不可断言。可断言的形态是字段:一个字段 = 一段确定性的代码 + 一条可检验的性质。把《灰烬牌库》拆开,恰好五个字段:

机制字段内核位置语义可断言的性质W 层证据
洗牌袋rng.ts shuffleDeckFisher-Yates,随机源是自实现的 Lehmer MINSTD同 seed 同序;洗牌是置换(多重集守恒)画布上 12 张的洗后序
抽牌combat.ts 开局切片 + 回合末补牌手牌上限 = handSize开局手牌恰为 5;三分区之和恒为 12「手牌(余 N 张)」标注
出牌combat.ts 的 p<i> 分支扣能量 / 加格挡 / 结算伤害 / 手牌转弃牌出牌数 +1;能量 -= 费用;伤害进 dealt「出牌 N 张」标注
能量combat.ts 的 energy 变量出牌递减、回合末重置为 energyMax恒有 0 ≤ energy ≤ 3能量条 + 「能量 N / 3」
弃牌combat.ts 的 discard 数组打出的牌进弃牌堆,计入 cardCountcardCount 恒等于牌组规模 12「弃牌 N 张」标注

这张表就是「机制解剖」的全部产出:五个字段,五行可断言的性质。后面两节都在这张表上做文章——5.2 量洗牌袋的方差,5.3 扫牌组规模的相变。

W 层:把五个字段画出来

web/build_proto05.mjs 复用 web/pack.mjs 的 bundle(),把五个模块擦类型后内联进单文件 out/proto05.html,再把五个字段画到 canvas 上:洗牌袋一行 12 张、手牌高亮、弃牌数、能量条、双方血量条。页面暴露 globalThis.__ashdeck,供 Playwright 注入策略并取回 digest。

// file: web/build_proto05.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto05.mjs
//
// 5.1 卡牌构筑的机制解剖:把 core/src/ashdeck/ 的确定性内核与 config/play_spec.json 一起
// 塞进**一个** HTML(out/proto05.html)。页面把五个机制字段画出来——洗牌袋(12 张的洗后序)、
// 手牌、弃牌数、能量、双方血量——并暴露 globalThis.__ashdeck:
//   run(seq?) -> 机制 digest(与 S 层 Python / P 层 TS 逐字相同);snapshot() -> 最近快照;
//   zones() -> 三分区计数;canvasDrawn。
// 本章是贯穿案例本体,不新增品类内核。擦类型复用 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 { runSpec } from "../core/src/ashdeck/combat.ts";
import { digestSnap } from "../core/src/ashdeck/digest.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, ".."); // $HOME/.gdbook/ashdeck

// 手工拓扑序:先声明后使用。去掉跨文件 import 后同一作用域内不得重名。
const MODULES = [
  "core/src/ashdeck/types.ts",
  "core/src/ashdeck/rng.ts",
  "core/src/ashdeck/cards.ts",
  "core/src/ashdeck/combat.ts",
  "core/src/ashdeck/digest.ts",
];

const spec = JSON.parse(readFileSync(join(ROOT, "config/play_spec.json"), "utf8"));
const core = bundle(ROOT, MODULES);
const OUT = join(ROOT, "out", "proto05.html");

const script = core + `

const SPEC = ` + inlineConfig(spec) + `;
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
const DECK_IDS = shuffleDeck(cardIds(buildDeck(SPEC.cards)), SPEC.seed);
const COLOR = { strike: "#38bdf8", heavy: "#f472b6", jab: "#a3e635", guard: "#fbbf24" };
let snap = null;

function chip(x, y, w, h, id, on) {
  ctx.fillStyle = on ? (COLOR[id] || "#94a3b8") : "#334155";
  ctx.fillRect(x, y, w, h);
  ctx.fillStyle = on ? "#0f172a" : "#64748b";
  ctx.font = "11px system-ui, sans-serif";
  ctx.fillText(id.slice(0, 6), x + 4, y + h - 5);
}

function bar(x, y, w, h, num, den, color) {
  ctx.fillStyle = "#334155";
  ctx.fillRect(x, y, w, h);
  ctx.fillStyle = color;
  ctx.fillRect(x, y, Math.floor((w * num) / Math.max(1, den)), h);
}

// 五个机制字段的画法:洗牌袋一行 12 张、手牌高亮、弃牌数、能量条、双方血量条。
function render(s) {
  const W = cv.width, H = cv.height, p = SPEC.params;
  ctx.fillStyle = "#111827";
  ctx.fillRect(0, 0, W, H);
  ctx.font = "13px system-ui, sans-serif";
  ctx.fillStyle = "#94a3b8";
  ctx.fillText("洗牌袋(洗后序,seed=" + SPEC.seed + ")", 16, 22);
  const cw = Math.floor((W - 32 - 11 * 4) / 12);
  for (let i = 0; i < DECK_IDS.length; i += 1) {
    chip(16 + i * (cw + 4), 30, cw, 26, DECK_IDS[i], true);
  }
  ctx.fillStyle = "#94a3b8";
  ctx.fillText("手牌(余 " + s.hand.length + " 张)", 16, 88);
  for (let i = 0; i < s.hand.length; i += 1) {
    chip(16 + i * (cw + 4), 96, cw, 26, s.hand[i], true);
  }
  ctx.fillStyle = "#e2e8f0";
  ctx.fillText("出牌 " + s.played + " 张 · 弃牌 " + s.played + " 张 · 牌堆总数 " + s.cardCount, 16, 146);
  ctx.fillText("能量 " + s.energy + " / " + p.energyMax, 16, 176);
  bar(96, 164, W - 220, 14, s.energy, p.energyMax, "#38bdf8");
  bar(16, 200, W - 32, 16, s.enemyHp, p.enemyHp, "#f87171");
  bar(16, 244, W - 32, 16, s.playerHp, p.playerHp, "#34d399");
  ctx.fillStyle = "#e2e8f0";
  ctx.fillText("敌方 HP " + s.enemyHp + " / " + p.enemyHp, 16, 232);
  ctx.fillText("我方 HP " + s.playerHp + " / " + p.playerHp, 16, 276);
  ctx.fillText("回合 " + s.turn + " · 伤害 " + s.dealt + " · 胜负 " + (s.won ? "胜" : "未胜"), 16, 306);
  document.getElementById("out").textContent = digestSnap(s);
  document.getElementById("zones").textContent =
    "手牌 " + s.hand.length + " / 洗牌袋 " + DECK_IDS.length + " / 弃牌 " + s.played;
}

globalThis.__ashdeck = {
  spec: SPEC,
  run(seq) {
    const sp = JSON.parse(JSON.stringify(SPEC));
    if (seq) sp.strategy = seq;
    snap = runSpec(sp);
    render(snap);
    return digestSnap(snap);
  },
  snapshot() { return snap; },
  zones() { return { deck: DECK_IDS.length, hand: snap ? snap.hand.length : 0, played: snap ? snap.played : 0 }; },
  canvasDrawn: false,
};

globalThis.__ashdeck.run();
// 冷启动首个 page 的 canvas 在首帧合成前读像素恒为空白;推迟到两次 rAF 之后再置真,
// 让通用验证器的 waitForFunction 等到「已合成一帧」而不是「已调用 draw」。
requestAnimationFrame(() => requestAnimationFrame(() => {
  globalThis.__ashdeck.canvasDrawn = true;
}));
`;

const body = `<h1>《灰烬牌库》· 卡牌构筑的机制解剖</h1>
<div class="sub">洗牌袋 / 抽牌 / 出牌 / 能量 / 弃牌:五个机制字段,各是一段确定性代码</div>
<canvas id="cv" width="640" height="360"></canvas>
<div class="card"><div class="k">digest(应与 S 层 Python / P 层 TS 逐字相同)</div><div id="out" class="k">—</div></div>
<div class="card"><div class="k">三分区</div><div class="big" id="zones">—</div></div>`;

const html = page({ title: "《灰烬牌库》· 第 5.1 节卡牌构筑的机制解剖",
  subtitle: "五个机制字段的确定性实现", body, script });
const bytes = writePage(OUT, html);

const nodeDigest = digestSnap(runSpec(spec));
mkdirSync(join(ROOT, "out"), { recursive: true });
writeFileSync(join(ROOT, "out", "gd_proto_05_anatomy.json"),
  JSON.stringify({ spec: spec.id, modules: MODULES.length, kernelChars: core.length,
    page: "out/proto05.html", pageBytes: bytes, deckSize: spec.cards.reduce((a, c) => a + c.count, 0),
    fields: ["shuffle-bag", "draw", "play", "energy", "discard"], 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_proto05.mjs
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs --page out/proto05.html --shot out/shots/proto05.png
modules=5 kernelChars=4123
built /Users/bingrong.yan/.gdbook/ashdeck/out/proto05.html  10281 B
NODE_DIGEST={"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
=== 通用断言 ===
  ok   两遍独立加载 digest 逐字一致(确定性)
  ok   canvas 已绘制(230400 px >= 1)
  ok   截图已落盘(81873 B)
PAGE_DIGEST={"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
OK W 层通过

三条断言全绿。canvas 已绘制(230400 px) 是 640×360 画布的全部像素,断言只写「≥ 1 px」——冷启动首个 page 读 canvas 恒为空白,canvasDrawn 被推迟到两次 requestAnimationFrame 之后才置真,靠这一手绕开。PAGE_DIGEST 与构建期 Node 侧算出的 NODE_DIGEST 一字不差:页面与内核本来就是同一段源码的两个宿主。

P 层:逐字段断言

W 层把字段画出来,P 层把字段断言掉。测试直接对 core/src/ashdeck/ 的既有实现下断言,不新增任何内核;期望值全部来自本机实跑:

// file: core/test/gd_proto_05_anatomy.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_05_anatomy.test.ts'
//
// 5.1 卡牌构筑的机制解剖(P 层):把《灰烬牌库》的五个机制字段——洗牌袋 / 抽牌 / 出牌 /
// 能量 / 弃牌——各绑到内核里的一段确定性代码,并逐字段断言。本章就是贯穿案例本体,因此
// 不新增品类内核,直接断言 core/src/ashdeck/ 的既有实现。期望值全部由本机实跑得出。

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, invariantsHold } from "../src/ashdeck/combat.ts";
import { digestSnap } from "../src/ashdeck/digest.ts";
import { shuffleDeck, rngNext, RNG_MOD, RNG_MUL } from "../src/ashdeck/rng.ts";
import { buildDeck, cardIds } from "../src/ashdeck/cards.ts";
import type { Card, 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 DECK_SIZE = SPEC.cards.reduce((a, c) => a + c.count, 0);
const byId = (id: string): Card => SPEC.cards.find((c) => c.id === id) as Card;

test("洗牌袋:Lehmer MINSTD 常数与同 seed 同序,且洗牌是置换", () => {
  assert.equal(RNG_MOD, 2147483647);
  assert.equal(RNG_MUL, 48271);
  let s = SPEC.seed;
  const states: number[] = [];
  for (let i = 0; i < 3; i += 1) {
    s = rngNext(s);
    states.push(s);
  }
  assert.deepEqual(states, [914347409, 1379866695, 1192438993]);
  const ids = cardIds(buildDeck(SPEC.cards));
  assert.deepEqual(shuffleDeck(ids, SPEC.seed), shuffleDeck(ids, SPEC.seed));
  assert.notDeepEqual(shuffleDeck(ids, SPEC.seed), shuffleDeck(ids, SPEC.seed + 1));
  assert.deepEqual(shuffleDeck(ids, SPEC.seed).slice().sort(), ids.slice().sort());
});

test("抽牌:开局手牌恰为 handSize,三分区之和恒等于 deckSize", () => {
  const s = runSpec(SPEC, []);
  assert.equal(DECK_SIZE, 12);
  assert.equal(SPEC.params.handSize, 5);
  assert.equal(s.hand.length, SPEC.params.handSize);
  assert.equal(s.cardCount, DECK_SIZE);
});

test("出牌:p0 扣该牌费用、手牌转弃牌、伤害进 dealt", () => {
  const shuffled = shuffleDeck(cardIds(buildDeck(SPEC.cards)), SPEC.seed);
  const first = byId(shuffled[0]);
  const s = runSpec(SPEC, ["p0"]);
  assert.equal(s.played, 1);
  assert.equal(s.energy, SPEC.params.energyMax - first.cost);
  assert.equal(s.hand.length, SPEC.params.handSize - 1);
  assert.equal(s.dealt, Math.min(first.damage, SPEC.params.enemyHp));
  assert.equal(s.cardCount, DECK_SIZE);
});

test("能量:出牌单调递减,回合结束重置为 energyMax 且恒在 [0, energyMax]", () => {
  const one = runSpec(SPEC, ["p0", "p0"]);
  const two = runSpec(SPEC, ["p0"]);
  assert.ok(one.energy <= two.energy, "多打一张能量不应回升");
  const afterEnd = runSpec(SPEC, ["p0", "e"]);
  assert.equal(afterEnd.energy, SPEC.params.energyMax);
  assert.equal(afterEnd.turn, 2);
  assert.ok(one.energy >= 0 && one.energy <= SPEC.params.energyMax);
});

test("弃牌:打出几张就有几张进弃牌,牌数守恒且伤害守恒", () => {
  const s = runSpec(SPEC);
  assert.equal(s.played, 9);
  assert.equal(s.cardCount, DECK_SIZE);
  assert.equal(s.damageConserved, true);
  assert.equal(s.dealt, SPEC.params.enemyHp - s.enemyHp);
  assert.equal(invariantsHold(SPEC, s), true);
});

test("确定性回放:同 seed 两遍 digest 逐字一致,且等于基线", () => {
  const BASE =
    '{"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,' +
    '"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,' +
    '"playerHp":48,"turn":4,"won":true}';
  assert.equal(digestSnap(runSpec(SPEC)), BASE);
  assert.equal(digestSnap(runSpec(SPEC)), digestSnap(runSpec(SPEC)));
});

test("五字段对账:机制字段表逐行给出可断言证据", () => {
  const s = runSpec(SPEC);
  const rows: [string, string, boolean][] = [
    ["shuffle-bag", "shuffleDeck(buildDeck(cards), seed)", s.cardCount === DECK_SIZE],
    ["draw", "hand.slice(0, handSize) + draw.shift()", s.hand.length <= SPEC.params.handSize],
    ["play", "p<i> -> energy -= cost", s.played === 9],
    ["energy", "end turn -> energy = energyMax", s.energy <= SPEC.params.energyMax],
    ["discard", "discard.push(played card)", s.damageConserved === true],
  ];
  for (const [field, code, ok] of rows) {
    console.log(`  ${field.padEnd(12)} ${code.padEnd(40)} ${ok ? "ok" : "FAIL"}`);
  }
  assert.equal(rows.every((r) => r[2]), true);
});
  shuffle-bag  shuffleDeck(buildDeck(cards), seed)      ok
  draw         hand.slice(0, handSize) + draw.shift()   ok
  play         p<i> -> energy -= cost                   ok
  energy       end turn -> energy = energyMax           ok
  discard      discard.push(played card)                ok
✔ 洗牌袋:Lehmer MINSTD 常数与同 seed 同序,且洗牌是置换
✔ 抽牌:开局手牌恰为 handSize,三分区之和恒等于 deckSize
✔ 出牌:p0 扣该牌费用、手牌转弃牌、伤害进 dealt
✔ 能量:出牌单调递减,回合结束重置为 energyMax 且恒在 [0, energyMax]
✔ 弃牌:打出几张就有几张进弃牌,牌数守恒且伤害守恒
✔ 确定性回放:同 seed 两遍 digest 逐字一致,且等于基线
✔ 五字段对账:机制字段表逐行给出可断言证据
ℹ tests 7
ℹ pass 7
ℹ fail 0

七条全绿。第一条把 RNG_MOD=2147483647、RNG_MUL=48271 两个常数钉死,并验证 seed 20261014 的前三个状态是 914347409 / 1379866695 / 1192438993——这是跨语言逐位一致的唯一保证点。第三条不硬编码「第一张牌是什么」,而是自己先跑一遍 shuffleDeck 拿到 hand[0] 再推导出它该扣几点能量、该打多少伤害,这样断言对洗牌顺序的变化是免疫的。第五条把「弃牌」与「伤害守恒」绑在一起:打出 9 张、牌数仍恒为 12、dealt 恰等于 enemyHp 的减少量。

本节实跑清单

命令产物实测
node web/build_proto05.mjsout/proto05.html5 模块内联,内核 4123 字符,页面 10281 B
node web/verify_mech.mjs --page out/proto05.html --shot out/shots/proto05.pngout/shots/proto05.png3 断言全绿,截图 81873 B
node --test 'core/test/gd_proto_05_anatomy.test.ts'断言结果7 测试全绿,0 失败

五个字段已经拆开并各自可断言。但字段拆开只说明「机制是什么」,不说明「机制好不好」——下一节把洗牌袋当成一个分布来量,看方差到底在体验里做了什么。

阅读导航:上一节:4.3 从测试到决策 · 下一节:5.2 构筑的方差与平衡 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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