7.1 三消的机制解剖
《小游戏开发实战》的核心循环拆解 量的是核心循环各阶段的时长占比,回答「一局的时间花在哪」;本节问的是更靠前的一件事——三消这个品类由哪几个字段构成,每个字段凭什么可断言。若要看品类分类与商业矩阵,《小游戏开发实战》的品类分类 已经写过;本节只解剖三消一个品类,而且每个字段都绑到一段能跑、能断言的确定性代码上。
《灰烬牌库》是这套三卷本的贯穿案例。卷三要回答的是「同一份规格换成别的品类还成不成立」,所以本章不新造规格:照搬 config/play_spec.json 的六段骨架,新建 core/genre/match3.ts,只 import 共享底座的 rng.ts 与 digest.ts,只覆盖 rules / params 两段——同一份规格,另一个品类。
机制不是名词,是字段
「棋盘」「交换」「消除」这些词在策划案里到处都是,但词本身不可断言。可断言的形态是字段:一个字段 = 一段确定性的代码 + 一条可检验的性质。把三消拆开,恰好五个字段:
| 机制字段 | 内核位置 | 语义 | 可断言的性质 | W 层证据 |
|---|---|---|---|---|
| 棋盘 | m3FillAvoid | 逐格抽色填满 7×7,抽到与左二 / 上二同色就重抽 | 开局 49 格全在 [0,types) 且无三连;rows 6 → 7 会改变结果 | 画布上 7×7 色块 |
| 交换 | m3SwapValid / m3FirstMove | 只有交换后产生 run 的相邻交换合法;无合法交换即死局 | 非法交换被拒且不消耗有效步;首个可消交换下标固定 | 白框描边的首个可消交换 |
| 消除 | m3Runs | 同行 / 同列连续同色且长度 >= minRun 的格子全部消掉 | 每次消除数 >= minRun;minRun 3 → 4 会改变结果 | 每步消除数绿条 |
| 掉落 | m3Resolve 的列压缩 + 补牌 | 被消的格子由上方格下落填补,空出的顶部用 rngNext 补新色 | 棋盘恒满 49 格、无悬空;types 5 → 6 会改变结果 | 棋盘下一帧仍然满格 |
| 连锁 | m3Resolve 的 for (depth …) 与 cascadeBonusPermille | 补牌不避 run,掉下来的新色可能再次成三连,奖励 250‰×(depth−1) | 最长连锁 <= cascadeCap;奖励 250‰ → 0 会改变结果 | 每步连锁长度白点 |
这张表就是「机制解剖」的全部产出:五个字段,五行可断言的性质。后面两节都在这张表上做文章——7.2 把「棋盘 / 交换」这两个字段上的随机单拎出来量死局率,7.3 把「消除 / 连锁」这两个字段上的分布量成节奏谱。
规格骨架与 play_spec.json 同构
三消变体的规格不是另起炉灶,而是把贯穿案例的六段骨架原样搬过来,只重写 rules / params:
id/spec_version/name/seed:标识与随机源,seed=70701;naming:把共享摘要契约的八个键映射到品类词——turn→movesUsed、energy→movesLeft、playerHp→score、enemyHp→scoreLeft、block→maxChain、played→validMoves、hand→chainCounts、cardCount→boardSize;state:{score: 0, moves: 0};rules:rows/cols/types/minRun/cascadeCap/scorePerTile/cascadeBonusPermille/policy/pity;params:moveCount/targetScore;invariant:boardFull/score>=0/movesUsed<=moveCount/stable/clearedConserved。
naming 那一段是关键:它让三消状态能投影回共享的 12 键摘要契约(m3CommonState),于是同一个 digestSnap 能摘要两个品类。这是「同一份规格实例化到别的品类」最硬的证据——不是文档里说同构,是代码里共用同一个摘要函数。
W 层:把五个字段画出来
web/build_proto07.mjs 复用 web/pack.mjs 的 bundle(),把 rng.ts / digest.ts / match3.ts 三个模块按手工拓扑序擦类型后内联进单文件 out/proto07.html,再把五个字段画到 canvas 上:7×7 色块棋盘、白框描边的首个可消交换、每步消除数绿条、每步连锁长度白点。页面暴露 globalThis.__ashdeck,供 Playwright 注入交换序列并取回 digest。
// file: web/build_proto07.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto07.mjs
//
// 卷三 7.1 三消的机制解剖(W 层):把品类内核 core/genre/match3.ts 与共享底座
// rng.ts / digest.ts 按手工拓扑序擦类型后,连同 config/gd_proto_07_m3.json 一起内联进
// **一个** HTML(out/proto07.html)。页面把五个机制字段画出来——棋盘(49 格)、交换(可消
// 交换高亮)、消除(每步消除数条)、掉落(棋盘恒满)、连锁(每步连锁长度条)——并暴露
// globalThis.__ashdeck:
// run(ops?) -> 品类摘要(与 P 层测试冻结的那条逐字相同);snapshot() -> 最近快照;
// proxy() -> { off, on } 每步死局率(千分比,代理指标,只做相对比较,禁绝对化);canvasDrawn。
// 构建期顺手在 Node 侧跑一遍内核,把 nodeDigest 落进 out/proto07_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 { m3Run, m3Digest } from "../core/genre/match3.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/match3.ts",
];
const spec = JSON.parse(readFileSync(join(ROOT, "config/gd_proto_07_m3.json"), "utf8"));
const POOL = Array.from({ length: 32 }, (_, i) => 70701 + i); // 与 P 层同一批种子
const core = bundle(ROOT, MODULES);
const OUT = join(ROOT, "out", "proto07.html");
const script = core + `
const SPEC = ` + inlineConfig(spec) + `;
const POOL = ` + inlineConfig(POOL) + `;
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
const COLOR = ["#38bdf8", "#f472b6", "#a3e635", "#fbbf24", "#a78bfa", "#fb7185", "#22d3ee"];
let snap = null;
// 代理指标:每步死局率(千分比)——一局里「开局时没有任何合法交换」的步数占比。
// off = 关掉保底,on = 打开保底。它只做相对比较,禁绝对化:数值低只说明不容易卡住,
// 不等于更好玩。两栏都用同一批种子、同一份规则,只有 pity.enabled 不同;为了让死局
// 真的出现,把颜色数推到高随机配置 types=7(与 7.2 的扫描焦点一致)。
function deadlockRate(enabled) {
const sp = JSON.parse(JSON.stringify(SPEC));
sp.rules.types = 7;
sp.rules.pity.enabled = enabled;
let dl = 0, mv = 0;
for (const sd of POOL) {
const s = m3Run({ ...sp, seed: sd });
dl += s.deadlockMoves; mv += s.movesUsed;
}
return Math.floor((1000 * dl) / Math.max(1, mv));
}
// 五个机制字段的画法:棋盘 49 格、首个可消交换描边、每步消除数条、连锁长度条、顶部读数。
function render(s) {
const W = cv.width, H = cv.height, r = SPEC.rules;
const cell = 34, x0 = 20, y0 = 46;
ctx.fillStyle = "#111827";
ctx.fillRect(0, 0, W, H);
for (let rr = 0; rr < r.rows; rr += 1) {
for (let cc = 0; cc < r.cols; cc += 1) {
ctx.fillStyle = COLOR[s.board[rr * r.cols + cc] % COLOR.length];
ctx.fillRect(x0 + cc * (cell + 2), y0 + rr * (cell + 2), cell, cell);
}
}
const mv = m3FirstMove(s.board, r.rows, r.cols, r.minRun);
if (mv !== null) {
ctx.strokeStyle = "#f8fafc";
ctx.lineWidth = 3;
for (const i of [mv.a, mv.b]) {
ctx.strokeRect(x0 + (i % r.cols) * (cell + 2) + 1.5, y0 + Math.floor(i / r.cols) * (cell + 2) + 1.5,
cell - 3, cell - 3);
}
}
// 每步消除数:绿条;每步连锁长度:白点。
const bx = x0 + r.cols * (cell + 2) + 26, bw = Math.floor((W - bx - 24) / SPEC.params.moveCount);
for (let i = 0; i < s.clearedPerMove.length; i += 1) {
const h = Math.min(150, s.clearedPerMove[i] * 4);
ctx.fillStyle = "#334155";
ctx.fillRect(bx + i * bw, 202 - 0, bw - 2, 150);
ctx.fillStyle = "#34d399";
ctx.fillRect(bx + i * bw, 202 - h, bw - 2, h);
ctx.fillStyle = "#e2e8f0";
ctx.fillRect(bx + i * bw, 202 + 8 + (s.chainCounts[i] - 1) * 8, bw - 2, 4);
}
ctx.fillStyle = "#94a3b8";
ctx.font = "12px system-ui, sans-serif";
ctx.fillText("棋盘 7×7(白框 = 首个可消交换)", x0, 38);
ctx.fillText("每步消除数(绿,1 格 = 4 点) · 连锁长度(白点)", bx, 38);
ctx.fillStyle = "#e2e8f0";
ctx.font = "13px system-ui, sans-serif";
ctx.fillText("分数 " + s.score + " / 目标 " + s.targetScore + " 步数 " + s.movesUsed + "/" +
SPEC.params.moveCount + " 最长连锁 " + s.maxChain + " 总消除 " + s.totalCleared, x0, 316);
ctx.fillText("非法交换 " + s.invalidSwaps + " 死局步 " + s.deadlockMoves + " 保底触发 " +
s.pityTriggers + " " + (s.won ? "达标" : "未达标"), x0, 338);
document.getElementById("out").textContent = m3Digest(s);
document.getElementById("dl").textContent = deadlockRate(false) + "‰ → " + deadlockRate(true) + "‰";
}
globalThis.__ashdeck = {
spec: SPEC,
run(ops) {
const sp = JSON.parse(JSON.stringify(SPEC));
snap = m3Run(sp, Array.isArray(ops) ? ops : undefined);
render(snap);
return m3Digest(snap);
},
snapshot() { return snap; },
proxy() { return { off: deadlockRate(false), on: deadlockRate(true) }; },
canvasDrawn: false,
};
globalThis.__ashdeck.run();
// 冷启动首个 page 的 canvas 在首帧合成前读像素恒为空白;推迟到两次 rAF 之后再置真,
// 让通用验证器的 waitForFunction 等到「已合成一帧」而不是「已调用 draw」。
requestAnimationFrame(() => requestAnimationFrame(() => {
globalThis.__ashdeck.canvasDrawn = true;
}));
`;
const body = `<h1>《灰烬牌库》· 三消变体(第 7 章)</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">代理指标 · 每步死局率 保底关 → 保底开(只做相对比较,禁绝对化)</div><div class="big" id="dl">—</div></div>`;
const html = page({
title: "《灰烬牌库》· 第 7.1 节三消的机制解剖",
subtitle: "品类内核 + 共享底座:读同一份骨架,产出同一条摘要",
body,
script,
});
const bytes = writePage(OUT, html);
const nodeDigest = m3Digest(m3Run(spec));
mkdirSync(join(ROOT, "out"), { recursive: true });
writeFileSync(join(ROOT, "out", "proto07_build.json"),
JSON.stringify({
spec: spec.id, modules: MODULES.length, kernelChars: core.length,
page: "out/proto07.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_proto07.mjs
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs --page out/proto07.html --shot out/shots/proto07.png --expect-digest "$(node -e 'console.log(JSON.parse(require("fs").readFileSync("out/proto07_build.json","utf8")).nodeDigest)')"
modules=3 kernelChars=10848
built /Users/bingrong.yan/.gdbook/ashdeck/out/proto07.html 18660 B
NODE_DIGEST={"block":6,"cardCount":49,"damageConserved":true,"dealt":166,"enemyHp":1011,"energy":0,"hand":["1","1","2","1","1","3","1","1","1","2","1","1","1","2","2","1","1","1","4","2","1","6","1","3"],"over":true,"played":24,"playerHp":1989,"turn":24,"won":false}
=== 通用断言 ===
ok 两遍独立加载 digest 逐字一致(确定性)
ok canvas 已绘制(230400 px >= 1)
ok 截图已落盘(87678 B)
ok digest 与 --expect-digest 一致
PAGE_DIGEST={"block":6,"cardCount":49,"damageConserved":true,"dealt":166,"enemyHp":1011,"energy":0,"hand":["1","1","2","1","1","3","1","1","1","2","1","1","1","2","2","1","1","1","4","2","1","6","1","3"],"over":true,"played":24,"playerHp":1989,"turn":24,"won":false}
OK W 层通过
四条断言全绿:PAGE_DIGEST 与构建期 Node 侧算出的 NODE_DIGEST 一字不差——页面与内核本来就是同一段源码的两个宿主。proxy() 另暴露一个代理指标:保底关与保底开时每步死局率的差(本页取 types=7 的高随机配置),实测 74‰ → 0‰,与 7.2 扫出来的焦点配置一致。
P 层:把机制字段接到断言上
W 层把字段画出来,P 层把字段断言掉。core/test/gd_proto_07_1_anatomy.test.ts 的做法是:改一个字段,最终摘要必须变;改不动,就说明那一格是文档装饰而不是机制。内核里负责「消除」与「掉落 / 连锁」两个字段的函数如下(「棋盘」与「交换」两个字段的代码在 7.2 里给出):
// snippet: core/genre/match3.ts(节选:m3Runs / m3Resolve)
/** 找所有长度 >= minRun 的同行 / 同列连续同色格;返回去重后的下标(升序)。 */
export function m3Runs(board: number[], rows: number, cols: number, minRun: number): number[] {
const mark = new Array<boolean>(rows * cols).fill(false);
for (let r = 0; r < rows; r += 1) {
let c = 0;
while (c < cols) {
let e = c;
while (e + 1 < cols && board[r * cols + e + 1] === board[r * cols + c]) e += 1;
if (e - c + 1 >= minRun) for (let k = c; k <= e; k += 1) mark[r * cols + k] = true;
c = e + 1;
}
}
for (let c = 0; c < cols; c += 1) {
let r = 0;
while (r < rows) {
let e = r;
while (e + 1 < rows && board[(e + 1) * cols + c] === board[r * cols + c]) e += 1;
if (e - r + 1 >= minRun) for (let k = r; k <= e; k += 1) mark[k * cols + c] = true;
r = e + 1;
}
}
const hit: number[] = [];
for (let i = 0; i < mark.length; i += 1) if (mark[i]) hit.push(i);
return hit;
}
/** 连锁结算:找 run → 计分 → 掉落 → 补牌(补牌**不避** run,连锁正是从这里长出来的)。 */
export function m3Resolve(board: number[], state0: number, rows: number, cols: number,
types: number, minRun: number, cascadeCap: number, scorePerTile: number,
cascadeBonusPermille: number, score0: number) {
let state = state0, score = score0, cleared = 0;
const chains: number[] = [];
for (let depth = 1; depth <= cascadeCap; depth += 1) {
const hit = m3Runs(board, rows, cols, minRun);
if (hit.length === 0) break;
const mult = 1000 + cascadeBonusPermille * (depth - 1);
score += idiv(hit.length * scorePerTile * mult, 1000);
cleared += hit.length;
chains.push(hit.length);
const gone = new Set<number>(hit);
for (let c = 0; c < cols; c += 1) {
let write = rows - 1;
for (let r = rows - 1; r >= 0; r -= 1) {
if (gone.has(r * cols + c)) continue;
board[write * cols + c] = board[r * cols + c];
write -= 1;
}
for (let r = write; r >= 0; r -= 1) {
state = rngNext(state);
board[r * cols + c] = state % types;
}
}
}
return { state, score, cleared, chains };
}
✔ 基线摘要冻结:与实跑逐字一致
✔ 确定性:同规格同种子两次运行摘要相同,换种子即变
✔ 不变量在基线、显式策略、关掉保底三种情况下都成立
✔ 棋盘是机制:开局填板避开 run(49 格全在 [0,types) 且无三连)
✔ 交换是机制:只有产生 run 的交换合法,非法交换被拒且不改变结果
✔ 消除是机制:每次消除数 >= minRun,且 minRun 3 → 4 会改变结果
✔ 掉落与补牌是机制:棋盘恒满,且颜色数 5 → 6 会改变结果
✔ 连锁是机制:最长连锁 <= cascadeCap,奖励 250‰ → 0 会改变结果
✔ 策略是机制:first 与 max 两种取法给出不同摘要
✔ 消除守恒:总消除数 == 每步消除数之和,且每步至少一次消除
✔ 品类状态投影回共享的 12 键摘要契约
✔ 规格骨架与 play_spec.json 同构:六段一个不缺
ℹ tests 12
ℹ suites 0
ℹ pass 12
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 762.555541
十二条全绿。基线摘要冻结在 dealt=166、played=24、block=6、playerHp=1989、turn=24、won=false——与 W 层页面里那条 PAGE_DIGEST 逐字相同。最后一条把 naming 的八个键与 play_spec.json 的键集对上,等于用断言证明了「同一份骨架」。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
node web/build_proto07.mjs | out/proto07.html | 3 模块内联,内核 10848 字符,页面 18660 B |
node web/verify_mech.mjs --page out/proto07.html --shot out/shots/proto07.png | out/shots/proto07.png | 4 断言全绿,截图 87678 B |
node --test 'core/test/gd_proto_07_1_anatomy.test.ts' | 断言结果 | 12 测试全绿,0 失败 |
五个字段已经拆开、各自可断言,naming 又把它们投影回共享契约。但字段拆开只说明「机制是什么」,不说明这个机制在跑的时候会不会卡住——下一节把「棋盘 / 交换」上的随机量成一个整数:死局率。
阅读导航:上一节:6.3 塔防的难度曲线 · 下一节:7.2 三消的随机与保底 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。