Fork me on GitHub

六颗骰子一只碗:用 React + TypeScript 实现闽南中秋博饼模拟器

中秋博饼是闽南地区的保留节目:一只大碗、六颗骰子,一家人轮流掷下去,比谁博出的「状元」大。规则听着简单,真写进代码才发现细节不少——13 级奖项谁先谁后、状元怎么比大小、一局到底什么时候算完。

这篇文章记录我用 React + TypeScript 写的闽南中秋博饼模拟器:一个纯单机离线的浏览器应用,完整实现「厦门常见规则」。

先看效果

开始界面 游戏主界面
开始界面 游戏主界面
掷骰结果 结算界面
掷骰结果 结算界面

六颗骰子在碗上方旋转弹跳、逐个落碗定格,奖项放大弹出,状元加冕时皇冠落下配金粉飘落。装饰全是手绘内联 SVG(月亮、玉兔、红灯笼、桂花、月饼、云纹),音效是 WebAudio 实时合成的——没有一张图片、没有一个音频文件。

技术选型

TypeScript 5 · React 18 · Vite 5 · Vitest 2 · 纯 CSS。没有 UI 库、没有 CSS 框架、没有状态管理库。

不用状态管理库是有意的:整个游戏的运行时状态只有「一局」这一个对象,用 useState 持有 + 纯函数更新就够了,引入 Redux/Zustand 只会多一层抽象。

目录结构上,真正值得说的是把规则层单独隔离出来:

src/
├── App.tsx              # 三阶段编排:开始 → 游戏 → 结算
├── styles.css           # 全部样式与动画
├── sound.ts             # WebAudio 音效引擎 + 偏好持久化
├── core/                # ★ 纯逻辑层,零 UI 依赖
│   ├── types.ts         # 类型、奖项元数据、奖池常量
│   ├── rules.ts         # judgeRoll() 判定 + compareRolls() 比较
│   ├── random.ts        # 可注入随机源
│   ├── engine.ts        # 状态机:发奖 / 状元更替 / 轮次 / 结算
│   └── __tests__/
└── components/          # 只负责渲染,不含任何判定逻辑

分层约定是硬的:core/ 里不许出现任何 React 或 DOM 引用。 好处是规则改动只需要改 core/ 并跑测试,UI 一行都不用碰;反过来,改 UI 也不可能碰坏规则。

一、规则层:一张「顺序即优先级」的判定表

13 级判定链

厦门常见规则从高到低:

等级 奖项 骰子组合
12 状元插金花 4 个 4 + 2 个 1
11 六杯红 6 个 4
10 遍地锦 6 个 1
9 六勃黑 6 个 2 / 3 / 5 / 6
8 五王 5 个 4
7 五子 5 个同点,且该点不是 4
6 四红 4 个 4,且不是状元插金花
5 对堂 1、2、3、4、5、6 各一个
4 三红 3 个 4
3 四进 4 个同点,且该点不是 4
2 二举 2 个 4
1 一秀 1 个 4
0 无奖 其他组合

实现就是一个从高到低的有序判定链,命中即返回:

export function judgeRoll(dice: number[]): RollResult {
  if (!Array.isArray(dice) || dice.length !== DICE_COUNT) {
    throw new Error(`judgeRoll 需要 ${DICE_COUNT} 颗骰子,收到 ${dice?.length}`);
  }
  for (const d of dice) {
    if (!Number.isInteger(d) || d < 1 || d > DICE_FACES) {
      throw new Error(`非法骰子点数:${d}(应为 1–${DICE_FACES} 的整数)`);
    }
  }

  const c = countFaces(dice);   // 各点数出现次数
  const fours = c[4];

  // ── 等级 12:状元插金花(4 个 4 + 2 个 1)──
  // 必须排在四红之前,否则会被误判为四红
  if (fours === 4 && c[1] === 2) return make('zhuangyuan', '状元插金花', 12, [], '4 个 4 + 2 个 1');

  // ── 等级 11:六杯红(6 个 4)──
  if (fours === DICE_COUNT) return make('zhuangyuan', '六杯红', 11, [], '6 个 4');

  // ── 等级 10:遍地锦(6 个 1)──
  // 必须排在六勃黑之前,否则会被误判为六勃黑
  if (c[1] === DICE_COUNT) return make('zhuangyuan', '遍地锦', 10, [], '6 个 1');

  // …(中间略)…

  // ── 等级 5:对堂(1、2、3、4、5、6 各一个)──
  // 必须排在一秀(1 个 4)之前,否则会被误判为一秀
  if ([1, 2, 3, 4, 5, 6].every((v) => c[v] === 1)) return make('duitang', '对堂', 5, [], '…');

  // …(中间略)…

  // ── 等级 0:无奖 ──
  return NO_PRIZE;
}

