六颗骰子一只碗:用 React + TypeScript 实现闽南中秋博饼模拟器
中秋博饼是闽南地区的保留节目:一只大碗、六颗骰子,一家人轮流掷下去,比谁博出的「状元」大。规则听着简单,真写进代码才发现细节不少——13 级奖项谁先谁后、状元怎么比大小、一局到底什么时候算完。
这篇文章记录我用 React + TypeScript 写的闽南中秋博饼模拟器:一个纯单机离线的浏览器应用,完整实现「厦门常见规则」。
- 在线试玩:https://kenneth-g6.github.io/minnan-bobing/(纯静态,断网也能跑)
- 源码:https://github.com/Kenneth-G6/minnan-bobing(MIT License)
先看效果
| 开始界面 | 游戏主界面 |
|---|---|
![]() |
![]() |
| 掷骰结果 | 结算界面 |
|---|---|
![]() |
![]() |
六颗骰子在碗上方旋转弹跳、逐个落碗定格,奖项放大弹出,状元加冕时皇冠落下配金粉飘落。装饰全是手绘内联 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 里写了对照命令。
小结
这个项目代码量不大,但有几个点我觉得值得记下来:
- 规则层必须和 UI 彻底隔离。 判定链、比较器、状态机全是纯函数,零 UI 依赖——这才让「穷举 46656 种组合校验判定表」和「27000 局蒙特卡洛」成为可能。如果规则和组件缠在一起,这些验证一个都做不了。
- 顺序即优先级,不是风格问题。 一秀的真实概率 37.29% 而非 40.19%,差的 1350 种组合全被更高奖项截走了。
- 同级比较抽象成数组,把四种不同的比较逻辑统一成十几行代码,且新增奖项不用改比较器。
- 随机源可注入是测试的杠杆:既让规则单测能锁死场景,也让界面冒烟测试在「不限轮数」的新规则下依然有确定终局。
- 不确定要不要加的「保险」先量化。 那个
round > 10的上限,测完才知道它在 2 人局里会让 78% 的奖品发不出去。
说到底,博饼的乐趣在于「不可预测」——你永远不知道下一掷会不会博出状元。而代码要做的,是让这份不可预测可验证。
中秋快乐,博个好彩头。
- 在线试玩:https://kenneth-g6.github.io/minnan-bobing/
- 源码:https://github.com/Kenneth-G6/minnan-bobing
- 欢迎 Issue 与 PR;规则相关改动请同步补充单元测试





浙公网安备 33010602011771号