零依赖单文件 HTML5 宜兴麻将游戏:1 个真人玩家 + 3 个 AI 对手。双击即玩,无需安装任何东西。
部署在阿里云 OSS(香港桶 yixing-mahjong-hk),已绑定自定义域名并启用 HTTPS。
master 分支每次 push 会自动同步到 OSS,见 .github/workflows/deploy.yml。
网上的麻将游戏要么是日麻规则、要么是简化到没有灵魂的通用规则,没有一款按宜兴本地花数规则打。
宜兴麻将的核心特征是「花数」而不是「番型」——牌型底花 + 字牌刻杠花累加,再对照 3 花出冲 / 2 花自摸的门槛。这套算法细节多、门槛特例多(独吊、抢杠一包三、暗杠不破门清),用现成引擎改反而比重写更麻烦,所以直接写了个自己的规则引擎。
| 模块 | 说明 |
|---|---|
| 牌组 | 144 张:万/饼/条各 36 + 东南西北中发白 28 + 花牌 8 张(春夏秋冬梅兰竹菊) |
| 补花 | 摸到花牌自动亮出计 1 花,并从牌尾补牌 |
| 副露 | 吃(仅上家)、碰、明杠、暗杠、补杠 |
| 计分 | 花数制:牌型底花 + 刻杠花 + 翻倍,实时累计四家分数 |
| 连庄 | 庄家胜或流局连庄,连庄倍数封顶 ×4 |
| AI | 向听数 + 听牌枚举 + 危险牌防守 + 副露决策,三档难度(难度同时影响出牌快慢) |
| AI 人格 | 激进 / 保守 / 均衡,可选随机分配或统一风格 |
| AI 节奏 | 摸牌后停顿 0.85~1.4 秒、约 15% 概率长考至 2.4 秒,AI 单巡约 1.5 秒(依据见下) |
| 音效 | Web Audio API 程序化生成(无音频文件) |
| 牌面 | 纯 SVG 手绘 42 种牌面(筒为三色同心圆、条为竹节、一条为燕子),尺寸色值均按参考图实测 |
| 适配 | 横竖屏通吃:四家共享中央牌河、手牌一行永不出屏、长按手牌弹放大条 |
| 情形 | 要求 |
|---|---|
| 出冲(荣和) | 3 花起 |
| 自摸 | 2 花起 |
| 一花独吊 | 可出冲 |
| 无花独吊 | 可自摸 |
| 牌型 | 底花 |
|---|---|
| 门清 | 4 |
| 碰碰胡 | 6 |
| 七对 | 10 |
| 清一色 | 8 |
| 混一色 | 6 |
| 十三风 | 13 |
多种牌型同时成立,每多一种各减 1 花。
| 牌 | 碰 | 暗刻 | 明杠 | 暗杠 |
|---|---|---|---|---|
| 中发白、门风 | 2 | 3 | 4 | 5 |
| 其余风牌 | 1 | 2 | 3 | 4 |
| 数牌 | — | — | 1 | 2 |
- 翻倍:天胡、地胡、独吊、海底捞月、杠上开花(×2ⁿ)
- 抢杠:一包三,独付 6 倍
- 暗杠不破门清,且不能抢杠
- 吃后不能打同张
- 放弃过的碰张/胡张,一圈内不能再碰/胡(轮到自己摸牌后恢复)
- 放冲独付,自摸三家付
git clone https://github.com/oracis/yixing-mahjong.git
cd yixing-mahjong
# 直接双击 index.html,或:
start index.html # Windows
open index.html # macOS推荐 Chrome / Edge / Firefox 最新版(需支持 Web Audio API 与 CSS :scope)。
- 开始页选择难度(简单/普通/困难)与对手风格(随机分配/全激进/全保守/全均衡)
- 点击「开始游戏」
- 轮到你时点击手牌出牌;AI 打出的牌你能碰/杠/吃/和时,底部自动出现按钮
- 每次和牌后结算面板逐项列出花数明细,可对照上图核账
- 手机上长按手牌会弹出放大条,方便认牌(单点=出牌,所以只能长按)
- 点击左右侧列(上/下家)弹出该家详情浮层:副露 + 全部弃牌按正常方向放大显示。 横屏侧列物理上放不下全部弃牌(只能露最近 8~10 张),这是补全信息的唯一途径; 竖屏也能点,弃牌只有 30px 宽、认不清时点开看大图
AI 的「思考时间」全由一个地方控制:index.html 里的 AI_PACE。改节奏只动它,别去改
scheduleLoop() 的 320ms 轮询——那个是玩家和 AI 共用的,加大会直接让玩家点牌变卡。
关键认知:AI 本身算完只要 10~300ms,玩家感知到的完全是停顿。所以这里调的不是算力,是演出。
| 参考 | 每巡节奏 |
|---|---|
| 雀魂 | 出牌/立直动画 + 角色 cut-in,玩家感知每次动作 多 1~2 秒 |
| 天凤 | 5 秒/巡(5+10 规则) |
| 世嘉网络麻将(高段) | 1.6 秒/巡,已属偏快一档 |
| 皇家学会 AI 对手实验 | AI 实际 2.5s、扣掉 1~2s 动画后,感知等待仍要 ~1s 才舒适 |
| MahjongCopilot 等拟人化打牌 | 明确要「基础延迟随机 + 关键巡额外加时」 |
据此取值为:摸牌后 850~1400ms、约 15% 概率长考到 1500~2400ms、反应碰杠吃
650~1100ms。加 320ms 轮询后 AI 单巡 ≈ 1.5 秒;难度再乘 0.9 / 1.0 / 1.15(困难档像在深算)。
实测(400×948,同一浏览器会话内把 pace.for 就地换成旧值做 A/B):整桌每张牌平均间隔
1.08s → 1.54s,最大停顿 702ms → 1616ms(长考分支确实触发)。
⚠️ 调完节奏记得重跑自测:test/selftest.js的「AI 节奏」段会做区间与均值断言, 端到端用例的循环预算也是按AI_PACE反推的,不是写死的。
设计依据(参考来源 · 决策记录 · 断点表 · 实测数据)见
docs/layout-spec.md。 改尺寸或动 Grid 前请先读它;下面只是速查。
一套 DOM + 一个 CSS Grid,横竖屏通吃,不存在「窄屏就把两家藏起来」。
┌──────────────────────────────┐
│ topbar 局数 · 余牌 · 轮到谁 │
├──────────────────────────────┤
│ 对家(西)信息 + 牌背 │ auto
│ 对家弃牌带 │ auto
│ ┌──────┬───────────┬──────┐ │
│ │ 上家 │ 中央: │ 下家 │ │ 1fr
│ │ 信息 │ 谁打出了 │ 信息 │ │ ← 富余高度全给这格
│ │ 牌背 │ 什么(放大) │ 牌背 │ │ 竖屏就是这里的留白
│ │ 弃牌 │ + 提示语 │ 弃牌 │ │
│ └──────┴───────────┴──────┘ │
│ 自己弃牌带 │ auto
├──────────────────────────────┤
│ 操作按钮(碰/杠/吃/和) │ auto
├──────────────────────────────┤
│ 自己信息 + 花牌 │
│ 自己手牌(14 张,一行) │ auto
└──────────────────────────────┘
几个必须记住的约束(都是实测撞出来的):
- 手牌一行塞 14 张:
--hand-w: min(var(--tile-w), calc((100vw - 51px) / 14)), 51px = 左右各 6px 内边距 + 13 个间隙。牌宽高用--tile-ar锁死比例, 否则被 flex 挤压后牌面会糊(旧版宽高比从 0.71 掉到 0.52)。 - 方向锁不可用:
screen.orientation.lock()在 iOS Safari 不支持、微信浏览器被拦, 所以竖屏必须能玩,不能指望让用户横过来。 - 上下家那一列给固定宽
--side-w:中间那条越宽,对家/自己的弃牌带折行越少、 越省高度(横屏最缺的正是高度)。 - 横屏(h≤600)中央格压到 0,两条弃牌带平分高度;竖屏反过来,中央吃富余。
- 后期自动缩档:每家弃牌 >22 张时给
#river挂.tight(0.78 倍), 否则弃牌带多折两行会把中央那张「最后打出」挤没。
--mini-h: calc(var(--mini-w) * var(--tile-ar)) 定义在 :root,
它的值在 :root 上就算完了;后代元素继承到的是算好的值。
所以在 #river 上覆盖 --mini-w 不会影响 --mini-h ——
要么连 --mini-h 一起重定义,要么把单位换算写在使用处(本项目用后者)。
牌面是纯 SVG 手绘(viewBox 0 0 100 138,与牌面 42×58 同比例),全套尺寸由
scripts/read_ref_tiles.py 从参考图逐像素量出来,不是目测估的。
用「腐蚀一次取量化众数」采样(腐蚀 = 只留下四邻同色系的像素,即色块/笔画内部):
| 用途 | 色值 | 对牌面底 #faf6ea 对比度 |
|---|---|---|
| 藏蓝(筒/条/字牌/白板框) | #2d3d67 |
9.84:1 |
| 深红(筒/条/萬) | #af2b36 |
6.03:1 |
| 竹绿(条/發) | #16663a |
6.27:1 |
三点值得记的经验:
- 别用「最饱和 5% 像素」估色:会被混色暗边带偏。实测同一张参考图上,
万字那一行的蓝是偏紫的
#241464,而筒点是#3c5474—— 根本不是同一个色, 混在一起统计只会得到一个谁都不像的中间值。所以脚本逐行统计配色。 - 绿色比参考图(实测
#3c844c,对比度只有 4.23:1)刻意调深:一条竹子的 描边在 42px 牌面上只有 ~1px,太亮的绿会被抗锯齿糊成浅灰 —— 这正是之前「条子对比不够」的真正根因(不是配色问题,是亚像素描边问题)。 - 描边宽度取
max(3, min(w,h)*0.24)(viewBox 单位)。低于 3 个单位必被抗锯齿吃掉。
- 圆点不是「空心环 + 实心芯」,是实心圆挖两道白环:
R → 白 0.63R → 彩 0.50R → 白 0.24R → 彩芯 0.13R,五个比例来自 「按半径统计彩色像素占比」的剖面测量。 - 一筒是团花(藏蓝外圈 + 12 瓣浅绿花环 + 红盘压白十字),不是点阵。
- 六条是 3 列 × 2 行(不是 2 列 × 3 行,很容易顺手写错)。
- 七条 = 顶部 1 根红竹 + 下方 3×2,中间那列是藏蓝。
- 九条中间一列为红;九筒中间一行为红 —— 两者红的位置不同。
- 八条是上「W」下「M」:每半 = 2 根竖竹 + 2 根斜竹,合计正好 8 根可数。
斜竹用
bambooSeg(x1,y1,x2,y2):竹节单元本身是竖直的,绕中心转到atan2(-dx, dy)(推导见代码注释)。
test/selftest.js 第 0 节断言「牌面数字 = 可数元素个数」:
n 筒 = n 个点 × 5 层同心圆、n 条 = n 个 <rect>;再校验六/九条的列数、
九条与九筒的红点位置。这类错误肉眼极难发现(六条排成 2×3 看起来也挺自然),
必须靠结构计数兜住。新加的断言要亲手改坏一次确认它会失败,否则等于没断言。
单文件、内部按 IIFE 分层,引擎层完全不依赖 DOM(所以能在 Node 里直接跑单测):
flowchart TD
C[CONFIG<br/>常量配置] --> TU[TileUtils<br/>牌组工具]
TU --> YR[YixingRules<br/>宜兴规则引擎]
YR --> MG[MahjongGame<br/>对局引擎·纯逻辑]
MG --> AI[AIPlayer<br/>决策 + 人格]
AI --> CT[Controller<br/>流程控制]
MG --> CT
CT --> RD[Renderer<br/>渲染]
AE[AudioEngine<br/>音效] --> CT
RD --> UI[DOM]
CT --> UI
| 模块 | 职责 |
|---|---|
CONFIG |
牌张定义、方位、常量 |
TileUtils |
牌的解析、花色判断、排序键 |
YixingRules |
和牌判定、向听数、听牌枚举、花数计算 |
MahjongGame |
对局状态机(摸/打/碰/吃/杠/和)、一圈限制、连庄 |
AIPlayer |
出牌选择、副露决策、危险度评估、人格与失误建模 |
AudioEngine |
Web Audio 程序化音效 |
Renderer |
全部 DOM 渲染,无游戏逻辑 |
Controller |
串联引擎与渲染、处理玩家输入 |
改动后建议跑一遍语法校验与逻辑自测:
# ① 语法校验:抽出 <script> 交给 node --check
node scripts/_extract_mod.js && node --check scripts/_mods.js
# ② 逻辑自测:最小 DOM mock 注入脚本,自动打完整局(当前 PASS=90)
node test/selftest.js
# ③ 布局几何试算:把 CSS 尺寸变量 + Grid 规则翻译成数字,
# 对 12 个视口检查「手牌一行是否放得下 / 两条弃牌带装不装得下 /
# 中央放不放得下最后打出 / 上下家那列能露出几张」,改尺寸后必跑
node scripts/sim_layout.js
# ④ 真机尺寸截图(用本机 Chrome 无头,见下)
bash scripts/shoot.sh # 全部设备
bash scripts/shoot.sh 390x844 # 指定尺寸
HASH='#auto&fill=26' bash scripts/shoot.sh 390x844 # 看后期最挤的样子自测里挂 uncaughtException 是为了抓到 setTimeout 里的异常,
但它同时会抑制进程退出——曾因此出现「PASS/FAIL 一行都不打印但 exit=0」的假绿。
现在 selftest.js 在 process.on('exit') 里强制兜底打印未捕获异常。
截图工装的两个坑:
- 不要用
--window-size定设备宽度:Windows 无头 Chrome 会把视口钳到最小 ~504 宽, 请求 390 实得 504,截图右侧被裁,看起来像「南家不见了」——纯截图假象。scripts/shoot.sh改成在页面内放精确尺寸的 iframe,外面等比缩放。 - 不要在 node 里 spawn Chrome(本机沙箱下
EBUSY),一律从 bash 调;
引擎层无 DOM 依赖,可用约 30 行最小 DOM mock 在 Node 中驱动整局:
const M = new Function('document','window','requestAnimationFrame',
code + '\n;return {Controller,Renderer,MahjongGame,AIPlayer,YixingRules,TileUtils};'
)(mockDoc, global, cb => setTimeout(cb, 16));
M.Controller.init({ difficulty:'normal', persona:'random' });
M.Controller.startNewRound();
// 然后 dispatch('click') 到具体的手牌 DOM 节点即可- 排序:万 → 饼 → 条 → 字 → 花,组内数字升序。排序下沉在所有手牌变更点(发牌、摸牌、碰/吃/杠后),不要只在摸牌后排。
- 尺寸变量必须留在第一个
:root里:scripts/gen_tiles_preview.py只读第一个:root,挪走了会生成一页空白牌。 - 座位映射
SEAT=[south, east, north, west]里的east/west是屏幕方位(右/左),不是风位。 玩家 0 是「东」但坐屏幕下方;上家(北)在左、下家(南)在右。 - 渲染层引用外部模块必须显式声明别名(
const T=TileUtils, C=CONFIG, AI=AIPlayer)。漏一个别名会抛ReferenceError,而它会被误判成「按钮没绑上」的交互 bug。 - 加新 DOM 查找前先想
test/selftest.js的 mock:innerHTML只是存字符串、不解析成子节点, 所以断言要查_html(正则)而不是_kids.length;用appendChild建的才在_kids里。
yixing-mahjong/
├── index.html # 全部代码:HTML + CSS + JS(零依赖单文件)
├── layout-preview.html # 手机端排版预览:多设备 iframe 同屏对比
├── tiles-preview.html # 牌面图案预览(由 scripts/gen_tiles_preview.py 生成)
├── scripts/
│ ├── _extract_mod.js # 抽出 <script> → scripts/_mods.js(供 node --check)
│ ├── sim_layout.js # 布局几何试算(12 个视口)
│ ├── shoot.sh # 真机尺寸截图(本机 Chrome 无头 + 精确 iframe)
│ ├── gen_tiles_preview.py
│ ├── read_ref_tiles.py # 把牌面参考图读成规格:网格定位 / 配色采样 / 元素测量
│ └── deploy_oss.py # 推 OSS
├── test/selftest.js # 最小 DOM mock + 完整对局自测
├── docs/layout-spec.md # 布局设计依据:参考来源 / 决策记录 / 断点表 / 实测数据
├── .github/workflows/ # push 到 master 自动部署到 OSS
├── README.md
├── LICENSE
└── .gitignore
- 十三烂(
shisanlan)目前只做结构判断,花型细分边界待打磨 - 蒙特卡洛出牌搜索(当前 AI 是 1 步贪心 + 启发式防守)
- 出牌/碰杠动画强化
本项目为个人学习与技术实践作品,规则按宜兴本地打法整理,各地细则(底花、门槛、翻倍)可能存在差异,请以你当地实际玩法为准。游戏内 AI 为纯本地规则引擎(非大模型),不使用任何网络请求,不收集任何数据。