顺序不是「代码风格」,是正确性问题

判定表里有几个天然陷阱,靠人眼很容易漏:

  • 4,4,4,4,1,1 是状元插金花,不是四红
  • 1,2,3,4,5,6 是对堂,不是一秀
  • 5,5,5,5,5,6 是五子,不是四进
  • 六个 1 是遍地锦,不是六勃黑
  • 四进要排除四个 4;五子要排除五个 4

真正让我警觉的是一个更隐蔽的推论。手算概率时很容易这样想:

「一秀」= 恰好一个 4 = C(6,1)·5⁵ / 6⁶ = 40.19%

但把 46656 种组合全部喂给 judgeRoll() 穷举一遍,一秀只有 37.29%。差的 3 个百分点去哪了?

// 穷举校验:恰好一个 4 的组合,最后被判成了什么?
「恰好一个 4」的组合总数: 18750  (C(6,1)*5^5 = 18750)
其中被更高奖项截走的:
    对堂 720
    四进 600
    五子 30
    合计被截走: 1350
真正判定为一秀的: 17400

那 1350 种组合确实只含一个 4,但它们同时构成了更高的奖项:

  • 对堂 720 种 —— 1,2,3,4,5,6 的全排列,正好各含一个 4
  • 四进 600 种 —— 「4 个同点」的四颗之外,剩下两颗里有一颗是 4
  • 五子 30 种 —— 「5 个同点」之外的余骰正好是 4

所以顺序错了不是「结果略有偏差」,而是整张表全错。这也解释了为什么这套判定链必须严格从高到低,没有任何调整顺序的余地。

顺便看看 13 个结果的完整概率分布(穷举 46656 种组合得出,骰子均匀):

奖项 组合数 概率
一秀 17400 37.2942%
无奖 14300 30.6499%
二举 9300 19.9331%
三红 2500 5.3584%
四进 1875 4.0188%
对堂 720 1.5432%
四红 360 0.7716%
五子 150 0.3215%
五王 30 0.0643%
状元插金花 15 0.0322%
六勃黑 4 0.0086%
遍地锦 1 0.0021%
六杯红 1 0.0021%

近三成的掷骰是纯无奖——这就是博饼的手感来源。

二、状元比较:把「大小」抽象成一个数组

规则里不只有等级高低,同级之间还要比大小:

  • 六勃黑要比六同点的数值(6 > 5 > 3 > 2)
  • 五王要比剩余那一颗骰子
  • 五子要先比五同点点数,再比余骰
  • 四红要比剩下两骰的降序序列([6,5] > [6,3] > [5,5])
  • 状元插金花 / 六杯红 / 遍地锦是唯一的,先到先得

四种比较逻辑各不相同,如果写成一堆 if (prize === '六勃黑') … 会非常难维护。我的做法是:给每个结果附一个 tiebreak 数组,把「同级比较」统一降维成「两个数组按位比较,数值大者胜」。

奖项 tiebreak 语义
状元插金花 / 六杯红 / 遍地锦 [] 唯一,先到先得
六勃黑 [v] 六同点点数
五王 [余骰] 剩余一颗骰子
五子 [v, 余骰] 先比五同点,再比余骰
四红 [大, 小] 剩下两骰的降序序列

于是比较器只有十几行,而且新增奖项不需要改它:

