7.2 三消的随机与保底
《小游戏开发实战》的核心循环拆解 把一局拆成「打牌 / 结算 / 决策」几个阶段量时长占比;本节拆的是三消里最容易被当成「运气」的那一段——补牌。三消的随机不是「抽一张牌」,是整块棋盘:每消掉几格,就有几格新色从顶部补进来,抽出来的局面可能根本没有合法交换。本节把这段随机量成一个整数,再看保底怎么把它按下去。若要看随机与保底在产品侧怎么取舍,《小游戏开发实战》的品类分类 讲的是商业矩阵,本节只算机制账。
7.1 把机制拆成五个字段,其中「棋盘」与「交换」两个字段上坐着本节的主角:填板与补牌都走 rngNext,而「有没有合法交换」是可以判定的。7.1 里那条 m3FirstMove(board, …) === null 就是死局的定义。随机指标与死局率都是代理指标(proxy),只做相对比较,禁绝对化:死局率低只说明不容易卡住,不等于「更好玩」。
死局:三消唯一会「卡住」的地方
三消没有失败态,唯一能让玩家停下来的情况是棋盘上没有任何一步合法交换——所有相邻交换都不产生三连。这不是稀有事件:7×7 的棋盘上,颜色越多,「任意一对相邻格不同色」的概率越高,但「交换后能连成三个」的概率反而越低。用同一批 256 个种子、同一份规则,只把 rules.types 从 4 推到 7,每步死局率如下(千分比,deadlockMoves / movesUsed,关掉保底):
| 颜色数 | 死局步数 | 每步死局率 | 读法 |
|---|---|---|---|
| 4 | 0 | 0‰ | 棋盘几乎不会卡 |
| 5 | 23 | 3‰ | 偶发 |
| 6 | 93 | 15‰ | 每 67 步一次 |
| 7 | 744 | 121‰ | 每 8 步就有 1 步开局无解 |
这不是线性上升:5 → 6 翻 5 倍,6 → 7 再翻 8 倍——颜色数是死局率的放大器。真实三消普遍用 5–7 色,正是因为这个区间里「够难但不至于频繁卡住」。
保底:连击计数 + 整板重排
保底的机制只有两行:连续 threshold 次遇到死局就介入,用同一串 rng 重排整板,直到存在合法交换为止(最多 maxAttempts 次)。关键在于「同一串 rng」——重排不是 Math.random(),是把当前 rng 状态继续往前推,所以保底本身也是可播种、可回放的。配置落在 config/gd_proto_07_pity.json 的 scan 段:
"scan": {
"seedStart": 70701,
"seedCount": 256,
"typesLadder": [4, 5, 6, 7],
"thresholdLadder": [1, 2, 3, 4],
"maxAttemptsLadder": [1, 2, 4, 8],
"focusTypes": 7,
"focusThreshold": 1,
"focusMaxAttempts": 4
}
内核里的实现是 7.1 那张表「交换」字段的延伸:
// snippet: core/genre/match3.ts(节选:m3Run 的死局检测与保底重排)
// 1) 死局检测:无任何可消交换时,保底按 threshold 连击计数后重排整板。
let mv = m3FirstMove(board, rows, cols, minRun);
if (mv === null) {
streak += 1;
if (pity.enabled && streak >= pity.threshold) {
for (let a = 0; a < pity.maxAttempts; a += 1) {
state = m3FillAvoid(board, state, rows, cols, types);
if (m3FirstMove(board, rows, cols, minRun) !== null) { pityTriggers += 1; streak = 0; break; }
}
mv = m3FirstMove(board, rows, cols, minRun);
}
if (mv === null) deadlockMoves += 1;
} else { streak = 0; }
streak 是「连续遇到死局」的计数,一旦某步有合法交换就清零。threshold=1 表示一遇到就介入;threshold=2 表示连遇两次才介入。pityTriggers 记录保底真的救回来的次数,deadlockMoves 只记「保底用尽 maxAttempts 之后仍然死局」的步数——两者的差就是保底的功劳。
焦点配置(7 色):121‰ → 0‰
取颜色数 7 的焦点配置(最容易被卡住的一档),保底前 / 后逐项对比:
| 指标 | 保底关 | 保底开 | 读法 |
|---|---|---|---|
| 每步死局率 | 121‰ | 0‰ | 744 步 → 0 步 |
| 受影响局数占比 | 269‰ | 0‰ | 69 / 256 局 → 0 局 |
| 保底触发次数 | — | 78 | 平均每局 0.30 次 |
| 平均分 | 892 | 1022 | +130 |
| 分数标准差 | 265 | 132 | 方差 70423 → 17517 |
| 终局死局率 | 273‰ | 11‰ | 终局板无交换 ≠ 玩家卡死 |
保底把死局率从 121‰ 压到 0‰——「卡住」这件事在这批种子下彻底消失。但它不是白拿的:保底触发 78 次,等于平均每 3.3 局就要重排一次棋盘,玩家会看到棋盘「忽然全变」。这是保底最真实的代价,也是为什么 threshold 不该设成 1 就完事。
保底削掉的是下尾
平均分从 892 升到 1022,听起来像「保底让游戏变简单」。但真正变化的是分布的形状:方差从 70423 掉到 17517(标准差 265 → 132),几乎砍掉四分之三。也就是说保底主要不是抬高均值,是砍掉那些因为卡住而废掉的低分局——玩家不会因为棋盘无解而拿到一个特别难看的分。
一个反证:4 色配置上死局率本来就是 0‰,保底逐字没有改变任何结果(均值、方差、触发数全等)。这说明保底只在需要它的地方生效,不是无条件扰动。
两个旋钮:threshold 与 maxAttempts
threshold | 每步死局率 | 读法 |
|---|---|---|
| 1 | 0‰ | 一遇就介入,卡住消失 |
| 2 | 12‰ | 连遇两次才介入,留一点「真死局」 |
| 3 | 24‰ | 更松,残留下尾更厚 |
| 4 | 35‰ | 最松,约为关掉保底的三成 |
maxAttempts | 每步死局率 | 死局步数 | 保底触发 |
|---|---|---|---|
| 1 | 0‰ | 1 | 77 |
| 2 | 0‰ | 0 | 78 |
| 4 | 0‰ | 0 | 78 |
| 8 | 0‰ | 0 | 78 |
threshold 是什么时候介入:越松,残留下的死局越多,曲线单调(0 → 12 → 24 → 35‰)。maxAttempts 是介入后多努力:1 次重排会留下 1 步死局,2 次就清零——因为 m3FillAvoid 本来就是「避开 run 的填板」,重排一次能成的概率已经很高,加次数几乎不增加触发数(77 → 78 是同一批种子里多救回来 1 次)。重排次数有上界即可,这是工程上的好消息:不用担心保底本身变成一个耗时黑洞。
P 层:把保底写成整数
core/test/gd_proto_07_2_randomness_pity.test.ts 复刻了上面全部扫描,把每条结论冻成断言:
// file: core/test/gd_proto_07_2_randomness_pity.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_07_2_randomness_pity.test.ts'
//
// 卷三 7.2 三消的随机与保底(P 层):三消的随机不是「抽一张牌」,是**整块棋盘**——每次补牌
// 都是一次可播种的整数抽取,抽出来的局面可能根本没有合法交换(死局)。保底就是在这个点上
// 介入:连击计数到 threshold 后,用同一串 rng 重排整板直到存在合法交换(可播种、可回放)。
// 本节用整数度量保底前后的**死局率与分数方差**:颜色数越高随机越强,死局率越高,保底的作用
// 越大。死局率、胜率、分数方差都是**代理指标(proxy)**,只做相对比较,禁绝对化。
// 全整数、无浮点、无 Math.random。
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 { m3Run, m3Digest } from "../genre/match3.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const PITY = JSON.parse(readFileSync(join(ROOT, "config", "gd_proto_07_pity.json"), "utf8"));
const S = PITY.scan;
const POOL = Array.from({ length: S.seedCount }, (_, i) => S.seedStart + i);
type Stat = {
moves: number; deadlockMoves: number; permille: number; stuckRuns: number; stuckPermille: number;
endDeadlockRuns: number; endPermille: number; scoreMean: number; scoreVar: number;
scoreSd: number; pityTriggers: number;
};
/** 一次扫描:固定种子池,只有 types / pity 三个旋钮在动。全部整数统计。 */
function scan(types: number, enabled: boolean, threshold: number, maxAttempts: number): Stat {
const spec = JSON.parse(JSON.stringify(PITY));
spec.rules.types = types;
spec.rules.pity = { enabled, threshold, maxAttempts };
let moves = 0, dl = 0, stuck = 0, end = 0, pity = 0;
const scores: number[] = [];
for (const sd of POOL) {
const s = m3Run({ ...spec, seed: sd });
moves += s.movesUsed; dl += s.deadlockMoves; pity += s.pityTriggers;
if (s.deadlockMoves > 0) stuck += 1;
if (s.endDeadlock) end += 1;
scores.push(s.score);
}
const mean = Math.floor(scores.reduce((a, b) => a + b, 0) / scores.length);
const varr = Math.floor(scores.reduce((a, b) => a + (b - mean) * (b - mean), 0) / scores.length);
return {
moves, deadlockMoves: dl, permille: Math.floor((1000 * dl) / moves),
stuckRuns: stuck, stuckPermille: Math.floor((1000 * stuck) / POOL.length),
endDeadlockRuns: end, endPermille: Math.floor((1000 * end) / POOL.length),
scoreMean: mean, scoreVar: varr, scoreSd: Math.floor(Math.sqrt(varr)), pityTriggers: pity,
};
}
const t0 = process.hrtime.bigint();
const OFF: Stat[] = S.typesLadder.map((t: number) => scan(t, false, S.focusThreshold, S.focusMaxAttempts));
const ON: Stat[] = S.typesLadder.map((t: number) => scan(t, true, S.focusThreshold, S.focusMaxAttempts));
const TH: Stat[] = S.thresholdLadder.map((th: number) => scan(S.focusTypes, true, th, S.focusMaxAttempts));
const MA: Stat[] = S.maxAttemptsLadder.map((ma: number) => scan(S.focusTypes, true, S.focusThreshold, ma));
const elapsedMs = Number((process.hrtime.bigint() - t0) / 1000000n);
test("死局率随颜色数上升:4/5/6/7 色分别 0/3/15/121‰", () => {
assert.deepEqual(S.typesLadder, [4, 5, 6, 7]);
assert.deepEqual(OFF.map((s) => s.permille), [0, 3, 15, 121]);
assert.deepEqual(OFF.map((s) => s.deadlockMoves), [0, 23, 93, 744]);
assert.equal(OFF[3].permille > OFF[2].permille * 8, true);
});
test("保底把所有档位的每步死局率压到 0‰", () => {
assert.deepEqual(ON.map((s) => s.permille), [0, 0, 0, 0]);
assert.deepEqual(ON.map((s) => s.deadlockMoves), [0, 0, 0, 0]);
assert.deepEqual(ON.map((s) => s.stuckPermille), [0, 0, 0, 0]);
});
test("焦点配置(7 色):保底把每步死局率 121‰ 压到 0‰、受影响局数 269‰ 压到 0‰", () => {
const off = OFF[3], on = ON[3];
assert.equal(off.permille, 121);
assert.equal(on.permille, 0);
assert.equal(off.stuckPermille, 269);
assert.equal(off.stuckRuns, 69);
assert.equal(on.stuckPermille, 0);
assert.equal(on.stuckRuns, 0);
assert.equal(on.pityTriggers, 78);
});
test("保底削掉的是下尾:均值 892 → 1022,标准差 265 → 132(方差 70423 → 17517)", () => {
const off = OFF[3], on = ON[3];
assert.equal(off.scoreMean, 892);
assert.equal(on.scoreMean, 1022);
assert.equal(off.scoreVar, 70423);
assert.equal(on.scoreVar, 17517);
assert.equal(off.scoreSd, 265);
assert.equal(on.scoreSd, 132);
assert.equal(off.scoreVar > on.scoreVar * 4, true);
});
test("无死局的配置上保底是空操作:4 色时关与开的分数逐字相同", () => {
assert.equal(OFF[0].permille, 0);
assert.equal(OFF[0].scoreMean, ON[0].scoreMean);
assert.equal(OFF[0].scoreVar, ON[0].scoreVar);
assert.equal(ON[0].pityTriggers, 0);
});
test("threshold 越松保底越晚介入:1/2/3/4 分别 0/12/24/35‰", () => {
assert.deepEqual(S.thresholdLadder, [1, 2, 3, 4]);
assert.deepEqual(TH.map((s) => s.permille), [0, 12, 24, 35]);
assert.equal(TH[0].permille < TH[3].permille, true);
});
test("重排次数有上界即可:maxAttempts 1 次留 1 步死局,>= 2 次清零", () => {
assert.deepEqual(S.maxAttemptsLadder, [1, 2, 4, 8]);
assert.deepEqual(MA.map((s) => s.permille), [0, 0, 0, 0]);
assert.deepEqual(MA.map((s) => s.deadlockMoves), [1, 0, 0, 0]);
assert.deepEqual(MA.map((s) => s.pityTriggers), [77, 78, 78, 78]);
});
test("保底是可播种的:同种子同配置两次运行摘要与触发数逐字相同", () => {
const spec = JSON.parse(JSON.stringify(PITY));
spec.rules.types = S.focusTypes;
spec.rules.pity = { enabled: true, threshold: 1, maxAttempts: 4 };
const a = m3Run({ ...spec, seed: 70711 });
const b = m3Run({ ...spec, seed: 70711 });
assert.equal(m3Digest(a), m3Digest(b));
assert.equal(a.pityTriggers, b.pityTriggers);
assert.notEqual(m3Digest(m3Run({ ...spec, seed: 70712 })), m3Digest(a));
});
test("终局死局与卡死不是一回事:游戏结束后终局板仍可能无交换(7 色 11‰)", () => {
assert.equal(ON[3].endPermille, 11);
assert.equal(ON[3].endDeadlockRuns, 3);
assert.equal(OFF[3].endPermille, 273);
assert.equal(ON[3].endPermille < OFF[3].endPermille, true);
});
test("扫描是确定性的,且总耗时在分钟级以内", () => {
assert.equal(elapsedMs < 60000, true);
const again = scan(S.focusTypes, false, 1, 4);
assert.equal(again.permille, OFF[3].permille);
assert.equal(again.scoreMean, OFF[3].scoreMean);
assert.equal(POOL.length, 256);
assert.equal(again.moves, 6144);
});
✔ 死局率随颜色数上升:4/5/6/7 色分别 0/3/15/121‰
✔ 保底把所有档位的每步死局率压到 0‰
✔ 焦点配置(7 色):保底把每步死局率 121‰ 压到 0‰、受影响局数 269‰ 压到 0‰
✔ 保底削掉的是下尾:均值 892 → 1022,标准差 265 → 132(方差 70423 → 17517)
✔ 无死局的配置上保底是空操作:4 色时关与开的分数逐字相同
✔ threshold 越松保底越晚介入:1/2/3/4 分别 0/12/24/35‰
✔ 重排次数有上界即可:maxAttempts 1 次留 1 步死局,>= 2 次清零
✔ 保底是可播种的:同种子同配置两次运行摘要与触发数逐字相同
✔ 终局死局与卡死不是一回事:游戏结束后终局板仍可能无交换(7 色 11‰)
✔ 扫描是确定性的,且总耗时在分钟级以内
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 5236.972
十条全绿。核心那条把 121‰ 与 0‰ 钉死:OFF[3].permille === 121、ON[3].permille === 0。最后一条顺带证了扫描是确定性的——重跑 scan(7, false, 1, 4) 得到同样的 121‰ 与同样的 6144 步。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
node --test 'core/test/gd_proto_07_2_randomness_pity.test.ts' | 断言结果 | 10 测试全绿,0 失败,扫描 256 局 × 多档 < 6 s |
node web/build_proto07.mjs | out/proto07.html | proxy() 实测 74‰ → 0‰(32 种子 / 7 色) |
随机量完了,但「随机」只是棋盘的一个侧面。同样一步交换,消掉 3 格和消掉 33 格在玩家那边是两种体验——下一节把每步的结算形状量成两条整数分布,做出三消的节奏谱。
阅读导航:上一节:7.1 三消的机制解剖 · 下一节:7.3 三消的节奏 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。