简介:面向微信小程序初学者与游戏逻辑练习者的麻将主题源码包,主体是一套围绕骰子与麻将交互的小程序示例,并附详细教程,可用于理解小型游戏项目的页面搭建、样式组织与基础逻辑实现。压缩包共16个文件、约52KB,包含10张PNG截图与图片素材、2个WXSS样式文件、2个JS脚本、1个WXML页面结构和1个JSON配置文件,基本覆盖小程序配置、视图、样式与交互脚本等层面,截图也可辅助对照界面效果。已有819人学习,表明该示例在入门练习场景中具备一定参考价值。读者可结合教程阅读源码,梳理页面目录组织、事件监听与响应、随机骰子逻辑及基础状态处理思路,也可将其作为课程设计或小程序练手项目的起步模板,在改造与调试中积累实践经验。
1. 高仿麻将游戏小程序源码从哪看起
解压这份「高仿麻将游戏」小程序源码,第一眼容易被pages/room里那两千多行牌桌页面劝退——能编译,能进房间,牌打出去却不消失,或者四家手牌全亮在桌上。这套资源的定位其实很清楚:一份能跑通的四人麻将小程序,客户端管牌面渲染与交互,规则层管胡牌判定、听牌与番型,服务端管房间与出牌广播,再配一份把三块串起来的详细教程。它适合三类人:想练一套完整回合制小游戏闭环的开发者、想把胡牌算法单独抠出来做校验工具的、想拿房间同步骨架改造成其他四人桌游的。需要提前说清:这套代码是纯娱乐向的单机与好友房逻辑,不含充值、兑换、房卡代理模块,拿来做玩法验证和技术练手合适,别指望它自带运营后台。
2. 拆包先看骨架:目录分层与最小可跑闭环
拿到一份陌生的棋牌源码,我一般不看牌桌页面,先看目录。牌桌页面是最脏的一层,动画、点击、弹窗、音效全糊在一起,从那儿读只会越读越乱。真正的信息量在三个地方:牌怎么表示、状态怎么流转、消息怎么发。把这三件事摸清楚,剩下的都是皮肤。
2.1 三层目录:渲染、规则、房间各管一段
这份源码的目录切得比较规矩,客户端和服务端是分开的两个工程,规则层被单独抽出来,没有和渲染混写。这是它比多数网上流传的「麻将 Demo」好读的地方——很多 Demo 把胡牌判定直接写在按钮回调里,改一条规则要翻五个文件。
| 路径 | 职责 | 读它的优先级 |
|---|---|---|
miniprogram/pages/hall | 大厅、建房、加入房间 | 低 |
miniprogram/pages/room | 牌桌页面、点击交互、动画调度 | 中 |
miniprogram/game/core | 牌墙、发牌、回合状态机 | 高 |
miniprogram/game/rule | 胡牌判定、听牌、番型计算 | 最高 |
miniprogram/game/ai | 机器人出牌与碰杠决策 | 中 |
miniprogram/render | 牌面绘制、四家座位坐标换算 | 低 |
server/room.js | 房间生命周期、座位管理 | 高 |
server/ws.js | 消息收发、心跳、断线清理 | 高 |
读的顺序建议是rule→core→ws→room→ 页面。规则层是纯函数,输入输出干净,半小时能看完;core里的状态机决定了「什么时候该谁摸牌」,是后面所有 bug 的源头;ws和room决定多端一致性;页面层最后看,它只消费前面三层的结果。
有一点要提前注意:game/rule和game/core里都没有引用任何渲染 API,这是刻意做的隔离,方便把这部分直接搬到 Node 里跑单测。如果你要改规则,尽量保持这个隔离,别顺手import一个 UI 组件进去,不然后面想批量验证牌型就只能靠手点。
2.2 让牌桌先亮起来:四步最小启动
不用急着读代码,先把它跑起来。整个启动过程就四步,卡住的人多半是卡在第二步的 AppID 或第三步的服务端地址上。
# 1. 服务端装依赖并起本地房间服 cd server npm install npm run dev # 默认监听 3000,端口在 config/index.js 里改 # 2. 另开一个终端,确认端口通了 curl http://127.0.0.1:3000/health # 期望返回 {"ok":true,"rooms":0}客户端这边在开发者工具里导入miniprogram目录,AppID 选测试号即可,不需要企业主体。导入后先别点编译,去miniprogram/config/env.js把 WebSocket 地址改成ws://127.0.0.1:3000/ws,同时确认开发者工具里「不校验合法域名」已经勾上,否则小程序会直接拦掉这条连接。
这几步里最容易翻车的是端口和协议。npm run dev起的是 HTTP + WS 同端口,如果 health 能通但牌桌一直转圈,八成是 WS 路径写错,正确路径带/ws后缀。另外本地调试必须用ws://而不是wss://,上线才换。
服务端起来后,建房、加入、发牌这条链路就能走通了。单机模式不需要服务端,game/core里有一个LocalRoom的模拟实现,断网也能打,适合调 UI。
2.3 一次出牌的完整链路:从点击到四家同步
跑起来之后,最该搞清楚的是「我点一张牌,中间发生了什么」。这条链路是全套代码的主干,读懂它,碰、杠、胡、抢杠胡都是它的变体。
// game/net/protocol.js —— 客户端与服务端的消息约定 const MSG = { DISCARD: 1001, // C->S 出牌请求 { seat, tile, seq } DISCARD_OK: 1002,// S->C 出牌确认 { seat, tile, seq, next } SYNC: 1003, // S->C 全量快照 { seq, hands, discards, turn, wallLeft } ACTION_ASK: 1004,// S->C 询问碰/杠/胡 { seat, options, timeout } ACTION_RSP: 1005,// C->S 响应 { seat, type, seq } RECONNECT: 1006, // C->S 断线重连 { roomId, token, lastSeq } };seq是这个协议的核心,服务端每广播一条消息就自增一次,客户端只接受seq大于本地记录的消息,小于等于的一律丢弃。这一条规则能挡住九成的重连错位问题。tile用的是 0~33 的牌值编码,不是 1~136 的物理牌 ID,原因在第 3 章细说,这里只要记住客户端渲染层拿到 0~33 之后自己乘 4 去取图。
链路本身是:点击牌 → 本地先做一次乐观更新(把自己手牌移走,桌面加一张) → 发DISCARD→ 服务端校验轮次与牌是否存在 → 广播DISCARD_OK给四家 → 三家各自重算能否碰杠胡 → 服务端聚合优先级(胡 > 碰 > 杠 > 过)→ 发ACTION_ASK。乐观更新是为了手感,但一旦服务端拒绝,客户端必须能把状态回滚回去,回滚的基准就是最近一次SYNC快照。很多人只做了乐观更新没做回滚,结果网络抖一下手牌就多一张,这类 bug 极难复现,只能靠快照兜底。
3. 胡牌判定与听牌计算:递归拆解怎么写才不炸栈
规则层是这份源码里最值得单独拿出来的一份东西。麻将的胡牌判定看起来复杂,本质是一个带约束的分解问题:14 张牌能不能拆成 4 个面子加 1 个将。写得好三十行搞定,写得不好就是几百行 if-else,还判不对七对和十三幺。
3.1 为什么把 136 张压成 34 个计数
物理麻将有 136 张牌,但判定胡牌只关心「每种牌有几张」。所以第一步永远是编码转换:把万条筒各 9 种、字牌 7 种,一共 34 种,映射到下标 0~33,用长度 34 的计数数组表示手牌。
# rule/encode.py —— 物理牌 ID 与规则牌下标互转 SUITS = ['m', 's', 'p', 'z'] # 万 条 筒 字 def tile_to_index(tile_id): """0~135 物理牌 -> 0~33 规则下标""" return tile_id // 4 # 每种牌 4 张,整除即得种类 def hand_to_count(tile_ids): cnt = [0] * 34 for t in tile_ids: cnt[tile_to_index(t)] += 1 return cnt用整除而不是查表,是因为这份源码把同一张牌的四张物理 ID 连续排布(0~3 是 1 万,4~7 是 2 万……)。这样做的好处是渲染层和规则层可以共用一套 ID,不需要额外维护映射表;代价是任何地方插入或删除牌种都会整体错位,所以改动牌墙时必须同步改render/tile.js里的贴图索引。
计数数组最大的价值是「剩余张数」可以直接算:某种牌已见 n 张,牌墙上还剩4 - n张。这个数字在听牌提示和 AI 决策里都要反复用,如果每次去遍历弃牌堆重算,一局下来就是几万次无效循环。
提示:花牌和癞子不要塞进这个 34 长度数组。花牌在发牌阶段就该被剥离到独立数组并立即补牌,癞子则单独记一个「可替代任意牌」的标记位,否则后面所有判定都会被污染。
3.2 四面子一将:外层枚举将牌,内层递归拆面子
标准型的判定思路只有一句话:先枚举哪两张牌做将,剩下的 12 张递归拆成 4 个面子。递归的时候永远从「当前剩下最小的那张牌」开始拆,因为顺子和刻子都必须用到它,不拆它就没有别的出路,这一步保证了搜索不会重复。
# rule/win.py —— 标准型(4 面子 + 1 将)胡牌判定 def _meld(cnt, need): """从 cnt 中拆出 need 个面子,cnt 在返回时保持原样""" if need == 0: return all(v == 0 for v in cnt) i = next((k for k, v in enumerate(cnt) if v), -1) if i < 0: return False # 分支一:刻子 if cnt[i] >= 3: cnt[i] -= 3 ok = _meld(cnt, need - 1) cnt[i] += 3 if ok: return True # 分支二:顺子,只在序数牌内成立,且不能跨花色 if i < 27 and i % 9 <= 6 and cnt[i + 1] and cnt[i + 2]: cnt[i] -= 1; cnt[i + 1] -= 1; cnt[i + 2] -= 1 ok = _meld(cnt, need - 1) cnt[i] += 1; cnt[i + 1] += 1; cnt[i + 2] += 1 if ok: return True return False def is_win(cnt): """14 张手牌(含刚摸的牌)是否胡牌""" if sum(cnt) % 3 != 2: return False for i in range(34): if cnt[i] >= 2: cnt[i] -= 2 # 拆出将牌 ok = _meld(cnt, 4) # 剩余拆 4 个面子 cnt[i] += 2 if ok: return True return False这段代码有两个细节值得单独说。第一,_meld在每条分支返回前都把cnt还原了,无论是成功还是失败。这不是多余的保险,而是必须的——如果只在失败分支回滚,外层枚举将牌的循环就会拿到一个被改过的数组,第二次判定直接判错。我在别的项目里见过用「传副本」来回避这个问题,代价是每次递归一次数组拷贝,14 张牌的搜索树能拷贝上万次,性能差一个数量级。
第二,i % 9 <= 6这个边界是顺子判定的命门。序数牌每个花色 9 张,下标 6、7、8 对应 7、8、9,从 7 开始就不可能再凑顺子。写错成<= 7会让 8、9、1 跨花色成顺,这种 bug 平时测不出来,只有牌型恰好凑到边界才会冒出来。
is_win开头的sum(cnt) % 3 != 2是一道便宜的前置过滤。正常手牌在判胡时一定是 3n+2 张,如果因为漏发、多摸导致张数不对,这里直接返回,省掉后面整棵搜索树。
3.3 听牌与有效张:把 34 次判定跑成一张表
听牌的计算方式非常朴素:对 34 种牌各试一次,如果手里这种牌不足 4 张,就加一张进去判胡,判胡成功就是听的牌。一次听牌计算最多跑 34 次is_win,每次is_win的搜索树在剪枝后通常只有几十个节点,整体耗时在毫秒级,完全够用。
| 听的牌 | 牌值下标 | 已见张数 | 剩余张数 | 备注 |
|---|---|---|---|---|
| 3 万 | 2 | 2 | 2 | 常规两面听 |
| 6 万 | 5 | 3 | 1 | 只剩绝张 |
| 东 | 27 | 4 | 0 | 已绝,界面上要置灰 |
| 9 筒 | 26 | 0 | 4 | 全活 |
「剩余张数」这个字段一定要在 UI 上体现出来。玩家看到「听 3 万 6 万」和「听 3 万(剩 1 张)6 万(剩 4 张)」,决策质量完全不一样。这份源码里这个数只在听牌提示里露了一半,game/rule/ting.js已经把剩余张数算出来了,但渲染层只取了牌名,稍微改一下就能显示,是个性价比很高的改造点。
听牌计算还有一个容易忽略的场景:碰牌之后的听牌变化。碰完之后手牌是 3n+1 张且多了一个副露,此时判听要把副露计入「已完成面子数」,不能直接拿is_win判。源码里用meldCount参数把已固定的面子数传进_meld,把need从 4 改成4 - meldCount,这一处改动很小但必须做,漏了就会出现「碰完还提示能胡」的假听。
3.4 顺序很重要:七对、十三幺得先判
特殊牌型必须排在标准型之前判定,顺序错了会漏。原因是七对(7 个对子)里存在「4 张相同的牌」这种结构,标准型递归也可能把它拆成两个刻子从而误判;而十三幺的结构(13 种幺九加字牌各一张,其中一种成对)更是完全不符合「4 面子 + 1 将」的形态。
# rule/special.py —— 特殊牌型的判定,必须早于标准型 def is_seven_pairs(cnt): return sum(cnt) == 14 and all(v in (0, 2, 4) for v in cnt) \ and sum(1 for v in cnt if v == 2) + sum(1 for v in cnt if v == 4) == 7 def is_thirteen_orphans(cnt): YAOJIU = [0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33] if sum(cnt) != 14: return False if any(cnt[i] == 0 for i in YAOJIU): return False return all(cnt[i] == 0 for i in range(34) if i not in YAOJIU) \ and any(cnt[i] == 2 for i in YAOJIU)七对判定里v in (0, 2, 4)这一句是关键:4 张相同的牌在七对里要算两个对子,这是规则允许的,但很多人写成v == 2就判不出来。十三幺则要先确认 13 种幺九牌全部到齐,再看是否恰好有一种成对,两步顺序反过来会产生大量误判。
判定的总入口一般写成check_win(cnt, meldCount),内部顺序是:十三幺 → 七对 → 标准型。如果这套源码的番型表里还有「一条龙」「清一色」这类牌型,那些是在胡牌成立之后再做加番,不参与「是否胡」的判断,别混在一起写。这一点在改番型的时候特别容易搞反,把加番条件塞进入口函数,结果就是明明没胡也返回 true。
4. 出牌 AI 与房间同步:让机器人别像在乱打
算法跑通之后,下一个让人不满意的地方通常是机器人。默认的随机出牌会让人一眼看穿,游戏感瞬间归零。另一头,房间同步做不好,两个人看到的桌面不一样,比 AI 傻更致命。
4.1 向听数是唯一该被优化的指标
麻将 AI 的核心指标只有一个:向听数,也就是「离听牌还差几张有效牌」。向听数越小越接近胡牌。出牌决策的本质是「枚举打出每一张之后的向听数,选最小的那个」,向听数相同时再比有效张数量。
# ai/shanten.py —— 简化版向听估算:听牌=0,一向听=1 from rule.encode import hand_to_count from rule.win import is_win from rule.ting import get_ting_tiles def shanten(cnt, meld_count=0): if is_win(cnt): return -1 # 已胡 if get_ting_tiles(cnt, meld_count): return 0 # 听牌 # 一向听:打掉任意一张后能听牌 for i in range(34): if cnt[i] == 0: continue cnt[i] -= 1 if get_ting_tiles(cnt, meld_count): cnt[i] += 1 return 1 cnt[i] += 1 return 2 if meld_count >= 2 else 3 # 再远就粗略归并,避免无谓计算 def effective_count(cnt): """有效张总数 = 每种听牌的剩余张数之和,用于同向听数时比大小""" total = 0 for t in get_ting_tiles(cnt, 0): total += 4 - cnt[t] return total这个实现是近似版本,深度只算到一向听就停下然后粗略归并,原因是完整的向听计算需要枚举所有面子拆法,在客户端每帧算一次会掉帧。实际使用中,把「打到一向听」和「有效张数最大化」这两条合起来,机器人的表现已经超过绝大多数休闲玩家的直觉判断,没必要上完整算法。
effective_count是决定手感的关键参数。同样是打一张牌,有的选择听 3 张活牌,有的选择听 1 张绝张,向听数一样但胜率差好几倍。源码默认没算这一层,加上之后机器人的「思考感」会明显增强。
4.2 把「该不该碰」写成可调参数表
机器人的第二类决策是碰、杠、胡要不要。这部分最好别硬编码在逻辑里,抽成一张参数表,调手感的时候只改数字。
| 参数 | 默认值 | 作用 | 调整方向 |
|---|---|---|---|
PONG_MIN_SHANTEN | 1 | 向听数大于该值才考虑碰 | 调大更保守 |
PONG_MIN_EFFECTIVE | 6 | 碰后有效张低于该值不碰 | 调大更挑剔 |
KONG_RISK_FACTOR | 0.7 | 杠的收益折算系数 | 调小更怕被抢杠 |
HONOR_PRIORITY | 1.2 | 字牌对子的保留权重 | 调大更爱留字牌 |
WIN_ALWAYS | true | 能胡必胡,不设策略性放弃 | 一般不改 |
这几个值没有绝对正确,取决于你想让机器人多强。我一般会先把WIN_ALWAYS打开、把PONG_MIN_SHANTEN调到 2,让机器人打得保守一点——太强的机器人会让新手玩家第一局就被打崩,留存反而差。
4.3 房间状态机与断线重连
房间这块的生命周期是固定的四态:WAITING(等人)→DEALING(发牌)→PLAYING(摸打循环)→SETTLE(结算)。所有状态迁移必须由服务端驱动,客户端只能发起请求,不能自己改状态。这一点在单机模式下可以放宽,但一旦接入服务端,客户端自己切状态就是灾难的开始。
断线重连是回合制游戏绕不开的问题。移动端切后台、锁屏、地铁进隧道,任何一次断连都会让玩家丢一次摸牌。处理方式不复杂,但字段一个都不能少。
// server/ws.js —— 重连时补发全量快照 async function onReconnect(conn, msg) { const room = rooms.get(msg.roomId); if (!room) return conn.send({ type: MSG.SYNC, ok: false, reason: 'ROOM_GONE' }); const seat = room.seats.find(s => s.token === msg.token); if (!seat) return conn.send({ type: MSG.SYNC, ok: false, reason: 'SEAT_LOST' }); seat.conn = conn; // 座位与连接重新绑定 seat.offlineAt = 0; // 关键:无论客户端 lastSeq 是多少,都推全量,别做增量补发 conn.send({ type: MSG.SYNC, seq: room.seq, hands: room.hands.map((h, i) => (i === seat.index ? h : h.length)), discards: room.discards, turn: room.turn, wallLeft: room.wall.length, actions: room.pending, }); }这里有个刻意的取舍:重连时不做增量补发,直接推全量快照。增量补发看起来更省流量,但要维护一个消息缓冲区,还要处理「客户端 seq 比服务端还新」这种异常,代码量翻三倍,bug 也多三倍。一份牌局快照撑死几百个字节,全量推反而更稳。hands那行做了一次脱敏,只把自己那家的手牌发全,别家只发张数,防止有人抓包看穿全场。
注意:
pending字段是当前待响应的碰杠胡询问。漏发这个字段,玩家重连回来会卡在「等待操作」,一直到超时才能继续,体验极差。
5. 避坑与排查:五个让我熬夜的坑
规则和算法这两层,出错时的表现往往很反直觉——报错的地方和真正出问题的地方隔了好几层。下面五条都是实际踩过的,按「现象 → 原因 → 解决」记下来,动手改之前先对一遍。
5.1 牌型判定层的三个坑
坑一:13 张牌被判成胡牌。现象是手里明明还差一张,听牌提示却亮着胡。原因是发牌阶段花牌被塞进了 34 长度的计数数组,花牌本身不参与组合,但把总数凑成了 14 的倍数,sum(cnt) % 3 != 2这道前置过滤就失效了,后面递归恰好拆完返回 true。解决方式是在发牌阶段就把花牌剥离到独立数组并立刻补牌,hand_to_count只接受非花牌的牌值,同时把前置过滤改成「张数恰好等于 3n+2 且不含花牌下标」。
坑二:第一次判胡正确,之后全错。现象是同一副牌连续判两次结果不一样,或者一局里前几巡正常,后面开始乱判。原因是递归拆面子时,某条失败分支忘记把计数数组还原,污染了外层的后续枚举。这类问题极其隐蔽,因为单次调用看起来完全正确。解决有两种,一种是把_meld的所有分支都写成「先扣、递归、必还原」,另一种是干脆在入口处传数组副本。我倾向第一种,但会在_meld出口加一句断言,把所有计数和原始快照对比,跑单测时一旦不相等立刻暴露。
坑三:8 万 9 万 1 条被判成顺子。现象是跨花色的三张牌被当成顺子,胡牌判定直接失效。原因是顺子边界写成了i % 9 <= 7。序数牌每个花色 9 张,下标 6、7、8 对应 7、8、9 万,从 7 开始就没有 8、9 了,写错一个等号就会跨花色。解决是在顺子分支里加花色断言i // 9 == (i + 2) // 9,比靠取模更直观,也不容易再写错。
5.2 运行时与网络层的两个坑
坑四:牌桌越打越卡,一局后半段明显掉帧。现象是开局流畅,几轮之后每次都掉到二三十帧;在开发者工具的性能面板里看,绘制耗时线性上涨。原因是渲染层每帧整桌重绘,包括那些完全不动的牌面和背景。解决是做分层缓存:背景、桌面弃牌区、自己的手牌底图各自做一次离屏绘制,每帧只重绘动画层(出牌飞行、碰杠特效)。改完之后帧率基本稳定,代价是牌面数据变更时要主动让缓存失效,容易漏,建议在状态机的状态迁移处统一打一个dirty标记。
坑五:断线重连回来手牌错位、多一张或少一张。现象是切后台再回来,自己的手牌顺序变了,或者刚摸的牌不见了。原因通常是客户端做了乐观更新,服务端却拒绝了这次出牌,本地没有回滚,随后又被一条SYNC覆盖,导致界面和服务端状态对不上。解决是两条:一是给每条消息带seq,客户端只接受seq更大的消息;二是本地维护一份「最近一次快照 + 尚未确认的本地操作」两份数据,收到拒绝时用快照重建界面。这条规则听起来啰嗦,但它是唯一能覆盖弱网场景的写法。
提示:调试这类问题不要靠肉眼。在
ws.js里加一行日志把每条消息的seq和类型打出来,两边日志按seq对齐,错位一眼就能看出来。
6. 进阶玩法:把番型表配置化,再用牌谱锁死回归
规则层写完之后,改动最频繁的不是胡牌判定,是番型。今天加个「清一色」,明天改成「门清 +1 番」,如果每个番型都是一段if,两个月后这段代码就没人敢动了。我现在的习惯是把番型全部抽成数据,判定函数退化成「遍历规则表求匹配」。
// game/rule/fan.js —— 番型配置驱动,判定逻辑与规则解耦 const FAN_TABLE = [ { id: 'qingyise', name: '清一色', fan: 6, test: (c) => c.suits.size === 1 }, { id: 'qidui', name: '七对', fan: 4, test: (c) => c.isSevenPairs }, { id: 'duanyao', name: '断幺九', fan: 2, test: (c) => c.hasTerminal === false }, { id: 'menqing', name: '门清', fan: 1, test: (c) => c.meldCount === 0 }, ]; function calcFan(ctx) { const hits = FAN_TABLE.filter(r => r.test(ctx)); const total = hits.reduce((s, r) => s + r.fan, 0); // stackable 为 false 的只取最高番,避免叠加失控 return { hits: hits.map(h => h.id), total: Math.min(total, 88) }; }ctx里预置好判定所需的信息,比如suits集合、hasTerminal布尔值、meldCount,这些在胡牌那一步顺手算出来存着,不要在每个test里重算一遍。fan字段是番数,stackable这类互斥规则如果用得上,也在表里加字段,别回到if。改成这个结构之后,加一个番型就是加一行数据,改番数就是改一个数字,测试也可以按id逐条跑。
配置化之后有个新风险:规则表越加越多,互相冲突没人发现。这时就需要牌谱回归。做法很简单,把常见的边界牌型固化成一组用例,每次改完规则跑一遍,输出比对。
// test/fan.spec.js —— 固定牌谱回归 const CASES = [ { name: '清一色+门清', tiles: '1m2m3m4m5m6m7m8m9m1m2m3m5m5m', expect: ['qingyise', 'menqing'] }, { name: '七对不叠加清一色', tiles: '1m1m2m2m3m3m4m4m5m5m6m6m7m7m', expect: ['qidui'] }, { name: '断幺九', tiles: '2m3m4m5m6m7m2s3s4s5s6s7s8p8p', expect: ['duanyao'] }, ]; for (const c of CASES) { const ctx = buildCtx(c.tiles); const got = calcFan(ctx).hits.sort(); console.assert( JSON.stringify(got) === JSON.stringify([...c.expect].sort()), `[FAIL] ${c.name} -> ${got.join(',')}` ); }buildCtx负责把字符串牌谱解析成计数数组再补上上下文,expect里只写期望命中的番型 id,不写番数,这样调整番数不会把用例全冲掉。用例不求多,二三十条覆盖住「跨花色顺子」「绝张听牌」「碰后听牌」「七对含四张」这几个边界就够了。
从那以后我每次动胡牌判定或番型表,都强制先跑一遍这组牌谱再谈改得对不对;靠手点验证的那几年,同一类错位 bug 反复出现,实在不划算。这套源码的rule目录隔离得干净,正好适合这么用,把算法层抠出来单独跑,比在牌桌上试牌快得多。希望帮到你。
本文还有配套的精品资源,点击获取