教你打麻将
- 约 34 分钟读完 - 全文 13212 字教你打麻将
文章面向从未接触过联网游戏开发的读者,所以前半部分会把"联网棋牌游戏到底是怎么跑起来的"讲清楚,中间穿插真实代码层面的例子;后半部分专门开一章讲四川麻将的新手实战策略——毕竟做麻将游戏的人,自己先得会打麻将。
- 服务端:Java + Spring Boot + Netty(netty-socketio)+ Hazelcast + MySQL
- 客户端:Cocos Creator / JavaScript
一、先搞明白:联网棋牌游戏到底是怎么回事
1.1 单机游戏和联网游戏的本质区别
单机游戏里,所有逻辑都在你自己的设备上运行,规则由你的设备说了算。联网游戏不行——四个人打麻将,谁摸了什么牌、谁点了炮,必须有一个"裁判"说了算,否则每个人的手机都各算各的,立刻就乱套了。
这个"裁判"就是服务器。联网棋牌游戏的基本模型是:
graph TB
subgraph 玩家设备
C1[玩家A的手机<br/>Cocos Creator 客户端]
C2[玩家B的手机]
C3[玩家C的手机]
C4[玩家D的手机]
end
S[游戏服务器<br/>贝密:Spring Boot + Netty]
DB[(MySQL<br/>账号/战绩/房卡)]
CACHE[(内存缓存 Hazelcast<br/>房间/牌局实时状态)]
C1 <-->|WebSocket 长连接| S
C2 <--> S
C3 <--> S
C4 <--> S
S --> CACHE
S -.异步落盘.-> DB
几个关键点:
- 牌在服务器手里。洗牌、发牌都在服务器上做,客户端只负责"显示"和"发指令"。你在手机上点"打出三万",本质是给服务器发了一条消息:
doplaycards: "12",服务器校验"现在确实轮到你、你手里确实有三万"之后,才广播给全桌人。 - 每个人看到的信息不同。服务器把同一局牌的状态,按"你能看到什么"过滤后分别发给四个人——你只能看到自己的手牌和别人打出来的牌。这叫信息不对称,也是棋牌游戏和射击游戏最大的不同:状态同步必须分人定制。
- 防作弊的第一道防线就是"服务器权威"。客户端想赖牌(打出一张自己没有的牌),服务器直接拒绝。
1.2 为什么用 WebSocket 而不是 HTTP
没接触过联网游戏的人容易问:发消息不就是调接口吗,用 HTTP 不行吗?
不行,因为 HTTP 是"问一句答一句",服务器没法主动找你。而棋牌游戏里大量事件是服务器主动推的:轮到你出牌了、有人碰了你的牌、房间人齐了……这些都需要服务器随时推消息给客户端。WebSocket 是一条建立后一直保持的双向通道,双方随时可以说话,延迟低、开销小。贝密用的 netty-socketio 就是在 Netty 上实现了 Socket.IO 协议(跑在 WebSocket 上),客户端 Cocos Creator 里直接用 socket.io 的 JS 库连。
1.3 房卡模式:一张"虚拟门票"
贝密 0.8.0 主打房卡模式,这也是 2016-2018 年棋牌游戏爆发的典型商业模式:
- 你在商城购买"房卡"(虚拟道具,存在 MySQL 里);
- 你想和朋友打牌,就"开房"——消耗房卡,服务器生成一个 6 位数字房号(贝密里由
GameEngine.creatGameRoom生成); - 朋友在大厅输入房号进房(客户端发
searchroom事件),人齐开局; - 打完约定的局数,服务器出结算单,房间销毁。
和陌生人随机撮合的"大厅模式"相比,房卡模式本质上卖的是"熟人社交的场地",技术上则多了一个"按房号精确找房"的入口。
二、贝密的整体架构拆解
2.1 架构总览
graph LR
subgraph 客户端
A[Cocos Creator<br/>dizhu / majiang 模块]
end
subgraph 服务端 com.beimi
B[GameEventHandler<br/>Socket 事件入口]
C[GameEngine<br/>游戏总调度]
D[自研状态机<br/>BeiMiStateMachine]
E[Board 牌局内存模型<br/>MaJiangBoard / DuZhuBoard]
F[Action/Task<br/>出牌·结算·计时任务]
end
subgraph 存储层
G[(Hazelcast<br/>房间/牌局/玩家/撮合队列)]
H[(cache2k<br/>过期回调当计时器)]
I[Disruptor RingBuffer<br/>异步事件]
J[(MySQL JPA)]
K[(Elasticsearch)]
end
A <-->|WebSocket /game namespace| B
B --> C --> D --> E
D --> F
E --> G
F --> H
C --> I
I --> J
I --> K
各层职责一句话概括:
- GameEventHandler:所有
@OnEvent端点都在这里,负责把客户端消息翻译成人话(比如把"12,13"解析成字节数组),然后交给 GameEngine; - GameEngine:总调度,加房、开局、出牌、杠碰胡、离房、解散房间都从这儿过;
- 自研状态机:驱动"房间"这个对象的生命周期(后面细讲);
- Board 内存模型:一局牌的完整状态(四家的手牌、桌面牌、轮到谁、倍率),只活在 Hazelcast 内存缓存里;
- 存储分层:实时状态全在 Hazelcast,MySQL 只存账号/战绩/房卡流水,而且是通过 Disruptor 异步写入——牌局进行中的一秒钟都不等数据库。
2.2 一次出牌的完整旅程(以麻将为例)
这是最核心的一条链路,用实际代码里的事件名走一遍:
sequenceDiagram
participant P1 as 玩家A客户端
participant H as GameEventHandler
participant E as GameEngine
participant B as MaJiangBoard
participant T as 计时器(cache2k)
participant P2 as 其他玩家客户端
P1->>H: emit("doplaycards", "12")
H->>E: takeCardsRequest(token, card)
E->>B: 出牌校验 + 从手牌移除
E->>T: remove(roomid) 取消A的出牌倒计时
E-->>P2: 广播 "command": takecards
E->>E: checkMJCard 遍历其他家<br/>查 杠/碰/吃/胡
alt 有人能杠碰胡
E->>T: 挂5秒等待任务(DEAL事件)
P2->>H: emit("selectaction", "peng")
H->>E: actionEventRequest 执行碰
else 无人响应
E->>B: dealRequest 下家抓牌
E-->>P2: 广播 "command": dealcard
E->>T: 挂8秒出牌任务<br/>(超时自动打)
end
举例说明:假设你打出一张"五筒",服务器干的事依次是——
- 校验:轮到你、你有五筒、这张牌合法;
- 把你 8 秒的出牌倒计时从计时器缓存里删掉(你不主动出,超时会由
CreateMJPlayCardsTask强制替你出); - 广播给全桌:“A 打了五筒”;
- 挨个问另外三家:你能碰吗?能杠吗?能胡吗?(定缺了筒子的那家直接跳过——这就是四川麻将的规则在代码里的体现,
GameUtils.processMJCard里定缺花色不参与判断); - 如果 B 能碰,给 B 弹"碰/过"按钮,牌局挂起 5 秒等他决定;B 点了"碰",就执行碰、轮到 B 出牌;
- 如果三家都"过",下家自动摸一张牌,服务器先替他检查一次"摸上来的牌能不能暗杠/自摸",然后通知他出牌,重新挂上 8 秒倒计时。
整个过程中,客户端只做两件事:把服务器的消息渲染成动画,把玩家的点击发给服务器。
2.3 房间的状态机流转
房间是有生命的:创建、等人、人齐、开局、结算、销毁。贝密没有用 Spring StateMachine,而是自研了一个极简状态机(core/statemachine 包),每种游戏一个 Bean:
stateDiagram-v2
[*] --> NONE
NONE --> CRERATED: ENTER 创建房间
CRERATED --> WAITTING: JOIN 玩家陆续进房
WAITTING --> READY: ENOUGH 人齐
WAITTING --> WAITTING: AUTO 超时补AI机器人
READY --> BEGIN: RAISEHANDS 全员准备
BEGIN --> LASTHANDS: 发牌完成
LASTHANDS --> PLAY: 庄家打出首张
PLAY --> PLAY: DEAL/PLAYCARDS<br/>摸牌出牌循环
PLAY --> END: ALLCARDS 有人胡牌/牌摸完
END --> [*]: 结算发战报
状态机里每个"事件"对应一串 Action(比如麻将的链路是 EnterAction → JoinAction → EnoughAction → SelectAction(定缺) → MJRaiseHandsAction → DealMJCardAction → PlayMJCardsAction → AllCardsAction),业务代码只需要一句 game.change(room, event, intervalSeconds),状态机负责找到对应 Action 执行,并由 Action 顺手创建下一个延时任务,形成闭环。
三、断线重连:联网游戏逃不掉的工程问题
新手最容易低估的就是这块。手机上打牌,来个电话、进个电梯,连接说断就断。贝密的处理方式是"托管 + 快照恢复":
flowchart TD
A[玩家网络断开] --> B{服务器收到<br/>OnDisconnect}
B -->|玩家正在对局中| C[标记 OFFLINE<br/>进入托管状态]
C --> D[计时器超时后<br/>auto=true 自动出牌]
D --> E{玩家重连?}
E -->|emit recovery| F[GameEngine 查缓存<br/>发现该房间有进行中的 Board]
F --> G[下发 RecoveryData 全量快照:<br/>自己的手牌/桌面牌/轮到谁/剩余倒计时25s]
G --> H[客户端按快照重建牌桌画面]
E -->|一直没回来| I[AI托管打到本局结束]
举例:你出牌出了半局断网了,服务器不会暂停牌局等你(其他三个人不答应),而是把你标记为 OFFLINE,由超时任务替你"摸什么打什么"。30 秒后你连回来了,客户端发 recovery,服务器把当前牌局的完整快照发给你,你的手机根据快照把桌面重新画出来,无缝接上。注意重连期间你是被托管的——所以掉线久了回来,往往牌已经很难看了,这是规则的一部分。
四、四川麻将新手致胜策略:一个结构化的决策框架
做了这么久的麻将游戏,经常被问"新手怎么提高胜率"。零散的口诀(“记牌"“防点炮”)新手记不住也用不上,所以这一章我把四川麻将的策略整理成一个结构化框架:先确立唯一的目标函数,再按"战略层 → 信息层 → 战术层 → 概率层"四个层级组织所有决策,最后给出血战到底的特有推论和新手错误清单。逻辑主线只有一句话:
麻将的每一步都是在信息不完备下做期望收益最大化的决策。四个层级分别回答:往哪走(战略)、知道什么(信息)、这巡打哪张(战术)、什么时候切换路线(概率)。
4.0 规则即策略:先立规则,再推结论
- 定缺:开局后每人必须选一门花色(万/筒/条)作为"缺门”,胡牌时手里不能有这门牌,摸到缺门的牌必须打掉。牌墙只有 108 张(三门花色,无风牌)。
- 血战到底:一家胡牌不结束牌局,剩下的人继续打,直到牌摸完或只剩一家没胡。点炮只有点炮者付分,最后没胡的人还要给已听牌的人"查叫"赔分。
从这两条规则可以直接推出三条战略推论,整章都是它们的展开:
- 信息压缩:定缺把每家的"有效牌域"从三门砍到两门,而且是公开信息。读牌成本比别的麻将低一个量级——新手也能读。
- 时间价值:牌局不因一人胡牌而结束,每张牌的价值随牌墙减少而衰减。“还剩几巡"是所有决策的隐含变量。
- 止损优先:点炮只由点炮者一人付分、没胡要赔查叫——落后的代价是确定的,追上的收益是概率性的。这决定了"不跟随到最后"在数学上往往是对的。
三条推论的推导关系:
flowchart LR
R1[规则一: 定缺<br/>缺门公开] --> C1[推论一: 信息压缩<br/>读牌成本低]
R2[规则二: 血战到底<br/>胡牌不终局] --> C2[推论二: 时间价值<br/>剩几巡是隐含变量]
R2 --> C3[推论三: 止损优先<br/>点炮独赔+查叫保底]
C1 --> S[决定 4.2 信息层怎么读]
C2 --> S2[决定 4.4 何时切换路线]
C3 --> S3[决定 4.5 防守与保底策略]
4.1 战略层:起手评估与路线选择
拿到起手牌、定缺之前,先给牌打分。不需要精确,三个数就够:
- 向听数:还差几张牌能听牌。数一下你手里有几个成型搭子/刻子/对子,13 张牌需要 4 副 + 1 将,差几副就是几向听。新手只要会区分"两向听以内(好牌)“和"三向听以上(差牌)"。
- 对子数:4 对以上 → 七对/大对子路线候选;0~1 对 → 别惦记对子路线。
- 同花色集中度:9 张以上同花色 → 清一色路线候选。
定缺选择是第一个战略决策,原则按优先级排:
- 缺张数最少的那门——缺门每张都是必打的废牌,张数越少浪费的巡数越少;
- 张数相近时,缺结构最差的那门(孤张多、没搭子的);
- 还在纠结时,缺端张多的那门(1、9 多),因为端张成搭能力弱。
举例:起手万子 2 张(三万、八万,无关联)、筒子 5 张(两个搭子)、条子 6 张(一个刻子一个边搭)。毫无疑问题缺万——但如果万子是 2 张而筒子是 3 张但全是散牌,就该缺筒子:定缺看的是"废牌数量”,不是"张数”。
定缺公布后立即做第二步战略判断:看三家的缺门,决定做大还是快跑。
- 三家中有两家缺你这门 → 你要做的花色没人抢,牌墙里全剩给你,做大的绿灯;
- 三家都不缺你这门 → 四个人抢一门牌,你的关键张会被飞速消耗,果断转快跑。
至此,三条路线已经清晰:
| 路线 | 触发条件 | 目标 | 核心动作 |
|---|---|---|---|
| 快跑 | 牌型一般、向听小、花色被抢 | 最速听牌、先胡锁定收益 | 牌效优先,不留废搭 |
| 做大 | 起手 9 张+同花色/4 对+,且无竞争 | 清一色/大对子/七对 | 忍受慢听牌,屯关键张 |
| 防守 | 有人已听牌且自己手牌差 | 不点炮、保查叫底线 | 只打熟张,听牌优先于胡牌 |
把起手到定路线的全过程画成一张图:
flowchart TD
A[拿到13张起手牌] --> B[三个数打分:<br/>向听数 / 对子数 / 同花色集中度]
B --> C{定缺: 缺哪门?}
C -->|第一优先| C1[缺张数最少的门]
C -->|张数相近| C2[缺结构最差的门<br/>孤张多没搭子]
C -->|还纠结| C3[缺端张多的门<br/>1和9成搭弱]
C1 & C2 & C3 --> D[公布定缺<br/>观察三家缺门]
D --> E{有人抢我要做的花色?}
E -->|两家以上缺我这门<br/>绿灯| F{起手支持做大?<br/>9张+同花色 或 4对+}
F -->|是| G[做大路线<br/>清一色/大对子/七对]
F -->|否| H[快跑路线<br/>最速听牌]
E -->|三家都抢我这门| H
E -->|牌型差 三向听以上| I[保守推进<br/>随时准备转防守]
style G fill:#ffe6cc
style H fill:#d4edda
style I fill:#f8d7da
路线不是选定就不变——每一巡都可以用新信息重估,4.4 会给出切换的量化标准。
4.2 信息层:记牌与读牌
信息层回答"我知道什么"。它分两半:记牌是对已发生事实的压缩存储,读牌是从事实反推未知信息。
4.2.1 记牌:分层记忆法
让新手背下三家打过的每张牌不现实。按重要性只记三类:
第一层:记"张数"不记"顺序"。 每门花色每张牌只有 4 张。你不需要记住"上家第 3 巡打了七万",只需在脑子里维护计数:“七万已见 3 张”。重点盯的是你自己将牌和搭子两端的关键牌——你手里 6、8 万等卡 7 万,“7 万见了几张"直接决定这个搭子的死活:见 3 张以上,搭子基本死了,趁早拆。
第二层:记"缺门"和"花色倾向”。 定缺后,对手打出的非缺门花色才是有效信息。下家缺条,从第 2 巡起一直打筒子、摸到万子全留——一句话记住"下家只留万"即可,不用记他打的每一张。
第三层:记"序盘的反常中张"。 序盘(前 4~5 巡)正常人先打孤张边张(1、9)。有人序盘就打 4、5、6 中张,说明这门要么极好(搭子齐了中张多余)要么整门不要,结合缺门大概率能分辨。
口诀:数自己的关键张,记别人的花色倾向,盯序盘的反常中张。
三层记忆的工作量递减、收益递增,像一座金字塔——新手从塔底练起:
flowchart BT
L1[第三层: 序盘反常中张<br/>一两个反常信号即可<br/>精力占比 ~10%] --> L2[第二层: 缺门与花色倾向<br/>每家一句话: 他只留X<br/>精力占比 ~30%]
L2 --> L3[第一层: 关键张计数<br/>自己搭子两端的牌各见几张<br/>精力占比 ~60%]
L3 --> R[收益: 搭子死活判断<br/>直接决定拆不拆]
L2 --> R2[收益: 知道谁在收哪门<br/>直接决定防什么]
L1 --> R3[收益: 序盘读出牌型方向]
4.2.2 读牌:他要什么、不要什么、打什么危险
读牌的本质是用别人的"打出"反推他的"留下"——人留下什么牌,才会打出什么牌。按牌局阶段读不同的东西:
序盘读"结构":缺门 + 反常中张 + 第一碰,拼出每家的大致牌型方向。
中盘读"进度":看连续动作。上家碰了"二筒"后摸打筒子以外的牌,说明他筒子上还有货;碰完后打的那张牌的邻张往往是他不需要的(碰二筒后打五万,他大概率没有 3-4 万、4-6 万的搭子);碰了二筒后打的还是筒子,几乎可断定他在做筒子清一色或已听牌。
尾盘读"节奏":一个人连续摸牌就打(摸打),说明手牌已成型,打出的就是刚摸的;反过来想了很久才打出一张,说明这张是从手里"拆"出来的,他在这张牌附近有搭子变动,其邻张不再安全。
由此得到防点炮 checklist,中后盘(牌墙剩 1/3 以内)每打一张前过一遍:
- 这张牌见过几张? 见 2~3 张的熟张相对安全;一张没见的生张风险最高。
- 有没有人明显在收这门? 对家在做万子,你手里的生张五万就是高危,宁可拆搭子打熟张。
- 他是不是单钓/卡张? 碰了三副、手牌只剩一两张的人,听的牌范围很窄,往往就在他碰的那几副的邻张上——那一片中张全是雷区。
完整例子:对家定缺条子,碰了"三筒",摸打两巡后思考良久切出"八万"。推断:他在做筒子(碰三筒+留筒),八万是万子里的多余搭子;你手里五、六、七万从此相对安全(他不要万子),但任何生张筒子都是高危,尤其一、二、四筒(三筒邻张)。
读牌的推理链条固定是"观察 → 反推 → 行动"三段,常见信号对照如下:
flowchart LR
subgraph 观察[他打出/做了什么]
O1[缺门公布后<br/>一直打筒留万]
O2[碰二筒后<br/>又打出筒子]
O3[碰二筒后<br/>打出五万]
O4[连续摸打]
O5[长考后拆出八万]
end
subgraph 反推[他留下/在想什么]
I1[在做万子清一色]
I2[筒子听牌或做筒]
I3[万子3-4/4-6搭子不存在<br/>→ 万字邻张变安全]
I4[手牌已成型<br/>打的就是刚摸的]
I5[八万附近有搭子变动<br/>→ 邻张不安全]
end
subgraph 行动[我该怎么办]
A1[生张万子列为高危]
A2[生张筒子绝不打]
A3[手里4万6万可放心打]
A4[按他已听牌对待<br/>进入防守]
A5[七万九万扣住观察]
end
O1 --> I1 --> A1
O2 --> I2 --> A2
O3 --> I3 --> A3
O4 --> I4 --> A4
O5 --> I5 --> A5
4.3 战术层:牌效——这一巡到底打哪张
战术层是执行:在路线已定、信息已有的前提下,每张出牌都按同一套优先级评估。
出牌优先级(无安全压力时):缺门牌(必打)→ 孤立端张(无邻张的 1、9)→ 孤立中张 → 多余搭子中进张最少的那张 → 拆搭子。核心原则是"每一巡都最大化有效进张总数":假设搭子 A(4-5 万)有 8 张潜在进张、搭子 B(1-2 筒)只有 3 张潜在进张,要拆就先拆 B——哪怕 B 看起来更"连得顺"。注意"潜在进张"还要减去已见张数,见 3 张的两面搭子不如没见过的卡张。
碰/杠决策是新手最容易犯错的地方,默认答案是"不碰",除非满足以下至少一条:
- 碰了立刻听牌,或碰的是你的将牌方向(对子路线);
- 你在做清一色/大对子,碰能加速成型;
- 这张牌不碰就被下家吃碰走、你再也等不到。
因为碰有隐性成本:暴露信息(全桌知道你在收这门)、破坏门清弹性(碰完之后手牌变窄,改路线更难)、改变摸牌顺序(可能把本该你摸的关键张让给下家)。杠同理:明杠多摸一张且血战里"杠上花"有额外收益,但同样暴露信息,尾盘还可能给别人开"杠上炮"。起手做大时积极碰杠,快跑路线时能不碰就不碰。
听口选择遵循两个数字:
- 听口宽度:两面听(8 张潜在)> 卡张/边张(4 张)> 对倒/单钓(2~4 张)。能选宽不选窄;
- 剩余张数:减去已见的。听见过 2 张的两面(剩 4~6 张),通常不如听没见过的卡张(剩 4 张但还有别人扣牌的空间)。尾盘再加一条修正:听熟张——别人敢打的牌,你才能吃到点炮;听生张只能指望自摸。
把出牌优先级和碰杠决策各画成一张图,实战时按图索骥即可:
flowchart TD
A[这巡要出牌] --> B{手里有缺门牌?}
B -->|有| C[打缺门牌]
B -->|没有| D{有孤立端张?<br/>无邻张的1/9}
D -->|有| E[打孤立端张]
D -->|没有| F{有孤立中张?}
F -->|有| G[打孤立中张]
F -->|没有| H{搭子进张数比较}
H -->|拆进张最少的搭子| I[打出该搭子中<br/>价值更低的一张]
C & E & G & I --> J[打完前最后过一遍<br/>4.2.2 防点炮checklist]
flowchart TD
P[别人打出一张牌<br/>我可以碰/杠] --> Q{碰了立刻听牌<br/>或是对子路线?}
Q -->|是| R[碰]
Q -->|否| S{我在做大<br/>且碰能加速成型?}
S -->|是| R
S -->|否| T{不碰就永远<br/>等不到这张?}
T -->|是| R
T -->|否| U[不碰, 默认答案]
U -.成本.-> V1[暴露信息: 全桌知道你收这门]
U -.成本.-> V2[破坏门清弹性: 手牌变窄]
U -.成本.-> V3[改变摸牌顺序: 可能放走关键张]
style U fill:#d4edda
style R fill:#fff3cd
4.4 概率层:期望值决策——做大、快跑、防守的切换点
这是整个框架的"调度器",回答"什么时候换路线"。先把 4.3 的直觉量化成一个可心算的模型。
基本概率感觉:任一时刻,你某方向的"期望进展" ≈ 剩余进张数 × 剩余可摸次数。你手里 4-5 万等 3/6 万,若都少见,剩余进张约 6-8 张;牌墙剩 40 张、四家轮流摸,你大约还能摸 8-10 次,摸中概率相当可观。但如果 3/6 万已见 3 张,进张只剩 1~2 张,期望直接砍到四分之一以下——搭子的价值不看牌面,看剩余进张数。
切换到"做大"的条件(需同时满足):
- 起手结构支持:9 张以上同花色,或 4 对以上;
- 进张供给充足:三家缺门不与你抢这门(4.1 的绿灯);
- 时间充足:牌墙过半之前。做牌是"用听牌速度换番数",起手离目标越近、剩余巡数越多,换得越划算;
- 血战规则加成:一家胡牌不结束牌局,早胡者只是先锁定小收益,晚胡的大牌仍能兑现——大牌的"被截胡损失"比推倒胡小得多,所以四川麻将天然鼓励做牌。
切换到"快跑/止损"的条件(满足任一即执行):
- 进张枯竭:关键牌期望进张 < 2,每一巡等待都是负期望——马上换听、拆死搭子;
- 有人已听牌且你生张密集:此时你的期望收益为负(点炮赔分 + 被查叫),最优解不是"我也赶紧听",而是只打熟张把点炮概率压到最低,保住查叫底线。这就是"不跟随到最后"——追不上就不再投入,及时止损;
- 牌墙见底(剩 15 张以内)还没听牌:做牌的时间价值归零,唯一目标是听牌防查叫——往剩余张数最多的熟张方向听,只听一张也比"做了一半的大牌"值钱。
一个简化的期望算账例子(假设平胡 1 番 = 2 分,清一色 4 番 = 16 分):你听牌了,可以选听口 A——平胡两面听,剩余进张 6 张;或改听口 B——清一色单钓,剩余进张 2 张。若剩余可摸次数约 6 次、点炮胡与自摸混合,A 的期望收益约是正 6~8 分;B 做成的概率约一半不到,做成了 16 分、做不成还要承担被查叫赔 2 分——期望约 6 分,且方差巨大。结论:番数翻倍抵不过进张减半,除非时间足够早或你已经落后必须搏。 新手默认选宽听口,永远是长期正期望。
把"什么时候切换路线"画成一张象限图——横轴是你的剩余进张,纵轴是牌局剩余时间,每巡定位一次自己落在哪个象限:
flowchart TB
UP[纵轴 ↑ 剩余时间多] --- TOP
subgraph TOP[ ]
direction LR
LEFT2[进张少] ~~~ A[换听调整<br/>时间够但方向死了<br/>例: 卡七万已见三张]
A ~~~ B[做大<br/>时间够且进张足<br/>例: 起手清一色九张]
B ~~~ RIGHT2[进张多]
end
TOP --- BOT
subgraph BOT[ ]
direction LR
LEFT[进张少] ~~~ C[防守保底<br/>先听牌防查叫<br/>例: 剩十二张没听牌]
C ~~~ D[快跑<br/>进张还在抢时间<br/>例: 两面听六张剩半局]
D ~~~ RIGHT[进张多]
end
BOT --- DOWN[纵轴 ↓ 剩余时间少]
style A fill:#fff3cd
style B fill:#ffe6cc
style C fill:#f8d7da
style D fill:#d4edda
两条边界记死:进张 < 2 立刻换方向(左半区行动),剩 15 张只保听牌(下半区行动)。右下象限是血战到底最常见的好局面——进张还在但时间不多,别犹豫,最快胡法锁收益。
4.5 血战到底的三条特有推论
规则特殊,策略就有别处用不上的推论:
- 先胡是"锁仓",不是"胜利"。你第一个平胡跑了,只是锁定 2 分;剩下三人继续打,大牌仍在你走后兑现。所以领先时快跑锁收益,落后时反而要做大搏翻盘——这和推倒胡"先胡为王"的直觉相反。
- 查叫把尾盘变成了保底竞赛。没胡的人要给听牌者赔分,所以尾盘的及格线是"听牌"而不是"胡牌"。最后 15 张,一切决策让位于"听上"。
- 点炮一人付,防守成本低。别人自摸三家分摊、你点炮你独赔——你打熟张"放生"别人自摸,损失远小于你点炮。所以防守期打熟张不仅是避险,还是把损失转移给全桌分摊的理性选择。
三种结算方式的赔付流向对比,一看就懂为什么"防守打熟张"是理性选择:
flowchart LR
subgraph 点炮[你点炮: 你独赔]
Y1[你 -8分] --> H1[胡牌者 +8分]
O1[另两家 0分]
end
subgraph 自摸[对手自摸: 三家分摊]
Y2[你 -3分] --> H2[胡牌者 +9分]
O2[另两家各 -3分] --> H2
end
subgraph 查叫[流局查叫: 没听赔听牌]
Y3[你没听 -2分] --> H3[听牌者 +2分]
end
style Y1 fill:#f8d7da
style Y2 fill:#fff3cd
style Y3 fill:#d4edda
同样是对手胡牌,你点炮亏 8 分、放生他自摸只亏 3 分——防守期每打一张熟张,赚的就是这个差价。
4.6 新手常见错误清单
对照检查,改掉一条涨一截胜率:
- 只记自己牌,不看池子——搭子死活取决于关键张已见几张,不看池子等于闭眼打牌;
- 见碰就碰——暴露信息、锁死手牌结构,快跑路线尤其要忍住;
- 死抱死搭子——7 万已见 3 张还抱着 6、8 万不拆,每巡都在付租金;
- 做大不看缺门——三家都抢你这门还硬做清一色,供给断了;
- 尾盘还想着翻盘——剩 15 张没听牌,唯一 KPI 是听牌防查叫;
- 不跟随到最后反着用——该止损时硬追生张点炮,该搏的时候(落后、时间早)却提前保守。
4.7 一页纸决策框架
把整章压缩成一张每巡都走的决策图:
flowchart TD
A[每一巡] --> B{信息更新:<br/>关键张已见数/<br/>三家缺门/谁听牌了}
B --> C{阶段判断}
C -->|序盘: 定缺+定路线| D{起手支持做大?<br/>9张+同花色/4对+<br/>且三家不抢这门}
D -->|是| E[做大路线:<br/>积极碰杠, 屯关键张]
D -->|否| F[快跑路线:<br/>牌效优先, 能不碰不碰]
C -->|中盘| G{有人已听牌<br/>且我生张密集?}
G -->|是| H[防守: 只打熟张<br/>放生自摸, 不点炮]
G -->|否| I{我的关键进张<br/>剩余>=4?}
I -->|是| J[继续推进<br/>选进张最多的打]
I -->|否| K[拆死搭子<br/>换听熟张方向]
C -->|尾盘: 剩15张内| L{已听牌?}
L -->|否| M[唯一目标: 听牌防查叫<br/>听剩张最多的熟张]
L -->|是| N[宽听口优先<br/>不为一番改窄]
四个层级的关系:战略层定方向,信息层供弹药,战术层管执行,概率层负责喊停。 新手先把战术层的"进张最大化"练成肌肉记忆,再逐步叠加记牌和读牌,最后学会用期望值喊停——这条学习路径本身也是按依赖关系排的。
五、贝密的技术亮点
最后回到工程,挑几个我认为值得讲的点。
5.1 用 byte 编码整副牌:一副麻将只要几十个字节
贝密把每张麻将牌编码成一个 byte:万/筒/条各占 0~107(每门 36 个编号 = 9 个面值 × 4 张),牌面值 = (card % 36) / 4,花色 = card / 36;风牌用负数。这样 108 张牌的牌墙就是一个 byte[108],序列化后不到 80 字节。
举例:编号 12 这张牌,12 / 36 = 0(万子),(12 % 36) / 4 = 3(第 4 个面值)——就是"四万"。一个除法一个取模就解出了花色和面值,不用查表、不用对象,广播出牌消息时网络上跑的就是 "12,13" 这样的字符串。
5.2 胡牌判断:不用 3N+2 递归,用"分堆 + 贪心摘顺子"
经典麻将胡牌算法是 3N+2 递归枚举。贝密的 GameUtils.processMJCard 走了一条不同的路:
- 把 14 张手牌按
key = 面值 + 9 × 花色放进字典计数,先查定缺——缺门花色还在手里,直接不能胡(四川规则前置拦截); - 按计数把牌分成三堆:
others(单张)、pairs(对子)、kezi(刻子); - 对单张堆排序后贪心摘顺子:同花色且 key 连续的 k/k+1/k+2 三张直接移除;
- 终止条件:单张摘空且恰好剩 1 对将(或 7 对——七对牌型天然被"pairs 堆 = 14 张"覆盖);
- 对子多于 1 对时,把多余对子拆开当顺子候选再摘;全无刻子时枚举刻子拆开试。
源码 main() 里作者还放了性能测试:500 万次胡牌判断直接计时。要说明的是,这是个简化实现——作者自己在注释里承认某些"三连对"式的特殊牌型没完整处理。对在线棋牌的实际负载来说,这种"足够快、覆盖主流牌型"的取舍是合理的。
5.3 斗地主牌型识别:频次直方图一把梭
ActionTaskUtils.identification(byte[]) 的做法很干净:先统计同点数张数的分布(几个单张、几个对子、几个三张、几个炸弹),再用 switch(种类数, 最大点数) 一把识别全部牌型——单张、对子、三带一/二、顺子、连对、飞机带翅、四带二、炸弹、王炸。比较两手牌大小的 allow() 则只有三条规则:王炸最大、炸弹管一切非炸弹、同牌型比最大点数。AI 托管出牌也复用这套识别,找"能压住上家的最小牌"。
5.4 自研极简状态机统一驱动两种游戏
斗地主和麻将的流程差异巨大(抢地主 vs 定缺、出牌规则完全不同),但贝密用一个抽象统一了:BeiMiStateMachine + 按事件名查 transition 表 + Action 链。新增一种游戏 = 写一组 Action + 一份状态配置,调度、计时、广播的基础设施全部复用。
5.5 把缓存过期回调当游戏计时器用
这是我觉得最"巧"的设计。棋牌游戏里到处是倒计时:出牌 8 秒、杠碰胡决策 5 秒、抢地主超时自动放弃、房间超时补机器人。贝密没用 Quartz、没开线程池,而是用一个 sharpExpiry 的 cache2k 缓存当计时器:key 是 roomid,value 是任务对象,到期自动触发过期监听器执行任务。玩家主动操作了?remove(roomid) 就把倒计时取消了;要重置?put 一个新任务进去。零额外线程,语义严丝合缝。
5.6 实时状态与持久化彻底解耦
牌局进行中的所有状态(房间、Board、玩家手牌、撮合队列)全在 Hazelcast 分布式内存里,MySQL 只在结算时被异步写入——通过 Disruptor 的 RingBuffer 把变更事件丢给消费者,JPA 和 Elasticsearch 双写(ES 用于运营后台的战绩检索)。注释里还有一条重要的工程纪律:“只有一个地方对 GameRoom 二次写入,避免分布式锁”——用设计约束换掉了锁的开销。另外,房卡模式建房时的局数等规则赋值用了 Drools 规则引擎,规则写在 .drl 文件里,运营改规则不用改代码。
六、总结与反思
回顾这个项目,对新手最有价值的三个认知是:
- 联网游戏 = 服务器权威 + 按人过滤的状态分发。理解了"客户端只是显示器和遥控器",就看懂了所有联网游戏的骨架。
- 棋牌游戏的复杂度不在算法,在工程。胡牌判断几百行就写完了,真正难的是断线重连、超时托管、并发房间这些"无聊"的部分——它们决定了玩家半夜打牌掉线时骂不骂人。
- 做游戏的人必须会玩游戏。第四章的四川麻将策略——分层记牌、由"打出"反推"留下"、用剩余进张 × 剩余巡数做期望决策——不只是给玩家的攻略,也是当初我们校验 AI 托管和出牌提示是否合理的标尺。
当然也有粗糙之处,如实记录:README 宣称的德州扑克在 0.8.0 里只有枚举占位并未实现;胡牌算法是简化版;源码里散落的 System.out.println 和作者自注的 bug 注释(比如多人同时可碰时先碰者胜出的竞态问题)都还在。但作为一个学习联网游戏开发的开源样本,它把一个房卡棋牌系统该有的零件——长连接、房间状态机、计时器、托管、异步落盘——都真实地摆在了那里,这比完美更有价值。