/** @returns > 0 表示 a 更大;< 0 表示 b 更大;0 表示完全相等 */
export function compareRolls(a: RollResult, b: RollResult): number {
  if (a.level !== b.level) return a.level - b.level;      // 先比等级

  const n = Math.max(a.tiebreak.length, b.tiebreak.length);
  for (let i = 0; i < n; i += 1) {                        // 再按位比比较键
    const av = a.tiebreak[i] ?? 0;
    const bv = b.tiebreak[i] ?? 0;
    if (av !== bv) return av - bv;
  }
  return 0;                                               // 完全相等 → 调用方按「先到先得」
}

注意 return 0 的语义被明确约定为「完全相等」,替换与否交给调用方决定。这样「相等先到先得」这条规则就只存在于状态机一处,不会散落在比较器里。

三、状态机:不可变更新 + 可注入的随机源

一掷骰子的完整流程都收在 rollOnce() 里,返回新状态而不是就地修改:

export function rollOnce(
  state: GameState,
  random: RandomFn = defaultRandom,
): { state: GameState; outcome: RollOutcome } {
  if (state.status === 'finished') throw new Error('游戏已结束,无法继续掷骰');

  const dice = rollDice(random);
  const result = judgeRoll(dice);
  const actor = state.players[state.currentIndex];

  // 不可变更新:复制玩家与奖项计数
  const players = state.players.map((p) => ({ ...p, prizes: { ...p.prizes } }));
  const pool = { ...state.pool };

  if (result.type === 'normal' && result.prizeKey) {
    // 普通奖:先到先得,池空则本次落空,绝不向下顺延
    const key = result.prizeKey;
    if (pool[key] > 0) {
      pool[key] -= 1;
      players[state.currentIndex].prizes[key] += 1;
    } else {
      prizeFull = true;      // 记「该奖项已满,无奖」
    }
  } else if (result.type === 'zhuangyuan') {
    // 状元类:更大才替换,完全相等先到先得
    if (!zhuangyuan) { zhuangyuan = { playerId: actor.id, result }; }
    else if (compareRolls(result, zhuangyuan.result) > 0) { zhuangyuan = { … }; }
  }
  // …
}

「池空不顺延」这条一定要点出来:对堂发完了,下次再博出对堂就是「已满,无奖」,不能改拿三红。这是规则,不是 bug。

随机源可注入,是让测试能锁死场景的关键

rollDice(random) 的随机源是参数,默认是 Math.random。测试时传入一个「预设骰子序列」的假随机源:

/**
 * 测试辅助:用「预设骰子序列」构造确定性随机源。
 * 每次 rollDice 会连续消费 6 个随机值,本函数把预设点数反算成对应的随机值,
 * 使得 `Math.floor(random() * 6) + 1` 恰好得到预设点数。
 */
export function diceSequenceRandom(sequence: number[][]): RandomFn {
  // …
  return (die - 0.5) / DICE_FACES;   // 让 floor(v * 6) + 1 === die
}

(die - 0.5) / 6 这个反算很实用:只要落在 [die-1, die) 区间内,floor(v*6)+1 就必然等于 die。于是测试可以这样写:

const CHAJINHUA = [4, 4, 4, 4, 1, 1];
const random = diceSequenceRandom([CHAJINHUA]);
rollDice(random);   // => [4,4,4,4,1,1]

不用 mock 函数、不用改生产代码、不用给组件开后门,就能把任意一局精确重放。这一点后面讲界面冒烟测试时会再次用到。

四、约 500 万次掷骰,量化了几个设计问题

规则写完了,但有两个问题靠「感觉」回答不了:一局到底要掷多久? 以及我初版加的「满 10 轮结束」到底错在哪?

这类问题手算很麻烦——五个奖池是相互竞争的,一局的长度取决于最慢清空的那个奖池。于是我把 src/core/ 的真实代码直接跑起来做蒙特卡洛:27000 局模拟、约 500 万次掷骰,另加 46656 种组合穷举校验。

一局有多长

玩家人数 模拟局数 掷骰数 均值 轮次 均值 轮次 中位数 轮次 P90 轮次 P99
2 人 3000 220.3 110.9 106 158 228
4 人 3000 221.0 55.9 53 80 116
6 人 10000 219.6 37.2 35 53 75
8 人 3000 220.4 28.1 27 40 56
12 人 2000 219.0 18.8 18 27 36

