简介:面向具备一定 Cocos Creator 与 JavaScript 基础、希望研究网络棋牌游戏完整实现路径的开发者,这份达达麻将项目包以可运行工程形式呈现前端玩法、服务端逻辑与数据存储方案。压缩包约 51.62MB,共约 2000 个文件,以 json 配置、js 脚本、meta 资源描述和 png 图像为主,同时包含 prefab、anim、mp3 等场景与音效资源,以及 md 说明、sql 脚本和少量原生 SDK 相关源码,便于按模块梳理工程结构。已有 3264 人学习,适合对照理解麻将规则、用户交互、状态管理、Node.js 匹配与数据同步、MySQL 玩家信息存储等关键环节,也能为上线前的安全、性能与兼容性测试提供参考。
1. 从一副牌说起:这套 Cocos Creator 麻将工程到底能跑出什么
很多人拿到一套麻将棋牌工程,第一反应是打开assets目录翻预制体,看大厅长什么样、按钮怎么排。真跑起来才发现卡住的不是 UI,而是手里那 13 张牌该不该胡。这套基于 Cocos Creator 的「达达麻将」棋牌工程,难点恰好就压在这张 34 个元素的计数表上:牌的编码怎么定、胡牌怎么判定、听哪几张、服务端怎么保证四个人看到的是同一副牌。
它给的是一个完整对局闭环——登录、建房或匹配、发牌、出牌碰杠、胡牌结算、牌局回放,前端用 Cocos Creator 加 TypeScript,逻辑层刻意和引擎解耦。适合两类人:一类是想拿一套能跑通的棋牌骨架做二次开发、换皮改玩法的;另一类是想看休闲棋牌类项目里,状态机、帧同步、断线重连这些常规套路具体怎么落地。下面按「先看骨架、再拆算法、最后讲改造」的顺序往下走。
2. 工程骨架与技术选型:从 assets 目录看这套棋牌项目的分层
打开工程,assets下面的目录名基本就说明了作者的分层意图。真正值得看的不是预制体有多少个,而是core这一层有没有被独立出来。棋牌类项目和普通 RPG 最大的区别在于:它的规则是封闭且可穷举的,一副牌 136 张,所有可能的牌型判定都能在毫秒级算完,这意味着核心逻辑完全可以脱离渲染引擎单独跑测试。
2.1 为什么把规则层从引擎里剥出来
先把结论摆出来:规则代码一旦import { _decorator } from 'cc',这条线就废了。Cocos Creator 的组件类依赖引擎运行时,在 Node 环境里根本 import 不进来,你没法给胡牌判定写单测,也没法让服务端复用同一份逻辑去校验客户端上报的胡牌请求。血泪经验是,先把规则层拆出来,后面服务端校验、回放复现、批量跑牌局,全都能省掉大量重复劳动。
常见做法是把assets/scripts拆成四层,依赖方向严格单向:ui → game → net → core,core谁也不依赖。下面这个结构是我一般会照着摆的:
assets/ scripts/ core/ # 纯规则:牌编码、胡牌、番型、计分。零引擎依赖 tile.ts hu.ts fan.ts rng.ts net/ # 协议、WebSocket 封装、消息编解码 protocol.ts socket.ts reconnect.ts game/ # 对局控制器、状态机、回放播放器 roomController.ts replayPlayer.ts ui/ # 预制体脚本、面板、弹窗 hall/ table/ boot/ # 启动入口、资源预加载、热更新检查 resources/ config/ # 番型表、房间配置、牌桌参数,JSON prefabs/ textures/core里的文件全部是纯函数加普通类,导出的是isWinHand(counts)这种签名,没有任何cc的东西。这样一来,同一个hu.ts文件可以原样拷到 Node 服务端工程里,客户端算一遍给提示,服务端再算一遍做权威校验,两边结果一致才落账。做棋牌项目,这个「两端同源」的设计能挡掉后面一大半的扯皮。
2.2 启动流程与初始化顺序
棋牌项目的启动链路比一般小游戏长,因为它要等资源、等配置、等网络握手,三步全绿才能进大厅。顺序错了就会看到白屏或者空列表。这套工程的入口脚本大致是这么组织的:
// assets/scripts/boot/GameEntry.ts import { _decorator, Component, resources, JsonAsset } from 'cc'; import { Socket } from '../net/socket'; import { ConfigTable } from '../core/config'; import { UIManager } from '../ui/UIManager'; const { ccclass } = _decorator; @ccclass('GameEntry') export class GameEntry extends Component { async start() { // 1. 先加载配置表,规则层要靠它算番、算分 const cfg = await this.loadConfig('config/game_config'); ConfigTable.init(cfg.json as any); // 2. 再建立连接,握手拿玩家信息和重连令牌 await Socket.instance.connect(); // 3. 最后预加载大厅常用预制体,避免点进去再卡一下 await UIManager.preload(['prefabs/hall/HallPanel']); UIManager.open('prefabs/hall/HallPanel'); } private loadConfig(path: string): Promise<JsonAsset> { return new Promise((resolve, reject) => { // resources.load 的路径不带扩展名,大小写敏感,真机上写错就是 404 resources.load(path, JsonAsset, (err, asset) => { if (err) return reject(err); resolve(asset); }); }); } }这段代码里三个await的顺序不是随便排的。配置表要最先到,因为后面任何一次服务端消息回来都可能触发算分,配置没就位就是空指针;网络握手排第二,握手包里带着重连令牌,如果玩家是断线回来的,服务端会顺手把房间快照塞在这个响应里,客户端拿到就能直接跳回牌桌而不是先进大厅;预制体预加载排最后,它是纯体验优化,失败了也不该阻塞主流程,实际项目里我会给它套一层超时,超时就跳过。参数上resources.load的第一个入参是相对resources目录的路径、不带后缀,这一点在编辑器里跑感觉不到,打包到原生平台后大小写不一致会直接读不到,属于典型的上线才炸的问题。
网络层建议统一成一个Socket单例,内部维护seq自增的消息序号和一张pending回调表,请求响应按序号配对。这样上层写起来就是await Socket.instance.request('room/create', params),不用每个面板自己写一遍on/off。重连逻辑也收在这一层,别散到各个 UI 里,否则以后加一个玩法就要改一遍重连分支。
3. 核心算法:34 张牌的编码、胡牌判定与听牌计算
规则的起点是编码。麻将 136 张牌去重后是 34 种,万条筒各 9 种,字牌 7 种。用0-8表示万、9-17表示条、18-26表示筒、27-33表示东南西北中发白。这个编码方式的好处是花色和点数都能算出来,顺子判定只看rank <= 6,字牌判定只看>= 27,不需要任何查表。
3.1 手牌数据结构与牌的编码
手牌不要用数组存牌对象,用长度 34 的计数数组。摸一张counts[t]++,打一张counts[t]--,判胡直接把这个数组丢进算法。对象数组每次判胡都要先排序去重,性能差一个数量级,而且序列化传输体积也大。
// assets/scripts/core/tile.ts export const TILE_KINDS = 34; /** 花色:0 万 1 条 2 筒 3 字 */ export function suitOf(t: number): number { if (t < 9) return 0; if (t < 18) return 1; if (t < 27) return 2; return 3; } /** 点数:万条筒返回 0-8,字牌返回 0-6 */ export function rankOf(t: number): number { return t < 27 ? t % 9 : t - 27; } /** 是否字牌,字牌不能组顺子 */ export function isHonor(t: number): boolean { return t >= 27; } /** 手牌数组转计数数组,同时做一次合法性校验 */ export function toCounts(tiles: number[]): number[] { const counts = new Array(TILE_KINDS).fill(0); for (const t of tiles) { if (t < 0 || t >= TILE_KINDS) throw new Error(`非法牌编码: ${t}`); counts[t]++; // 同一种牌最多 4 张,超过说明是作弊包或者逻辑 bug if (counts[t] > 4) throw new Error(`牌数量异常: ${t}`); } return counts; }toCounts里那个counts[t] > 4的校验别省。客户端上报的胡牌请求、服务端转发的碰杠消息,都可能被篡改或因为并发出问题,在入口处挡掉比在算法深处抛异常好排查得多。牌编码的下标越界同理,直接抛错。
面子(顺子/刻子)的表示,我一般用两个字段:type: 'shun' | 'ke'加起始牌编码。碰杠产生的副露单独存一个数组,判胡的时候只需要判断「手牌部分能不能凑成整数个面子加一对将」,副露的面子数直接减掉即可,不用混在一起算。
3.2 胡牌判定的递归拆解
标准胡型是3n + 2结构:若干个面子加一对将。算法的思路很直接——先枚举哪一种牌当将,把它扣掉,然后递归检查剩下的能不能全部拆成面子。拆的时候永远从当前剩余的最小牌开始,因为顺子必须连续,最小那张牌只有两种归宿:当刻子,或者作为顺子的起点。这一个剪枝就让递归深度可控。
// assets/scripts/core/hu.ts import { TILE_KINDS, isHonor, rankOf } from './tile'; /** * 判断 counts 能否恰好拆成 need 个面子 * start 是搜索起点,避免重复枚举 */ function canFormMelds(counts: number[], start: number, need: number): boolean { if (need === 0) return true; // 找到第一张还有剩余的牌,它就是本次必须处理的最小牌 let i = start; while (i < TILE_KINDS && counts[i] === 0) i++; if (i >= TILE_KINDS) return false; // 分支一:当成刻子 if (counts[i] >= 3) { counts[i] -= 3; if (canFormMelds(counts, i, need - 1)) { counts[i] += 3; return true; } counts[i] += 3; } // 分支二:当成顺子起点。字牌不能成顺,点数 > 6 也接不上 if (!isHonor(i) && rankOf(i) <= 6 && counts[i + 1] > 0 && counts[i + 2] > 0) { counts[i]--; counts[i + 1]--; counts[i + 2]--; if (canFormMelds(counts, i, need - 1)) { counts[i]++; counts[i + 1]++; counts[i + 2]++; return true; } counts[i]++; counts[i + 1]++; counts[i + 2]++; } return false; } /** 七对:14 张,恰好 7 个对子 */ function isSevenPairs(counts: number[]): boolean { let pairs = 0; for (let i = 0; i < TILE_KINDS; i++) { if (counts[i] === 0) continue; if (counts[i] !== 2) return false; // 四张的情况视规则算龙七对,这里单独走番型 pairs++; } return pairs === 7; } /** 十三幺:13 种幺牌各一张,其中一种成对 */ const YAO = [0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33]; function isThirteenOrphans(counts: number[]): boolean { let pair = 0; for (const t of YAO) { if (counts[t] === 0) return false; if (counts[t] === 2) pair++; else if (counts[t] !== 1) return false; } return pair === 1; } /** 主入口:counts 为手牌计数,melds 为已副露的面子数 */ export function isWinHand(counts: number[], melds: number = 0): boolean { const total = counts.reduce((a, b) => a + b, 0); // 手牌张数必须满足 3n+2 if (total % 3 !== 2) return false; // 特殊牌型优先判,它们不走常规拆解 if (melds === 0 && total === 14 && isSevenPairs(counts)) return true; if (melds === 0 && total === 14 && isThirteenOrphans(counts)) return true; const need = (total - 2) / 3; // 枚举将牌,扣掉之后看剩下的能否全拆成面子 for (let i = 0; i < TILE_KINDS; i++) { if (counts[i] < 2) continue; counts[i] -= 2; const ok = canFormMelds(counts, 0, need); counts[i] += 2; if (ok) return true; } return false; }几个参数要说清楚。counts是 34 长度数组,函数内部会临时修改再还原,所以调用方传进去的数组不会被污染;melds是已经碰、杠出来的面子数,因为副露的牌不在手上,得把总面子需求减掉,杠算一个面子但占 4 张牌,这部分的账要在调用前算好。canFormMelds的start参数一开始传 0,递归时传当前的i,因为比i小的牌都已经处理干净了,回头再搜纯属浪费。
复杂度方面,常规胡型最多也就几十次递归,单次判定在微秒级。但如果要做「听牌提示」——把 34 种牌轮流加进手牌各判一次——一次出牌提示就是 34 次判定,四个玩家轮着来也在毫秒级,完全撑得住。真正需要担心的是暴力穷举所有牌型做测试的时候,那时候得换个思路。
提示:七对和十三幺建议在番型表里做成开关,不同地区规则差异很大,有些玩法不认七对,有些四张算龙七对要额外加番。硬编码进判定函数,后面改规则就要动核心算法。
3.3 听牌计算与出牌建议
听牌是胡牌判定的反向用法:当前手牌 13 张,往counts里加一张候选牌,如果isWinHand返回真,这张就是听口。34 种牌全试一遍,把听口收集起来,同时能算出剩余张数——用 4 减去自己手上和桌面上已经出现的数量,这个数字直接决定要不要改听。
// assets/scripts/core/ting.ts import { TILE_KINDS } from './tile'; import { isWinHand } from './hu'; export interface TingResult { tiles: number[]; // 听哪些牌 remain: number; // 理论剩余张数(不含桌面已现牌) } /** counts: 当前 13 张手牌;visible: 桌面上所有已打出的牌计数 */ export function calcTing(counts: number[], visible: number[], melds: number): TingResult { const tiles: number[] = []; let remain = 0; for (let t = 0; t < TILE_KINDS; t++) { if (counts[t] >= 4) continue; // 自己已经四张,不可能再摸到 counts[t]++; const win = isWinHand(counts, melds); counts[t]--; if (!win) continue; tiles.push(t); // 4 张减去自己手上的和场上已经现过的 remain += 4 - counts[t] - (visible[t] || 0); } return { tiles, remain }; }visible这个参数容易被忽略,但它直接影响提示质量。只告诉玩家「听二五万」没用,得告诉他「二万还剩 3 张、五万只剩 1 张」,玩家才知道该不该搏。实际项目里我会在每次出牌、碰杠后增量更新这张可见牌表,而不是每次提示都从消息流里重算。
出牌建议是另一回事,听口多不代表好胡。常见做法是给每个候选出牌算一个权重:听口数量、剩余张数、番型期望值,三项加权排序。番型期望值要从配置表里读,别写死,换皮换玩法的时候这张表是必改项。
4. 对局流程:房间状态机、断线重连与牌局回放
规则算得再准,联机对局一旦出现「我这边显示胡了、他那边显示没胡」,这局就得人工介入。棋牌项目的联机部分,核心原则只有一条:服务端权威,客户端只做表现和预测,任何结算数字以服务端下发的为准。
4.1 服务端权威的房间状态机
一个房间的生命周期就那么几个状态,把它显式定义出来,比用一堆布尔标志位堆叠要靠谱得多。状态机的好处是所有非法流转在入口就被拒掉,比如结算中又来一条出牌消息,直接丢弃并记日志。
// assets/scripts/game/roomController.ts(服务端同一份逻辑可直接复用) export enum RoomState { Idle = 0, // 空闲,等人或等开局 Dealing = 1, // 发牌中,期间不接受任何操作 Playing = 2, // 对局中 Settle = 3, // 结算中,只允许查询 } export interface PlayerAction { seat: number; type: 'discard' | 'peng' | 'gang' | 'hu' | 'pass'; tile?: number; seq: number; // 客户端本地序号,用于去重 } export class RoomController { private state: RoomState = RoomState.Idle; private turn = 0; // 当前该谁出牌 private seqCounter = 0; // 服务端全局操作序号 private lastSeqOfSeat: number[] = [0, 0, 0, 0]; /** 统一的动作入口,所有合法性检查在这里做完 */ public onAction(a: PlayerAction): boolean { if (this.state !== RoomState.Playing) return false; if (a.seat !== this.turn) return false; // 幂等:同一条消息重发两次只处理一次,弱网下很常见 if (a.seq <= this.lastSeqOfSeat[a.seat]) return false; this.lastSeqOfSeat[a.seat] = a.seq; this.seqCounter++; this.apply(a); // 改状态 this.broadcast(a); // 广播给四个座位 return true; } }seq这个字段看着多余,其实是弱网环境下的后悔药。移动网络下客户端重发消息很常见,没有幂等保护,一次出牌可能被处理两次,牌桌上就少了一张牌。服务端给每个座位维护一个已处理的最大序号,小于等于它的直接丢。state的检查放在最前面,发牌动画期间玩家手快点了出牌,这里就能挡住。
回合推进我建议做成显式的nextTurn(),而不是每个分支里各写一遍turn = (turn + 1) % 4。牌局里有碰、杠、胡这些会打断顺序的插入操作,分散写迟早出错。碰牌之后出牌人变成碰牌的人,杠之后要补牌,这些规则集中在一个函数里,改起来才有底。
4.2 断线重连:快照加增量
重连是棋牌项目最容易翻车的地方。玩家地铁进隧道断个十几秒,回来发现手牌对不上、牌墙少了几张、轮到自己出牌却点不动。根因基本都是重连时只拉了部分状态,或者客户端本地状态和服务端已经分叉。
我的做法是两级:服务端在每次状态变更后维护一份完整快照(手牌、副露、牌墙剩余、当前轮次、各座位分数),客户端重连时带上自己最后收到的seq,服务端返回「完整快照 + 该seq之后的所有操作」。客户端清空本地牌桌,先按快照重建,再按顺序重放增量操作。
// assets/scripts/net/reconnect.ts export async function doReconnect(lastSeq: number): Promise<void> { // 1. 带上重连令牌和本地最后序号 const res = await Socket.instance.request('room/reconnect', { lastSeq }); if (res.code !== 0) { // 房间已解散或超时被踢,回大厅,别在原地死等 UIManager.open('prefabs/hall/HallPanel'); return; } // 2. 先按快照整体重建牌桌,不做任何动画 TableModel.instance.restoreFromSnapshot(res.snapshot); // 3. 再按序重放快照之后的增量操作,重放期间屏蔽玩家输入 TableModel.instance.setInputEnabled(false); for (const op of res.ops) { TableModel.instance.applyRemoteOp(op, { silent: true }); } TableModel.instance.setInputEnabled(true); }关键在第三步的silent选项:重放时不要播动画、不要弹「碰!」的音效,否则玩家一回来就是一堆特效乱飞。重放结束再统一刷新一次 UI,视觉上就是「画面一闪,牌桌恢复」。另外setInputEnabled(false)也别省,重放期间玩家点出牌,本地状态和服务端会立刻分叉,后面就再也对不上了。
快照的粒度要权衡。全量快照几百字节,四个玩家、每秒几次状态变更,对服务端内存完全没压力,没必要为了省这点带宽搞复杂的增量合并。真正需要精简的是观战和回放场景,那是另一个话题。
4.3 回放:种子加操作流
牌局回放,很多工程的做法是录视频或者录每一帧的完整状态,前者文件大、后者数据量爆炸。正确做法是只存两样东西:洗牌用的随机种子,以及整局的操作序列。麻将的随机性只在洗牌那一次,之后所有牌的位置都是确定的,有了种子就能完整复现牌墙。
// assets/scripts/core/rng.ts /** 可复现的伪随机数,同一个 seed 永远产出同一副牌墙 */ export function mulberry32(seed: number) { return function (): number { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** 洗牌:Fisher-Yates,用上面的 rng 替换 Math.random */ export function shuffleWall(seed: number): number[] { const wall: number[] = []; for (let t = 0; t < 34; t++) { for (let k = 0; k < 4; k++) wall.push(t); } const rnd = mulberry32(seed); for (let i = wall.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [wall[i], wall[j]] = [wall[j], wall[i]]; } return wall; }mulberry32是个短小的 32 位伪随机实现,同一个 seed 在客户端和服务端产出的序列完全一致,这点很重要——服务端存下seed,客户端播回放时用同一个函数重新生成牌墙,两边对得上。整局回放的数据量就是「seed + 几十条操作」,几十 KB 都不到,存数据库毫无压力。
回放的播放器本质上就是RoomController的只读版本:按操作序列逐条 apply,中间加延时和动画。这套东西还能反过来当调试工具用——线上反馈「这局结算有问题」,把 seed 和操作流拉下来本地重放一遍,比看日志快得多。
5. 避坑与排查:上线前最容易翻车的几个地方
5.1 资源与包体相关的坑
现象:编辑器里跑得好好的,打真机包或者小游戏包之后,牌面全是白块,控制台报资源加载失败。
原因:resources.load的路径在原生平台和小游戏平台是大小写敏感、且不带扩展名的,编辑器环境宽松得多。另外牌面碎图如果放在图集之外,打包时会被剔除掉。
解决:所有动态加载路径统一走常量表,别在业务代码里手写字符串;牌面、按钮这些高频小图强制打进自动图集,加载时按图集名 + 精灵帧名取,不要按单张图取。上线前一定要打一次真机包走一遍完整对局,别只在浏览器预览里验。
现象:首屏加载慢,小游戏平台包体超限。
原因:麻将项目的美术资源集中在牌面、桌布、音效三块,很多人把四套不同风格的牌面全打进了主包。
解决:牌桌场景做成独立分包,大厅只留必要的 UI 图;牌面纹理用 512 或 1024 的单张图集,别用 2048 的大图;音效走远程加载或者按玩法分包,别一股脑塞主包。资源清单里定期扫一遍没被引用的图,这类项目做两轮换皮之后冗余资源能占到三成。
5.2 逻辑与联机相关的坑
现象:玩家胡了一把七对,客户端显示胡了,结算时服务端判定不胡,直接判负。
原因:客户端和服务端各写了一份胡牌逻辑,两边的特殊牌型开关不一致——客户端开了七对,服务端配置表里没开,或者服务端那份代码是早期的老版本。
解决:规则代码只留一份,放在不依赖引擎的core目录里,服务端构建时直接引这个目录,通过构建脚本同步,禁止各改各的。上线前用同一批牌局数据在两端各跑一遍,比对结果。
现象:弱网重连后手牌数量对不上,或者轮到自己出牌点不动。
原因:重连时只恢复了手牌,没恢复当前轮次和牌墙位置;或者重放增量操作时玩家点了按钮,本地又发了一次请求。
解决:重连必须走「全量快照 + 增量重放」的完整流程,重放期间锁输入,重放完再刷一次 UI。重连成功后客户端把本地seq和服务端返回的seq对一次,不一致就以服务端为准,把本地状态整个丢掉重建。
现象:偶发出现同一张牌被打了两次,或者牌墙少一张。
原因:客户端消息重发没有幂等保护,服务端把同一条出牌请求处理了两次。移动网络下这个概率比想象中高。
解决:所有上行操作带客户端自增seq,服务端按座位维护已处理的最大序号,小于等于的直接丢弃并记一条日志。这条日志上线后特别有用,能直接看出弱网比例。
现象:上线后收到反馈说某局结算分数不对,但复现不了。
原因:没有留存牌局数据,只能靠玩家描述,而玩家描述基本不可靠。
解决:每局存seed + 操作序列 + 结算结果,出了问题本地重放。这套数据顺带还能做运营侧的牌局分析,比如统计各番型出现频率、平均时长,用来调房间配置。
6. 二次开发:换皮、加玩法与一套可复现的验证流程
先讲换皮,这是接手这类工程最常见的需求。改的地方其实很集中:assets/resources/textures下的牌面图集、桌布、按钮三张图,加上config里的房间参数和番型表,最后是ui层里散落的硬编码文案。血泪经验是,先把所有中文文案抽到一张strings.json里再动手,否则换到一半发现某个弹窗的标题写死在预制体上,得一个个翻。
改玩法比换皮麻烦,但套路是固定的。比如要加一种新番型「清一色」,正确的位置是在core/fan.ts里加一个纯函数:
// assets/scripts/core/fan.ts import { suitOf } from './tile'; /** 清一色:所有牌(含副露)同一花色,字牌不算 */ export function isPureSuit(handCounts: number[], meldSuits: number[]): boolean { const suits = new Set<number>(); for (let t = 0; t < 34; t++) { if (handCounts[t] > 0) suits.add(suitOf(t)); } for (const s of meldSuits) suits.add(s); // 只有一种花色,且不是字牌 return suits.size === 1 && !suits.has(3); }然后在番型配置表里加一行,指定番数和触发条件,结算时按顺序遍历所有番型累加。不要在结算函数里写一堆if,番型表是可配置的,玩家端和运营端都靠它,写死了以后加番就得改代码重新发版。
验证这块,我的习惯是准备一个 Node 脚本,直接把core目录拷过去跑:
# 把纯规则层拷到临时目录,用 ts-node 直接跑批量测试 mkdir -p /tmp/mj-test && cp -r assets/scripts/core /tmp/mj-test/ cd /tmp/mj-test && npx ts-node bench.tsbench.ts里做三件事:随机生成一万手牌跑isWinHand统计耗时和胡牌率、穷举所有 13 张手牌组合验证听牌计算结果、拿历史牌局数据重放比对结算分数。第二项尤其重要——听牌算错一个口,玩家立刻就能发现,但你可能要等好几天才收到反馈。
最后说个我自己的教训。以前接手一个棋牌项目,改完牌型判定直接打包上传,觉得单测跑过了就没事。结果真机上七对可以胡,但结算分数永远是零,查了两天才发现番型表里七对的enabled字段被我顺手改成了字符串"true",运行时的类型判断走了另一条分支。从那以后我每次改完配置表,都强制走一遍「JSON schema 校验 + 一局完整对局 + 结算数值人工核对」这三步,一步都不跳。这套工程的配置项不少,你也别赌运气,跑起来看一眼结算面板的数字对不对,比什么都实在。希望帮到你。
本文还有配套的精品资源,点击获取