人人麻将:从规则状态机到 Android 包的四川血战麻将
这是我做得最完整的一个项目:四川血战到底,四人实时对战,有真人房也有 AI 补位,H5 和 Android 包都出了,现在跑在自己的 VPS 上。
为什么是麻将
麻将看着简单,实际是个很好的服务端练手题:它同时具备强规则、多人实时、状态必须绝对一致三个特征。任何一处判断写歪,四个客户端立刻就会吵起来。写完一遍麻将,基本把状态同步该踩的坑踩了一轮。
而且川麻的「血战到底」比标准麻将更麻烦——有人胡牌后不是结束,而是离桌,剩下的人继续打。这意味着对局状态机不能简单地按「一局 = 四个人从头打到尾」建模。
架构
整体拆成三个仓库:
| 仓库 | 职责 | 技术栈 |
|---|---|---|
majiang-server | 规则引擎 + 房间服务 | Node.js、Socket.IO |
majiang-app | 客户端 | Cocos Creator 3.8.8、TypeScript |
majiang-godot-commercial-fork | 另一条客户端技术路线的试验 | Godot |
关键的一条设计决定:规则引擎和网络层彻底分开。
majiang-server/ src/ core/ # 纯函数式的规则与对局状态机,不认识 socket server/ # HTTP + Socket.IO,只负责收发和广播 test/ # 直接对 core 做单元测试,不起服务core 里没有任何网络代码,它就是一个 (状态, 动作) => 新状态 的状态机。好处非常直接:
- 测规则不用起服务器,
npm run test:core几秒跑完 - AI 就是一个「读状态、返回动作」的函数,和真人玩家走完全同一条代码路径
- 以后要换传输层(比如换成 WebSocket 原生或 KCP),
core一行不用动
规则实现的几个点
牌墙:108 张,只有万、筒、条,没有风牌和箭牌。这是川麻和国标最直观的区别。
定缺:开局每人必须选一门花色作为「缺门」,之后不能碰杠胡这门牌,手里的缺门牌必须优先打出去。这一步是川麻的灵魂,也是 AI 最容易写蠢的地方。
没有吃:川麻只有碰、杠、胡,没有吃。少了一个动作,但优先级判定并没有变简单——同一张牌可能同时有人要碰、有人要胡,得按「胡 > 杠 > 碰」的优先级仲裁,还要处理一炮多响。
血战到底:某人胡牌后从牌桌移除,但牌局继续,直到只剩一人或牌墙摸完。所以对局状态里必须显式维护「还在场的玩家」这个集合,而不是假定永远是四个人。
一次缺陷审计
项目跑了一段时间后我做过一轮系统性的缺陷排查,一共整理出 20 条问题,修掉了 17 条。剩下 3 条是需要改数据结构的,放到了后面的迭代里。
这轮审计最大的收获不是修了多少 bug,而是确认了一件事:凡是能在 core 层用单元测试复现的问题,修起来都很快;凡是只能靠「开四个浏览器手动打一局」复现的,都很慢。 后来我把能下沉的判断全部往 core 挪了。
客户端与打包
客户端用 Cocos Creator 3.8.8 + TypeScript。选它的主要原因是一套工程同时出 H5 和 Android,而对局逻辑完全复用服务端已有的协议,客户端不重写任何规则——客户端只负责渲染和把玩家操作发上去,所有判定以服务端为准。
Android 出包这一步坑最多,主要是 Gradle 和 NDK 的版本匹配问题。这部分我单独记了笔记,之后补一篇。
部署
服务端直接 tar over ssh 推到 VPS,不走 CI。原因很实际:这个项目的部署频率不高,而且有几个目录(玩家数据、房间快照)绝对不能被覆盖,走脚本反而容易手滑。改数据相关的东西之前必须先停服。
后面要做的
- 血流成河玩法
- 账号和金币持久化(现在是内存,重启清零)
- 换三张
- 完整番型表
这篇是项目概览。具体到某一块(比如状态机怎么设计、Android 出包踩了什么坑)我会单独开文章写。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!


