7.2 三消的随机与保底

三消的随机不是抽一张牌,是整块棋盘:每次补牌都可能补出一个没有任何合法交换的死局。本节用同一批 256 个种子量死局率,再把「连续 N 次无可消就重排整板」的保底写成可播种的整数代码——保底把死局率从 121‰ 压到 0‰,同时把分数标准差从 265 削到 132;死局率与方差是代理指标,只做相对比较。

7.2 三消的随机与保底

《小游戏开发实战》的核心循环拆解 把一局拆成「打牌 / 结算 / 决策」几个阶段量时长占比;本节拆的是三消里最容易被当成「运气」的那一段——补牌。三消的随机不是「抽一张牌」,是整块棋盘:每消掉几格,就有几格新色从顶部补进来,抽出来的局面可能根本没有合法交换。本节把这段随机量成一个整数,再看保底怎么把它按下去。若要看随机与保底在产品侧怎么取舍,《小游戏开发实战》的品类分类 讲的是商业矩阵,本节只算机制账。

7.1 把机制拆成五个字段,其中「棋盘」与「交换」两个字段上坐着本节的主角:填板与补牌都走 rngNext,而「有没有合法交换」是可以判定的。7.1 里那条 m3FirstMove(board, …) === null 就是死局的定义。随机指标与死局率都是代理指标(proxy),只做相对比较,禁绝对化:死局率低只说明不容易卡住,不等于「更好玩」。

死局:三消唯一会「卡住」的地方

三消没有失败态,唯一能让玩家停下来的情况是棋盘上没有任何一步合法交换——所有相邻交换都不产生三连。这不是稀有事件:7×7 的棋盘上,颜色越多,「任意一对相邻格不同色」的概率越高,但「交换后能连成三个」的概率反而越低。用同一批 256 个种子、同一份规则,只把 rules.types 从 4 推到 7,每步死局率如下(千分比,deadlockMoves / movesUsed,关掉保底):

颜色数死局步数每步死局率读法
400‰棋盘几乎不会卡
5233‰偶发
69315‰每 67 步一次
7744121‰每 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 次
平均分8921022+130
分数标准差265132方差 70423 → 17517
终局死局率273‰11‰终局板无交换 ≠ 玩家卡死

保底把死局率从 121‰ 压到 0‰——「卡住」这件事在这批种子下彻底消失。但它不是白拿的:保底触发 78 次,等于平均每 3.3 局就要重排一次棋盘,玩家会看到棋盘「忽然全变」。这是保底最真实的代价,也是为什么 threshold 不该设成 1 就完事。

保底削掉的是下尾

平均分从 892 升到 1022,听起来像「保底让游戏变简单」。但真正变化的是分布的形状:方差从 70423 掉到 17517(标准差 265 → 132),几乎砍掉四分之三。也就是说保底主要不是抬高均值,是砍掉那些因为卡住而废掉的低分局——玩家不会因为棋盘无解而拿到一个特别难看的分。

一个反证:4 色配置上死局率本来就是 0‰,保底逐字没有改变任何结果(均值、方差、触发数全等)。这说明保底只在需要它的地方生效,不是无条件扰动。

两个旋钮:threshold 与 maxAttempts

threshold每步死局率读法
10‰一遇就介入,卡住消失
212‰连遇两次才介入,留一点「真死局」
324‰更松,残留下尾更厚
435‰最松,约为关掉保底的三成
maxAttempts每步死局率死局步数保底触发
10‰177
20‰078
40‰078
80‰078

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.mjsout/proto07.htmlproxy() 实测 74‰ → 0‰(32 种子 / 7 色)

随机量完了,但「随机」只是棋盘的一个侧面。同样一步交换,消掉 3 格和消掉 33 格在玩家那边是两种体验——下一节把每步的结算形状量成两条整数分布,做出三消的节奏谱。

阅读导航:上一节:7.1 三消的机制解剖 · 下一节:7.3 三消的节奏 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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