7.3 三消的节奏

三消没有回合,节奏量不出时长,只能量每步结算的形状:256 局 6144 步的连锁长度分布(1 层 639‰、2 层 232‰、尾巴拖到 10 层)与每步消除数分布(3-5 格 533‰、平均 6.182 格),加代表种子 70701 的24 步节奏线与 CSV + 纯 xml.etree SVG,并用 Python 镜像做跨语言逐字对账;节奏指标是代理指标。

7.3 三消的节奏

《小游戏开发实战》的核心循环拆解 用时间轴量节奏——一局里各个阶段各占多久;三消的节奏量不出时长,因为它没有「回合」这种自然的时间单位。本节换一把尺子:把节奏量成每步结算的形状,也就是两条整数分布。7.1 拆出五个字段,7.2 量了随机与保底,本节量「消除」与「连锁」两个字段在时间上的排布。若要看心流与难度曲线怎么和节奏挂钩,《小游戏开发实战》的难度-心流曲线 给的是模型;本节给的是可算的分布。

先说口径:节奏、方差、分布形状都是代理指标(proxy),只做相对比较,禁绝对化。两条分布的形状好看只说明这批种子下起伏分明,不等于「手感更好」。

节奏不是时长,是每步结算的形状

三消的一局就是 24 次交换。每次交换的结果有两个整数可以量:

  • 连锁长度:这一次交换一共触发了几层连续消除(1 = 只消了一层,6 = 消完之后掉下来的又成三连,连锁六层);
  • 每步消除数:这一次交换总共消掉了几格。

两个分布合起来就是节奏谱。同样一局 24 步,「连锁长而少」意味着稀疏的大起伏——大部分步只消一层,偶尔来一次雪崩;「连锁短而多」意味着密集的小碎步,每步都消一点但从不爆。这两种节奏在同样的总消除数下,玩家感受完全不同——而它们都可以用同一份规格里的 cascadeCap / types / policy 调出来。

两条整数分布

256 个种子、6144 步全量统计,连锁长度分布:

连锁长度步数千分比
13929639‰
21428232‰
348679‰
418930‰
57111‰
6254‰
791‰
840‰
920‰
1010‰

前 3 层占了 950‰——绝大多数步就是「消一层」。但尾巴拖得很长:最长的连锁到 10 层(cascadeCap=16 从未触顶)。平均连锁 1566‰(1.567 层)。每步消除数分布(按 3 格一段分箱):

消除数区间步数千分比
3-53280533‰
6-81531249‰
9-11648105‰
12-1432352‰
15-1715124‰
18-2010116‰
21-23477‰
24-26233‰
27-29101‰
30-32152‰
33-3571‰
36-3840‰
39+40‰

平均每步消除 6181‰(6.182 格)。两条分布都单调递减(只有 27-29 → 30-32 一处 10 → 15 的小反翘,来自 30 格附近的「十字 + 连锁」形状),且分箱计数之和恒等于 6144——每一格都归了档,没有漏。

节奏谱:CSV + 纯 xml.etree SVG

sim/gd_proto_07_rhythm.py 是三消内核的 Python 镜像(S 层),与 TS 内核逐条对应,产出 out/proto07_rhythm.csv(71 行)、out/proto07_rhythm.svg(纯 xml.etree,无第三方绘图库)与 .json。它跑出来的代表种子摘要与 TS 侧逐字相同——这是「同源异宿主」的证据:

