5.1 卡牌构筑的机制解剖
《独立游戏平衡与经济设计》
讲的是通用平衡与经济,全篇是散文加 14 段 Unity csharp 片段,读得到结论但跑不出数字;本章是卡牌构筑专章,只解剖一个品类的机制,而且每个字段都绑到一段能跑、能断言的确定性代码上。若只想看核心循环的时长占比,《小游戏开发实战》的核心循环拆解
已经写过;本节不重复那件事,本节问的是「卡牌构筑由哪几个字段构成,每个字段凭什么可断言」。
《灰烬牌库》是这套三卷本的贯穿案例,所以本章不需要新建品类内核——它就是案例本体。core/src/ashdeck/ 的五个模块、config/play_spec.json 的四个卡种与八个参数,从卷一用到现在一个字没改。
机制不是名词,是字段
「抽牌」「洗牌」「能量」这些词在策划案里到处都是,但词本身不可断言。可断言的形态是字段:一个字段 = 一段确定性的代码 + 一条可检验的性质。把《灰烬牌库》拆开,恰好五个字段:
| 机制字段 | 内核位置 | 语义 | 可断言的性质 | W 层证据 |
|---|---|---|---|---|
| 洗牌袋 | rng.ts shuffleDeck | Fisher-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 数组 | 打出的牌进弃牌堆,计入 cardCount | cardCount 恒等于牌组规模 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.mjs | out/proto05.html | 5 模块内联,内核 4123 字符,页面 10281 B |
node web/verify_mech.mjs --page out/proto05.html --shot out/shots/proto05.png | out/shots/proto05.png | 3 断言全绿,截图 81873 B |
node --test 'core/test/gd_proto_05_anatomy.test.ts' | 断言结果 | 7 测试全绿,0 失败 |
五个字段已经拆开并各自可断言。但字段拆开只说明「机制是什么」,不说明「机制好不好」——下一节把洗牌袋当成一个分布来量,看方差到底在体验里做了什么。
阅读导航:上一节:4.3 从测试到决策 · 下一节:5.2 构筑的方差与平衡 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。