有个反直觉的结论:一局的掷骰数几乎与人数无关(219–221),因为奖池总量是固定的 62 件(一秀 32 + 二举 16 + 四进 8 + 三红 4 + 对堂 2),跟几个人玩没关系。人数改变的只是「轮次」这个展示维度——2 人要掷 110 轮,12 人只需 19 轮。

顺带一提,实测 6 人的中位轮次是 35 轮、均值 37.2 轮、P90 达 53 轮。我原先在 README 里写的「约 20–35 轮」偏低了,大概只覆盖到中位数附近,得改。

为什么「满 10 轮结束」是错的

初版我加过一个保险:round > 10 就结束。想法是「万一奖池发不完,别让玩家等到天荒地老」。

手测时觉得不对劲——一局往往在还剩二十多件奖品时就结束了。量化一下:

玩家人数 10 轮后剩余奖品 占比 能在 10 轮内自然发完的局
2 人 48.4 / 62 件 78% 0.00%
6 人 21.8 / 62 件 35% 0.00%
12 人 4.0 / 62 件 6% 5.15%

2 人局里,10 轮只能博掉 14% 的奖品;而且 2000 局里没有一局能在 10 轮内自然发完。也就是说对这个上限而言,「保险丝」实际上是个永远会熔断的闸刀——它从来没能让一局正常打完,只是在半路把游戏掐死。

但真实博饼的规矩是「奖品发完为止」。你不可能在中秋夜里跟家人说「时间到了,剩下的月饼收回」。

所以这个条件被我彻底删掉了,包括随之而来的 GameState.maxRounds 字段和 createGame(names, { maxRounds }) 的第二个参数——createGame 回到单参,轮次字段降级为「仅用于记录与展示,不参与结束判定」。现在结束条件只有一个:五个普通奖池全空。

这也是个设计上的教训:不确定要不要加的「保险」,先量化再决定。 如果没跑这 2000 局,我可能会一直以为那个上限在「保护」什么,实际上它在破坏规则。

代价是超长局的存在(实测 2 人局最长 672 掷、337 轮),但发生率极低,且在规则上完全正当。

大约 10% 的局,根本没有状元

顺带发现一个挺有意思的现象:

玩家人数 状元类奖项出现次数 / 局 状元空缺率
2 人 四红 1.70 · 五子 0.69 · 五王 0.15 · 插金花 0.07 10.13%
6 人 四红 1.68 · 五子 0.71 · 五王 0.14 · 插金花 0.07 9.74%
12 人 四红 1.72 · 五子 0.70 · 五王 0.13 · 插金花 0.06 9.50%

单次掷骰博出状元类奖项(四红及以上)的概率只有 1.2%(穷举得出:561/46656),一局约 220 掷,所以累积下来仍有约一成的局没人博出状元,状元奖品发不出去。

而状元里绝大多数是「四红」,插金花平均约 14 局才出现一次,遍地锦和六杯红各是万里挑一(0.0021%)。

这不是 bug,是规则的客观性质——应用里如实显示为「状元空缺」,奖品不发。

五、工程化:测试、CI 与「零外部依赖」

三层共 73 项测试

测试文件 项数 覆盖内容
rules.test.ts 37 13 条判定用例、比较器(六勃黑 6>5>3>2、五王比余骰、五子两级比较、四红降序)、唯一奖项相等返回 0、非法输入抛错
engine.test.ts 26 先到先得与池空不顺延、状元成为/替换/相等不替换、不设轮数上限、结算状元领奖或空缺、2 人与 12 人边界,以及 60 组确定性随机整局模拟的不变量校验
app.smoke.test.tsx 10 jsdom 下真实挂载渲染,走完「开始 → 掷骰 → 结算 → 重开」全流程

engine.test.ts 里最有价值的不是逐个 case,而是不变量校验:跑 60 组整局模拟,断言「结束后普通奖池必为空」「奖品不超上限」「状元至多 1 个」。这类断言能在规则演进时抓住预料之外的破坏。