# snippet: sim/gd_proto_07_rhythm.py(节选:分箱与节奏谱绘制)
    chain_max_obs = max(chain_hist)
    step, cmax = rh["clearedStep"], rh["clearedMax"]
    bands, lo = [], 3
    while lo <= cmax:
        hi = min(lo + step - 1, cmax)
        bands.append((f"{lo}-{hi}", lo, hi))
        lo += step
    if max(clear_hist) > cmax:
        bands.append((f"{cmax + 1}+", cmax + 1, max(clear_hist)))
    band_rows = []
    for label, lo, hi in bands:
        n = sum(clear_hist.get(i, 0) for i in range(lo, hi + 1))
        band_rows.append((label, lo, hi, n, 1000 * n // moves))
种子池 256 局 · 6144 步 · 总消除 37981
平均连锁 = 1566‰(1.567)  平均每步消除 = 6181‰(6.182)
每局最长连锁分布(2..10)= [8, 67, 87, 56, 23, 8, 4, 2, 1]
代表种子 70701 的节奏线(每步消除数)= [6, 8, 6, 4, 3, 9, 3, 3, 7, 7, 3, 6, 3, 6, 7, 3, 3, 6, 15, 8, 4, 33, 3, 10]
代表种子 70701 的连锁长度 = [1, 1, 2, 1, 1, 3, 1, 1, 1, 2, 1, 1, 1, 2, 2, 1, 1, 1, 4, 2, 1, 6, 1, 3]
digest(seed=70701) = {"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}
csv=71 rows  svg=14667 bytes  elapsed=5211 ms
wrote out/proto07_rhythm.csv out/proto07_rhythm.svg out/proto07_rhythm.json

上面这段是脚本输出里的摘要行(① 连锁长度分布与 ② 每步消除数分布两条明细已原样列在上面的两张表里)。注意 digest(seed=70701) 那行:它与 7.1 的 P 层基线逐字相同。也就是说同一份规格、同一个种子,在 TypeScript 内核与 Python 镜像里跑出的每一步消除数、每一层连锁长度都一致——节奏谱不是「另一个实现的大致结果」,是同一个机制的两次相同投影。末行的 elapsed 是墙钟时间,随机器负载浮动(本机 4.5–5.3 s),它不是断言的一部分。

代表种子 70701 的节奏线

把种子 70701 的 24 步摊开,就是一条具体的节奏线:

步123456789101112
消除数686439337736
连锁112113111211
步131415161718192021222324
消除数367336158433310
连锁122111421613

这条线是典型的「稀疏的大起伏」:前 18 步里有 15 步只消 3–8 格、连锁 1 层,然后第 19 步(15 格 / 4 层)与第 22 步(33 格 / 6 层)接连爆两次。正是这两步把整局的分数推上去——如果把它们换成两步普通的 6 格消除,总分要掉一大截。这就是节奏的可操作定义:不是「快慢」,是大起伏在 24 步里的位置与间隔。

三个旋钮把节奏调出来

旋钮值总消除平均连锁平均每步消除形状变化
基线cascadeCap=16379811566‰6181‰长尾拖到 10 层
连锁上限cascadeCap=1357981000‰5826‰所有连锁恒为 1 层,长尾消失
颜色数types=6286951323‰4670‰更少的大起伏,分布整体压扁
策略policy=first285361415‰4644‰贪心取首个,雪崩变少
最小连线minRun=4276041081‰4511‰更难成三连,节奏变碎

五档都是同一份规格、同一批种子,只改一个字段。cascadeCap=1 是最干净的一刀:所有连锁恒为 1 层,平均连锁正好落到 1000‰——这条断言本身就是「连锁是节奏的旋钮」的证明。

P 层:把分布写成整数

core/test/gd_proto_07_3_rhythm.test.ts 在 TS 侧重跑了同一批种子,把两条分布与上面每一档都冻成断言:

// file: core/test/gd_proto_07_3_rhythm.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_07_3_rhythm.test.ts'
//
// 卷三 7.3 三消的节奏(P 层):节奏不是时长,是**每步结算的形状**。本节把「节奏」量成两条
// 整数分布——连锁长度分布(一次交换触发几次连续消除)与每步消除数分布(一次交换总共消掉
// 几格)——用同一批 256 个种子扫出 6144 步,冻结在 TS 侧。S 层 sim/gd_proto_07_rhythm.py 是
// 同一内核的 Python 镜像,两条摘要必须逐字相同(同源异宿主)。节奏指标是**代理指标(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 RH = JSON.parse(readFileSync(join(ROOT, "config", "gd_proto_07_rhythm.json"), "utf8"));
const R = RH.rhythm;
const POOL = Array.from({ length: R.seedCount }, (_, i) => R.seedStart + i);

type Dist = {
  moves: number; cleared: number; meanChain: number; meanCleared: number;
  chain: number[]; chainPm: number[]; maxChainPerSeed: number[];
  clearMax: number; clearSum: number; clearHist: Map<number, number>;
};

/** 一次扫描:固定种子池,只有 rules 在动。全部整数统计。 */
function scan(spec: any): Dist {
  const ch = new Map<number, number>(), cl = new Map<number, number>(), mc = new Map<number, number>();
  let moves = 0, cleared = 0, csum = 0;
  for (const sd of POOL) {
    const s = m3Run({ ...spec, seed: sd });
    for (const c of s.chainCounts) { ch.set(c, (ch.get(c) ?? 0) + 1); csum += c; moves += 1; }
    for (const v of s.clearedPerMove) cl.set(v, (cl.get(v) ?? 0) + 1);
    cleared += s.totalCleared;
    mc.set(s.maxChain, (mc.get(s.maxChain) ?? 0) + 1);
  }
  const cmax = Math.max(...ch.keys());
  let clearSum = 0;
  for (const n of cl.values()) clearSum += n;
  return {
    moves, cleared,
    meanChain: Math.floor((1000 * csum) / moves),
    meanCleared: Math.floor((1000 * cleared) / moves),
    chain: Array.from({ length: cmax }, (_, i) => ch.get(i + 1) ?? 0),
    chainPm: Array.from({ length: cmax }, (_, i) => Math.floor((1000 * (ch.get(i + 1) ?? 0)) / moves)),
    maxChainPerSeed: Array.from({ length: cmax }, (_, i) => mc.get(i + 1) ?? 0),
    clearMax: Math.max(...cl.keys()), clearSum, clearHist: cl,
  };
}

const t0 = process.hrtime.bigint();
const BASE = scan(RH);
const elapsedMs = Number((process.hrtime.bigint() - t0) / 1000000n);

const mutate = (fn: (s: any) => void) => {
  const s = JSON.parse(JSON.stringify(RH));
  fn(s);
  return scan(s);
};

test("基线节奏冻结:256 局 6144 步、总消除 37981、平均连锁 1566‰、平均每步消除 6181‰", () => {
  assert.equal(POOL.length, 256);
  assert.equal(BASE.moves, 6144);
  assert.equal(BASE.cleared, 37981);
  assert.equal(BASE.meanChain, 1566);
  assert.equal(BASE.meanCleared, 6181);
  assert.equal(BASE.chain.length, 10);
});

test("连锁长度分布冻结:1/2/3 层占 950‰,尾巴拖到 10 层", () => {
  assert.deepEqual(BASE.chain, [3929, 1428, 486, 189, 71, 25, 9, 4, 2, 1]);
  assert.deepEqual(BASE.chainPm, [639, 232, 79, 30, 11, 4, 1, 0, 0, 0]);
  const head = BASE.chainPm[0] + BASE.chainPm[1] + BASE.chainPm[2];
  assert.equal(head, 950);
  assert.equal(BASE.chainPm[0] > BASE.chainPm[1], true);
  assert.equal(BASE.chain[9], 1);
});

test("连锁分布单调递减:越长的连锁越罕见(代理指标,只做相对比较)", () => {
  for (let i = 1; i < BASE.chain.length; i += 1) {
    assert.equal(BASE.chain[i] <= BASE.chain[i - 1], true, `第 ${i + 1} 档不递减`);
  }
  assert.equal(BASE.chain[0] > BASE.chain[9] * 100, true);
});

test("每步消除数分布覆盖全部 6144 步,无漏档", () => {
  assert.equal(BASE.clearSum, BASE.moves);
  assert.equal(BASE.clearSum, 6144);
  assert.equal(BASE.clearMax, 50);
  assert.equal(BASE.clearHist.get(3), 2485);
  assert.equal(BASE.clearHist.get(4), 602);
  assert.equal(BASE.clearHist.get(6), 1104);
});

test("每局最长连锁分布冻结:2 层 8 局 / 3 层 67 局 / 4 层 87 局(众数)", () => {
  assert.deepEqual(BASE.maxChainPerSeed, [0, 8, 67, 87, 56, 23, 8, 4, 2, 1]);
  assert.equal(BASE.maxChainPerSeed.reduce((a, b) => a + b, 0), 256);
  assert.equal(BASE.maxChainPerSeed[3], 87);
  assert.equal(BASE.maxChainPerSeed[0], 0);
});

test("代表种子 70701 的节奏线冻结:稀疏的大起伏 + 一次 33 格雪崩", () => {
  const s = m3Run({ ...RH, seed: R.sampleSeed });
  assert.deepEqual(s.clearedPerMove,
    [6, 8, 6, 4, 3, 9, 3, 3, 7, 7, 3, 6, 3, 6, 7, 3, 3, 6, 15, 8, 4, 33, 3, 10]);
  assert.deepEqual(s.chainCounts,
    [1, 1, 2, 1, 1, 3, 1, 1, 1, 2, 1, 1, 1, 2, 2, 1, 1, 1, 4, 2, 1, 6, 1, 3]);
  assert.equal(s.maxChain, 6);
  assert.equal(s.totalCleared, 166);
});

test("同源异宿主:TS 侧与 S 层 Python 镜像的代表种子摘要逐字相同", () => {
  const here = m3Digest(m3Run({ ...RH, seed: R.sampleSeed }));
  const py = JSON.parse(readFileSync(join(ROOT, "out", "proto07_rhythm.json"), "utf8"));
  assert.equal(py.sampleSeed, R.sampleSeed);
  assert.equal(here, py.sampleDigest);
});

test("连锁是节奏的旋钮:cascadeCap 1 → 所有连锁恒为 1 层", () => {
  const r = mutate((s) => { s.rules.cascadeCap = 1; });
  assert.deepEqual(r.chain, [6144]);
  assert.equal(r.meanChain, 1000);
  assert.equal(r.cleared, 35798);
  assert.equal(r.cleared < BASE.cleared, true);
});

test("颜色数是节奏的旋钮:5 → 6 色把平均连锁压到 1323‰、总消除降到 28695", () => {
  const r = mutate((s) => { s.rules.types = 6; });
  assert.equal(r.meanChain, 1323);
  assert.equal(r.meanCleared, 4670);
  assert.equal(r.cleared, 28695);
  assert.equal(r.chain.length, 7);
  assert.equal(r.meanChain < BASE.meanChain, true);
});

test("策略是节奏的旋钮:first 比 max 少消 9445 格、连锁也更短", () => {
  const r = mutate((s) => { s.rules.policy = "first"; });
  assert.equal(r.cleared, 28536);
  assert.equal(r.meanChain, 1415);
  assert.equal(BASE.cleared - r.cleared, 9445);
  assert.equal(r.meanChain < BASE.meanChain, true);
});

test("minRun 3 → 4 会改变节奏,且步数不再是 6144(部分步无可消交换)", () => {
  const r = mutate((s) => { s.rules.minRun = 4; });
  assert.equal(r.cleared, 27604);
  assert.equal(r.meanChain, 1081);
  assert.equal(r.moves < 6144, true);
  assert.equal(r.chain.length, 4);
});

test("扫描是确定性的,且总耗时在分钟级以内", () => {
  assert.equal(elapsedMs < 60000, true);
  const again = scan(RH);
  assert.deepEqual(again.chain, BASE.chain);
  assert.equal(again.cleared, BASE.cleared);
  assert.equal(again.meanChain, BASE.meanChain);
});
✔ 基线节奏冻结:256 局 6144 步、总消除 37981、平均连锁 1566‰、平均每步消除 6181‰
✔ 连锁长度分布冻结:1/2/3 层占 950‰,尾巴拖到 10 层
✔ 连锁分布单调递减:越长的连锁越罕见(代理指标,只做相对比较)
✔ 每步消除数分布覆盖全部 6144 步,无漏档
✔ 每局最长连锁分布冻结:2 层 8 局 / 3 层 67 局 / 4 层 87 局(众数)
✔ 代表种子 70701 的节奏线冻结:稀疏的大起伏 + 一次 33 格雪崩
✔ 同源异宿主:TS 侧与 S 层 Python 镜像的代表种子摘要逐字相同
✔ 连锁是节奏的旋钮:cascadeCap 1 → 所有连锁恒为 1 层
✔ 颜色数是节奏的旋钮:5 → 6 色把平均连锁压到 1323‰、总消除降到 28695
✔ 策略是节奏的旋钮:first 比 max 少消 9445 格、连锁也更短
✔ minRun 3 → 4 会改变节奏,且步数不再是 6144(部分步无可消交换)
✔ 扫描是确定性的,且总耗时在分钟级以内
ℹ tests 12
ℹ suites 0
ℹ pass 12
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1973.460584

十二条全绿,其中一条专门盯着跨语言对账:它把 TS 侧算出的代表种子摘要与 out/proto07_rhythm.json 里的 sampleDigest 逐字比对——S 层与 P 层必须给出同一条摘要。

本节实跑清单

命令产物实测
/opt/homebrew/bin/python3 sim/gd_proto_07_rhythm.pyout/proto07_rhythm.csv / .svg / .json71 行 CSV,SVG 14667 B,256 局 6144 步
node --test 'core/test/gd_proto_07_3_rhythm.test.ts'断言结果12 测试全绿,0 失败
NODE_PATH=… node web/build_proto07.mjsout/proto07.html3 模块内联,内核 10848 字符

三个品类小节到此收束:7.1 把机制拆成字段,7.2 把随机量成死局率与保底,7.3 把节奏量成两条整数分布。三消与贯穿案例(卡牌构筑)共用同一份六段骨架、同一个 digestSnap,只在 rules / params 上分岔——而且这一章多了一层:同一套机制在 TypeScript 与 Python 两个宿主上逐字一致。下一章换平台跳跃,重做一遍同样的解剖、技巧曲线与心流带宽。

阅读导航:上一节:7.2 三消的随机与保底 · 下一节:8.1 平台跳跃的机制解剖 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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