基于浏览器的多人麻将游戏,支持 1–4 名真人玩家,空位由 AI 自动填补。
A browser-based multiplayer Mahjong game. Supports 1–4 human players per room; empty seats are filled by AI.
- 标准中国香港麻将规则(144 张牌,含花牌季牌)
- 实时多人对战(WebSocket)
- AI 自动填补空位,启发式出牌与声索决策;人类玩家可在游戏进行中加入并接管 AI 座位
- 声索优先级:胡 > 碰/杠 > 吃;加杠时支持搶杠胡(限声索赢牌);有多口可吃时分别显示三张牌序列供玩家选择(如「吃 三四五」/「吃 四五六」)
- 自摸与荣和均支持;最低番数门槛可配置(当前 1 番,即任意合法手牌可胡)
- 七对(七對)为合法胡牌型,计 +3 番
- 声索窗口 30 秒倒计时,归零自动跳过
- 番数驱动筹码结算:
unit = min(64, 2^(番数-1))(1番=1,2番=2,3番=4…7番+=64);庄家付/收 2×unit,闲家付/收 1×unit;荣和放炮全包(放炮者独付三人自摸份额之和);杠即时结算(各家付 1 筹码);跨局累计,初始 1000 筹码;结算弹窗显示本局筹码变化量(±N,绿色/红色) - 胡牌番数详情:游戏结束时展示本局达成的番型列表及合计番数(支持 16 种港式番型);胡牌时语音播报类型(「胡!自摸!」/「胡!点炮!」/「胡!嶺上開花!」);弹窗显示胡牌类型(自摸 Tsumo / 点炮 Ron / 嶺上開花 Lingshang)
- 港式麻将牌面:Wikimedia Commons Cangjie6 斜视 3D SVG(全套 42 张,含花牌季牌),传统象牙底面配色
- 摸牌后自动选中刚抓到的牌,出牌按钮立即可用;双击 / 双指点击手牌直接出牌(桌面
dblclick+ 移动端touchend时间差,合并选牌与打牌两步操作) - 庄家(庄)徽标显示在对应玩家名旁;庄家赢则连庄,闲家赢或流局则换庄
- 手牌自动整理(条 → 饼 → 萬 → 风/字/花季);中央弃牌区按空间位置排列:底部左格始终为自己(金色边框高亮),右为下家,上排为上家和对家
- 游戏结束后支持重开局(保留原房间人类玩家,筹码持续累计);多人场景:只有庄家可点"再来一局"(非庄家显示"等待庄家重开…"),其他玩家收到新局 game_state 时弹窗自动关闭,消除竞态混乱
- 大厅显示各房间实时筹码余额;已结束的房间显示"Rejoin 重回"按钮
- 断线宽限期:真实玩家 WebSocket 断开后,保留 20 秒宽限期(
AI_TAKEOVER_GRACE);宽限期内重连则无缝恢复操作,超时后 AI 才接管座位,避免移动端短暂网络波动导致立即被 AI 顶替 - 中文语音 + 程序化音效(全面统一,桌面 + iOS 均支持):所有动作均有语音 + 音效——出牌(脆响)、碰/吃/杠(各自专属音效)、胡(四层程序化锣声音效)、流局(双音下行);本人与对手动作覆盖完全一致;零音频文件,全部由 Web Audio API 实时合成;声音优先级机制避免吃/碰乱序和牌名被跳过;操作按钮等待 TTS 播完后出现,音效与界面时序同步。移动端实现:iOS 强制要求
AudioContext在用户手势中创建,WebSocket 回调中创建会进入suspended状态无声;采用共享AudioContext单例,首次touchstart/click时解锁,后台切换后自动resume(),确保对手动作音效在 iOS 同样生效 - 流畅渲染:差量 DOM 更新(手牌/弃牌堆/副露仅在内容变化时重建);
active-turn焦点切换平滑过渡(CSS transition);声索/结算弹窗淡入动画;所有文本守卫防止无意义重绘 - 移动端竖屏支持:
top/bottom区域横跨全列(手牌/对面玩家铺满屏宽);声索弹窗高度紧凑(牌面图+倒计时并排,按钮始终可见);大厅房间表格横向滚动;侧边玩家显示庄标+筹码;touch-action: manipulation消除 300ms 延迟;44px 触控目标 - 响应式绿毡牌桌界面(桌面 1024px+ 全功能;手机 375px 竖屏可玩);
API_BASE/WS_BASE动态适配本地开发与生产环境(wss://生产 WebSocket)
本地开发(依赖 Python 3.11+)
cd backend
pip install -r requirements.txt
uvicorn main:app --reload --port 8000打开浏览器访问 http://localhost:8000。
生产部署(Google Cloud Run + IAP)
# 构建推送镜像
IMAGE="us-central1-docker.pkg.dev/YOUR_GCP_PROJECT_ID/mahjong-repo/mahjong:latest"
docker build -t ${IMAGE} . && docker push ${IMAGE}
# 部署更新
gcloud run deploy mahjong --image=${IMAGE} --region=us-central1 --project=YOUR_GCP_PROJECT_ID生产地址:https://YOUR_CLOUD_RUN_URL(需 Google 身份认证)
IAP 公网地址:https://YOUR_APP_DOMAIN(SSL 证书签发中)
├── backend/
│ ├── game/ # 游戏引擎(牌型、胡牌、状态机、AI、番数计算)
│ ├── api/ # FastAPI 路由 + WebSocket 处理
│ └── tests/ # 后端单元测试(314 tests)
├── frontend/
│ ├── js/ # 大厅 + 游戏客户端
│ ├── tiles/ # Cangjie6 港式麻将 SVG 牌面(42 张)
│ └── tests/ # 前端单元测试(111 tests)
└── tests/
└── integration/ # REST + WebSocket 集成测试(79 tests)
后端单元测试
cd backend
pip install -r requirements-test.txt
pytest前端单元测试
npm install
npm run test:coverage集成测试
cd tests/integration
pip install -r requirements.txt
pytest -v| 层级 | 技术 |
|---|---|
| 后端 | Python 3.11 + FastAPI 0.111 + Uvicorn |
| 实时通信 | WebSocket(Starlette 内置) |
| 前端 | 原生 HTML5 + CSS3 + JavaScript |
| 牌面图片 | Wikimedia Commons Cangjie6 SVG(CC BY-SA 4.0) |
| 测试 | pytest + Vitest |
胡牌时自动计算番型,番数直接驱动筹码结算。
| 番型 | 番数 | 说明 |
|---|---|---|
| 基本分 | +1 | 始终计入 |
| 自摸 | +1 | 非荣和自摸 |
| 无花 | +1 | 无花牌/季牌 |
| 门清 | +1 | 无副露且荣和 |
| 平胡 | +1 | 全顺子+对子 2-8+门清荣和 |
| 断幺 | +1 | 全部牌 2-8,无幺九风字 |
| 嶺上開花 | +1 | 杠后补摸牌胡(自摸专属) |
| 本命花 | +1/张 | 座位匹配的花/季牌 |
| 自风碰 / 圈风碰 | 各 +1 | 碰本座风牌 / 碰当前圈风牌 |
| 混幺九 | +3 | 每组均含幺九或风字 |
| 七对 | +3 | 七个不同对子 |
| 碰碰胡 | +3 | 四副全刻子 |
| 混一色 | +3 | 一色数牌+风字 |
| 小三元 | +5 | 两副字牌刻子+第三种字牌对子 |
| 小四喜 | +6 | 三副风牌刻子+第四风对子 |
| 清一色 / 字一色 | 各 +7 | 纯一色 / 全风字 |
| 大三元 | +8 | 三副字牌(中发白)刻子 |
| 大四喜 | +13 | 四副风牌(东南西北)刻子 |
最低起胡:1 番(任意合法手牌可胡)。可在
game_state.py调整MIN_HAN。
胡牌番数通过指数公式转换为筹码单位,7 番封顶:
unit = min(64, 2^(番数-1))
| 番数 | 计算式 | 筹码单位 |
|---|---|---|
| 1 番 | 2^0 | 1 |
| 2 番 | 2^1 | 2 |
| 3 番 | 2^2 | 4 |
| 4 番 | 2^3 | 8 |
| 5 番 | 2^4 | 16 |
| 6 番 | 2^5 | 32 |
| 7 番 | 2^6 | 64(封顶) |
| 8 番+ | 2^7+ | 64(封顶) |
赢家从其他三家收钱,庄家付/收双倍:
| 赢家身份 | 各家支付 | 赢家总收入 | 计算公式 |
|---|---|---|---|
| 闲家赢 | 庄家付 2u另两闲各付 1u |
+4u | 2u + 1u + 1u |
| 庄家赢 | 三位闲家各付 2u |
+6u | 2u + 2u + 2u |
示例:
- 闲家自摸 3 番(七对)→ unit=4 → 收入 4×4=16 筹码
- 庄家自摸 5 番(混一色+碰碰胡)→ unit=16 → 收入 16×6=96 筹码
放炮者独自承担全部自摸份额之和,其余两家不付钱:
| 赢家身份 | 放炮者支付 | 赢家总收入 | 说明 |
|---|---|---|---|
| 闲家赢 | 4u | +4u | 相当于三人自摸总额 |
| 庄家赢 | 6u | +6u | 无论放炮者是庄还是闲 |
关键规则:
- 放炮者支付金额 = 三人自摸时的总支付额
- 庄家赢时,即使放炮者是闲家,仍需付 6u(而非自摸时的 2u)
示例:
- 闲家荣和 4 番(清一色)→ unit=8 → 放炮者付 32 筹码
- 庄家荣和 2 番(自摸+无花)→ unit=2 → 放炮者付 12 筹码
每次成功杠牌(暗杠/明杠/加杠),立即从其余三家各收 1 筹码(固定值,不受番数影响):
| 杠牌类型 | 杠牌者收入 | 各家支付 |
|---|---|---|
| 暗杠 / 明杠 / 加杠 | +3 筹码 | -1 筹码 |
注意:
- 杠钱即时结算,与局末胡牌结算独立
- 多次杠牌累计计算(杠 3 次 = +9 筹码)
- 搶杠胡时,杠未完成,不支付杠钱
| 项目 | 规则 |
|---|---|
| 初始筹码 | 每人 1000 |
| 跨局累计 | 筹码余额持续累加,不重置 |
| 结算弹窗 | 显示本局变化量(±N)及庄家标识(庄) |
| 大厅显示 | 各房间实时筹码余额 |
示例:完整一局结算
庄家(初始 1000)自摸 4 番 → unit=8 → 收入 6×8=48
闲家 A(初始 1000)放炮 → 筹码 1000 不变(自摸不涉及)
闲家 B(初始 1000)支付 2×8=16
闲家 C(初始 1000)支付 2×8=16
本局后余额:
庄家:1048(显示 +48,绿色)
闲 A:1000(显示 ±0)
闲 B:984 (显示 -16,红色)
闲 C:984 (显示 -16,红色)
连接地址:/ws/{room_id}/{player_id}
| 消息(客户端→服务器) | 说明 |
|---|---|
{"type": "discard", "tile": "BAMBOO_5"} |
出牌 |
{"type": "pung"} |
碰 |
{"type": "chow", "tiles": [...]} |
吃 |
{"type": "kong", "tile": "..."} |
杠 |
{"type": "win"} |
声明胡牌 |
{"type": "skip"} |
过 |
{"type": "restart_game"} |
重开局(仅游戏结束后有效) |
| 消息(服务器→客户端) | 说明 |
|---|---|
{"type": "game_state", "state": {...}} |
完整游戏状态(含 cumulative_scores、round_number) |
{"type": "claim_window", "tile": "...", "actions": [...], "timeout": 30} |
声索窗口,含倒计时秒数 |
{"type": "game_over", ..., "han_breakdown": [...], "han_total": N, "win_ron": bool, "chip_changes": {...}, "dealer_idx": N, "cumulative_scores": {...}} |
游戏结束,含番型/胡牌类型/本局筹码变化/庄家标识 |
详细文档见 CLAUDE.md。