删掉轮数上限后,冒烟测试必须重写——靠真随机掷骰无法在有限步内稳定结束。解法正是前面那个可注入随机源:注入「第 1 掷博出插金花 + 其后 62 掷恰好清空五个普通奖池」共 63 掷的确定性序列,让界面测试有了确定的终局。

CI 与自动部署

  • .github/workflows/ci.yml —— push / PR 跑「类型检查 → 单测 → 构建」,产物存 7 天
  • .github/workflows/pages.yml —— push main 后自动构建并发布到 GitHub Pages

两个工作流都是先 npx tsc --noEmit 再跑测试。类型检查放在最前面,因为它最快也最能拦住低级错误。

真的做到「零网络请求」

既然主打离线,素材就不能有外部依赖:

  • 音效 —— WebAudio 程序化实时合成(掷骰碰撞、中奖铃声、状元锣声、加冕音阶),零音频文件
  • 字体 —— 站酷快乐体 ZCOOL KuaiLe,SIL OFL 协议可免费商用,已本地化到 src/assets/fonts/ 随构建打包;加载失败自动回退 PingFang SC / Microsoft YaHei
  • 装饰 —— 全部手绘内联 SVG,零图片
  • 构建 —— base: './' 用相对路径,dist/ 可直接双击打开

构建产物体积:

dist/assets/index-*.js      179 KB(gzip  58 KB)
dist/assets/index-*.css      30 KB(gzip   8 KB)
dist/assets/*CJK*.woff2     726 KB   ← 标题字体

那个 726 KB 的字体是唯一的「体积大头」。因为要支持用户输入任意中文姓名,没做极致子集化——这是个明确的取舍:离线场景下多 700KB 换取「任何名字都不会变成方块字」,我认为值。

六、几个踩过的坑

1. 「这个数字怎么渲染歪了?」——其实是字体的设计

站酷快乐体是手绘感艺术字,它的数字(尤其 0、6)刻意做成几何折角造型,视觉上略带方块感。我一度以为渲染出故障了,放大 + 隔离渲染对比后才确认是字体本身的字形设计,不是 bug。后来在 README 里专门写了一段说明,免得别人也去「修」它。

2. 结束条件的演进(前面已详述)

round > 10 这个上限从「保险」变成了「破坏规则的闸刀」,最后被删除。这次重构让我在 README 里写下了一条硬规则:本项目固定实现「厦门常见规则」,不接受地方规则差异的可配置化改动。因为一旦开了配置口子,判定链就会从「一张有序表」退化成「一堆条件分支」,正确性再也无法穷举验证。

3. 国内网络的安装体验

仓库自带 .npmrc 指向 registry.npmmirror.com,国内 npm install 会稳很多。不需要的话删掉该文件、改用官方源即可,README 里写了对照命令。

小结

这个项目代码量不大,但有几个点我觉得值得记下来:

  1. 规则层必须和 UI 彻底隔离。 判定链、比较器、状态机全是纯函数,零 UI 依赖——这才让「穷举 46656 种组合校验判定表」和「27000 局蒙特卡洛」成为可能。如果规则和组件缠在一起,这些验证一个都做不了。
  2. 顺序即优先级,不是风格问题。 一秀的真实概率 37.29% 而非 40.19%,差的 1350 种组合全被更高奖项截走了。
  3. 同级比较抽象成数组,把四种不同的比较逻辑统一成十几行代码,且新增奖项不用改比较器。
  4. 随机源可注入是测试的杠杆:既让规则单测能锁死场景,也让界面冒烟测试在「不限轮数」的新规则下依然有确定终局。
  5. 不确定要不要加的「保险」先量化。 那个 round > 10 的上限,测完才知道它在 2 人局里会让 78% 的奖品发不出去。

说到底,博饼的乐趣在于「不可预测」——你永远不知道下一掷会不会博出状元。而代码要做的,是让这份不可预测可验证。

中秋快乐,博个好彩头。


posted @ 2026-09-25 10:00  郭幸坤  阅读(3)  评论(0)    收藏  举报
1