简介《cocos creator达达麻将棋牌游戏》是一套面向棋牌游戏开发者、Cocos Creator学习者及需要完整前后端案例团队的项目源码围绕麻将规则实现、用户交互、状态管理、Node.js服务端登录匹配与MySQL数据存储展开可用于学习网络棋牌从客户端到服务端的组织方式。压缩包共2000个文件以JSON配置、JS脚本、meta资源索引、PNG素材、Markdown文档、mp3音频及Prefab/Anim等为主整体约51.62MB目录中包含客户端资源、服务端代码与数据库相关文件。已有3264人学习下载说明其在棋牌项目实战参考方面具有一定关注度。读者可据此梳理Cocos Creator场景与脚本分工、前后端通信和玩家积分数据表设计并参考上线运营所需的安全、性能优化与兼容性测试思路适合作为完整项目拆解与二次开发底稿。1. 一套能直接跑起来的 Cocos Creator 麻将棋牌游戏工程第一件事不是翻代码压缩包解开是个能直接跑的 Cocos Creator 麻将棋牌游戏工程牌桌、发牌、吃碰杠胡、房间匹配、结算面板都在里面打开主场景点预览就能坐上四个人打一圈。这类资源最容易踩的误区是从第一个脚本开始读——两百多个文件翻完你还是不知道改动该落在哪一层。我的习惯反过来先跑通一局把洗牌、发牌、出牌、判定、结算这五件事在控制台日志里从头跟一遍再顺着日志回去找对应脚本半小时就能画出自己的心智地图。这套工程对两类人比较友好想快速摸清棋牌品类工程结构的 Creator 开发者以及接了二次开发、要在原有玩法上换皮加规则的小团队。它把规则层和表现层切开了这一点直接决定了你能把它改成什么样后面几章都围绕这个划分展开。2. 工程骨架与牌数据模型34 种牌面怎么落成可序列化的结构刚拿到工程先别管牌桌怎么画先搞清楚数据长什么样。棋牌类项目和普通休闲游戏最大的差别是它的正确性可以被数学证明也可以被数学证伪。牌墙是不是 136 张、一手牌能不能胡、听哪几张这些都不是看起来对就行的东西。所以这一章先把骨架和数据结构定死后面所有逻辑都建立在这上面。2.1 目录骨架规则层必须能脱离引擎单独跑按 Creator 3.x 的组织方式这套工程的 assets 下面大致分成三块scenes放登录、大厅、牌桌、结算四个场景scripts/game放表现层跟节点、动画、音效绑定scripts/core放规则层只处理数字和数组。真正值钱的是第三块因为它在整个项目里最不该被引擎污染。判断一个棋牌工程写得好不好我一般看一个指标core目录下的文件能不能直接被 Node 加载执行。如果能说明规则层没有import { _decorator } from cc没有Node、没有Sprite、没有director纯 TS 加数组运算如果不能说明规则和表现搅在一起了后面想写自动化测试、想在服务端复用同一份判定逻辑全得重写。实际拆的时候core里通常这么分tile.ts管牌面编码wall.ts管牌墙与洗牌hand.ts管手牌增删和排序rule.ts管胡牌、听牌、番型fsm.ts管一局的状态机。表现层只做一件事监听状态机抛出的事件把事件翻译成动画。发牌事件来了就飞牌出牌事件来了就落桌别的什么都不算。这条边界一旦守住后面两个好处会立刻体现出来一是可以写一个 Node 脚本把线上牌谱灌进去跑回归几秒钟跑完十万局二是断线重连时客户端可以快进重演因为重演用的就是同一套纯函数不需要加载任何预制体。2.2 牌 ID 编码为什么用 0–33 而不是一万这种字符串牌面表示是第一个要拍板的事。见过用字符串1万、东当 key 的工程跑起来没问题但一做排序就难受一做网络传输就膨胀一做位运算就彻底没戏。这套工程用的是整数编码34 种牌面0 到 33每种 4 张副本合计 136 张。编码区间花色rank 计算方式备注0–8万kind % 9 1序数牌可组顺子9–17条kind % 9 1序数牌可组顺子18–26筒kind % 9 1序数牌可组顺子27–33字牌kind - 26东南西北中发白不可组顺子// core/tile.ts —— 牌面编码约定全工程唯一的真相来源 export const TILE_KINDS 34; /** 花色0万 1条 2筒 3字 */ export function suitOf(kind: number): number { return kind 27 ? Math.floor(kind / 9) : 3; } /** 点数序数牌 1..9字牌 1..7 */ export function rankOf(kind: number): number { return kind 27 ? (kind % 9) 1 : kind - 26; } /** 是否序数牌顺子只可能出现在序数牌里这个判断是后面剪枝的关键 */ export function isSuited(kind: number): boolean { return kind 27; }这段代码本身没什么难度值钱的是它带来的三个便利。第一排序直接a - b就完了不用写比较器手牌天然按万条筒字排好。第二网络传输一个字节就能表示一张牌杠上开花时一次广播十几张牌也不心疼流量。第三判定顺子时只需要判断kind % 9 6因为编码本身就保证了同一花色的牌是连续的九个整数跨花色根本凑不出连续的三个。手牌本身不建议用number[]存用长度 34 的计数数组更划算counts[k]表示第 k 种牌有几张取值 0 到 4。删除一张就是counts[k]--判断有没有就是counts[k] 0判断刻子就是counts[k] 3。胡牌判定里要反复做这几件事用计数数组比用数组查找快一个数量级代码也短。2.3 牌墙生成与可复现洗牌种子比随机数重要发牌这件事有个隐藏要求同一个种子必须洗出同一副牌墙。原因不复杂——断线重连要把玩家送回原来的牌局牌谱回放要让客服能复现玩家投诉的那一手自动化测试要能拿固定牌型去验证胡牌算法。这三件事全都依赖洗牌可复现而Math.random()一个都满足不了。// core/wall.ts —— 牌墙生成 种子洗牌 import { TILE_KINDS } from ./tile; /** xorshift32够快、够散而且状态就是一个 32 位整数方便序列化进快照 */ export class SeededRandom { private state: number; constructor(seed: number) { this.state (seed 0) || 0x9e3779b9; // seed 为 0 时退化必须兜底 } next(): number { let x this.state; x ^ x 13; x 0; x ^ x 17; x ^ x 5; x 0; this.state x; return x / 0x100000000; } } /** 生成一副 136 张的牌墙元素是 0..33 的牌面编码 */ export function buildWall(): number[] { const wall: number[] []; for (let k 0; k TILE_KINDS; k) { for (let c 0; c 4; c) wall.push(k); } return wall; } /** Fisher-Yates从后往前换保证每种排列等概率 */ export function shuffleWall(wall: number[], rng: SeededRandom): void { for (let i wall.length - 1; i 0; i--) { const j Math.floor(rng.next() * (i 1)); const tmp wall[i]; wall[i] wall[j]; wall[j] tmp; } }xorshift32选它的理由不是性能是可序列化整个随机状态就是一个数字塞进重连快照里只用四个字节客户端拿到之后接着往下洗结果和服务端完全一致。用Math.random()就做不到这一点你只能把整副牌墙的剩余部分打包下发136 张牌多的时候几百字节看着不多但每局都要传一次就难受了。洗牌算法本身也有讲究。早期见过有人写wall.sort(() Math.random() - 0.5)这行代码在 V8 里不是等概率的——比较函数返回值不稳定时排序结果会偏向原始顺序表现就是感觉好牌总在固定几个位置。Fisher-Yates 从后往前遍历、每次和[0, i]区间内随机一个位置交换才是数学上严格的等概率洗牌代码还更短。牌墙生成之后不要立刻把 136 张全发给四个人。标准流程是庄家 14 张其余三家各 13 张剩下 83 张留在牌墙里按顺序摸。这套工程把摸牌指针单独存成一个整数wallCursor摸一张就wall[wallCursor]比每次shift()好——shift()是 O(n)一局下来累积的开销在低端机上会有感知。3. 胡牌判定与听牌递归拆解能不能扛住一局七十次调用规则层里最容易被写错、也最容易被写得性能不够的就是胡牌判定。它被调用的频率比想象中高得多玩家每次出牌要判一次自己是否听牌收到别人打的牌要判一次能不能胡杠牌要判一次抢杠结算要判一次番型一局下来七十次往上很正常。写得好这段代码全程不到 1 毫秒写得随意就是那种平时没事一到残局四家都在算听牌就掉帧的玄学。3.1 先算复杂度账为什么纯暴力枚举会翻车最朴素的思路是把 14 张手牌做全排列然后每三张一组检查是不是面子最后两张检查是不是将。14 张牌的全排列是 14!约 870 亿种就算加上去重也还是天文数字。这条路走不通问题不在计算机慢在于枚举了大量本质相同的组合——[1万,2万,3万]和[3万,1万,2万]在手里是完全一样的东西枚举它们纯属浪费。正确做法是按牌面从小到大拆每次只处理当前最小的那张牌。这张牌要么出现在刻子里三张一样要么出现在顺子里连同后面两张相邻的序数牌要么它是将牌的一部分。三种可能对应三个分支处理完就把这几张牌从计数里扣掉递归处理剩下的。因为每张牌最多四张每次递归至少消耗两张牌将或三张牌面子递归深度不超过 5分支数在实际牌型下也很有限。实测下来一次判定在普通手机上大约 20 到 60 微秒也就是说一局七十次判定的总耗时不到 5 毫秒。这个量级完全不需要上查表或者预计算除非你想做 AI 出牌推荐——那种场景要枚举所有可能的出牌组合才需要另想办法。3.2 一套带剪枝的递归判定// core/rule.ts —— 胡牌判定递归拆解计数数组原地回滚 /** * 判定 counts 能否拆成 need 个面子刻子或顺子 * counts 长度 34元素 0..4调用期间会被临时修改返回前必定还原 */ function canFormMelds(counts: number[], need: number): boolean { // 找当前还有剩余的最小牌面 let i 0; while (i 34 counts[i] 0) i; if (i 34) return need 0; // 牌用完了面子够不够就是答案 // 分支一刻子 if (counts[i] 3) { counts[i] - 3; const ok canFormMelds(counts, need - 1); counts[i] 3; if (ok) return true; } // 分支二顺子。只有序数牌能组且 i%9 6 保证不跨花色 if (i 27 i % 9 6 counts[i 1] 0 counts[i 2] 0) { counts[i]--; counts[i 1]--; counts[i 2]--; const ok canFormMelds(counts, need - 1); counts[i]; counts[i 1]; counts[i 2]; if (ok) return true; } // 两个分支都不成立说明这张最小的牌无处安放 return false; } /** 标准胡型4 个面子 1 个将 */ export function isStandardWin(counts: number[]): boolean { for (let i 0; i 34; i) { if (counts[i] 2) continue; counts[i] - 2; // 先定将 const ok canFormMelds(counts, 4); counts[i] 2; // 无论成败都还原 if (ok) return true; } return false; } /** 七对部分玩法才有用开关控制别硬编码进主流程 */ export function isSevenPairs(counts: number[]): boolean { let pairs 0; for (let i 0; i 34; i) { if (counts[i] % 2 ! 0) return false; pairs counts[i] / 2; } return pairs 7; }三个细节值得说。第一canFormMelds每次只处理最小牌这个顺序天然去重——同一组面子不会被拆成不同顺序重复计算剪枝效果比随机挑牌好得多。第二所有减法都配了对应的加法还原这一点非常关键counts是全局共享的手牌引用一旦某条分支提前 return 忘了还原后面所有判定都会带着脏数据跑出现明明没胡却提示胡了这类血泪问题。第三i % 9 6这行判断省不掉虽然编码保证了同花色连续但 8 号9万和 10 号1条在整数上也是相邻的不加这个判断就会拆出9万1条2条这种不存在的顺子。规则开关建议做成配置对象而不是写死。七对、十三幺、清一色加番、是否允许吃、是否只能自摸这些在不同玩法里差异很大接二次开发时十有八九要改。做成ruleSet: { sevenPairs: boolean, thirteenOrphans: boolean, ... }传进判定函数改玩法时只动配置不动算法。3.3 听牌提示把 34 次判定压成一次遍历听牌的计算很直接往手牌里加一张看能不能胡能胡这张就是听的牌。34 种牌面全试一遍最多 34 次判定也就是 1 到 2 毫秒完全可以放在出牌后的下一帧算。// core/rule.ts —— 听牌列表同时把还剩几张一起算出来 export function getTingTiles( counts: number[], ruleSet: { sevenPairs: boolean }, remain: number[] // remain[k] 表示第 k 种牌还有几张没露面 ): Array{ kind: number; left: number } { const result: Array{ kind: number; left: number } []; for (let k 0; k 34; k) { if (remain[k] 0) continue; // 四张全在桌上听了也是死听直接跳过 counts[k]; const win isStandardWin(counts) || (ruleSet.sevenPairs isSevenPairs(counts)); counts[k]--; if (win) result.push({ kind: k, left: remain[k] }); } return result; }remain这个数组容易被忽略但它是听牌提示能不能用的分水岭。玩家看到听 3 万第一个反应是还剩几张如果你只告诉他听什么不告诉他剩几张他会自己去数弃牌堆体验就断了。remain[k] 4 - 自己手上的 - 明牌副露里的 - 所有弃牌里的这个值在每次出牌、碰杠之后增量更新一次成本几乎为零。还有个实用的小优化算听牌前先判一次是不是已经胡了胡了就不用算听牌手牌数不是 13 张或者 10 张、7 张这类副露后的形态时直接返回空避免在动画中途被误调用算出一堆垃圾结果。4. 对局流程服务端权威、断线重连快照与牌谱回放客户端能算牌但不能决定牌。棋牌游戏里客户端唯一可信的东西是玩家点了哪张牌剩下的全得服务器说了算。这不是技术洁癖是因为棋牌游戏的作弊收益太高了——改一个本地变量就能看到别人的手牌改一个判定返回值就能强行胡牌。所以这一章讲三件事状态怎么切、重连怎么补、牌谱怎么留。4.1 谁说了算把表现和状态彻底切开这套工程用的是标准的服务端权威模型。客户端只发意图服务器校验后广播结果客户端照着结果播动画。一次出牌的完整链路是这样的// scripts/net/protocol.ts —— 意图上行 / 事件下行两边字段名必须完全一致 export interface DiscardIntent { type: discard; seq: number; // 客户端收到的最后一个事件序号服务器用来做幂等 kind: number; // 0..33 } export interface GameEvent { seq: number; // 全局单调递增客户端按序 apply跳号就说明丢包了 seat: number; // 事件发起方 action: draw | discard | peng | gang | hu | pass; kind?: number; // 涉及的牌 from?: number; // 碰杠胡的来源座位 deadline?: number; // 下一个行动者的超时时间服务端时间戳 }seq这个字段是整个同步机制的锚点。服务端每产生一个事件就加一客户端收到后检查是不是本地 seq 1是就 apply不是就说明丢包或者乱序立刻走一次全量拉取。没有这个字段你就只能靠看起来对不对来判断同步状态出问题时根本没法定位。本地预测要不要做我的经验是克制着做。出牌动画可以本地先播——玩家点了牌牌立刻抬起来往桌上飞手感是顺的但这张牌已经落到弃牌堆、下家开始摸牌这个状态必须等服务器广播。中间这段几十毫秒的空窗用动画时长糊过去就够了。反过来如果本地直接把牌落桌并推进到下一家服务器一旦拒绝比如这张牌其实不在你手上你就得写一套回滚逻辑那套逻辑的复杂度和 bug 密度远超它带来的手感提升。输入锁也是同一件事的另一面。出牌动画播放期间把交互关掉等seq推进到下一手再打开。看着简单但不做的话玩家连点三下客户端发出去三个意图服务器只认第一个剩下两个返回错误界面上的牌和服务器上的牌就开始对不上了。4.2 重连快照要存到什么粒度断线重连是棋牌游戏里最容易做漏的功能。移动端网络切换、来电、切后台随时会断玩家回来的时候必须一秒钟之内看到完整的牌桌而不是空白界面或者重新开始。快照的关键在于粒度。只存手牌肯定不够因为一局牌的状态不只包含手牌还包含谁该行动牌墙还剩多少有没有人杠了等着补牌当前是不是抢杠窗口。这些中间态漏一个重连回来就会错位。字段类型为什么必须存seqnumber快照对应的操作序号和增量事件对齐handnumber[34]自己的手牌计数别人的不下发handCountsnumber[4]各家手牌数量用来渲染牌背meldsnumber[4][]各家已亮出的碰杠副露discardsnumber[4][]各家弃牌堆按时间顺序wallCursornumber牌墙摸到第几张决定剩余张数turnSeatnumber当前行动者-1 表示结算中turnDeadlinenumber服务端时间戳客户端要算时钟偏移rngStatenumber种子随机状态保证后续摸牌一致pendingActionstring杠后补牌、抢杠窗口这类等待态rngState是很多人会漏的一个。如果牌墙在服务端是开局洗好一整副、按游标摸那重连其实不需要它但如果中途有杠后从牌墙尾部补牌流局重洗这类操作随机状态就必须跟着快照走否则重连之后摸出来的牌和原来对不上玩家会发现我明明记得下一张是三条。turnDeadline用服务端时间戳不要用服务器下发的剩余秒数。移动端从后台切回来倒计时不会自己走用剩余秒数会导致玩家回来发现还剩 15 秒实际服务器那边早就超时托管了。正确做法是拿服务端时间戳减去本地时间算一个偏移之后所有倒计时都基于偏移换算。客户端收到快照后的处理顺序也有讲究先把本地牌桌状态整体重置成快照内容不做动画再把快照seq之后收到的增量事件依次 apply同样不做动画最后才恢复正常渲染循环。整个快进过程应该在一帧内完成玩家看到的是加载一下牌桌就回来了。4.3 牌谱回放种子加操作序列就够了牌谱别存整局的所有状态快照那玩意儿一局能到几十 KB存一百万局就是几十 GB。正确做法是只存两样开局种子和整局的操作序列。回放的执行过程就是重演用种子重建牌墙按操作序列依次调用同一套规则函数客户端拿到的是每一帧的完整状态。这套做法的前提是规则层必须是纯函数且确定性——同样的种子和同样的操作序列跑出来必须逐帧一致。这也是第 2 章强调规则层脱离引擎的第二个理由服务端和客户端各自跑一遍结果能对上才敢用来做客服举证。回放功能的价值不只是看录像。玩家投诉我明明胡了为什么不给我胡客服把牌谱拉出来跑一遍如果确实是误判那就是规则 bug修复之后这条牌谱就变成回归用例如果不是也能拿出证据。这套流程跑顺之后规则相关的纠纷会少一大半。5. 避坑五条上线前必须自己踩一遍的坑前面四章讲的是怎么把东西做出来这一章讲的是怎么做完之后不被打脸。下面五条都是这类工程里反复出现的问题按现象、原因、解决写能对上号就直接照做。5.1 热更之后进牌桌白屏日志里一片 404现象小版本热更推上去大厅能进一点牌桌就白屏控制台刷一堆资源加载失败。原因基本是两个。一是 bundle 的版本号没跟 manifest 走CDN 上还是旧资源本地的配置已经指向新路径二是配置表、预制体里写的是绝对路径db://assets/xxx热更之后目录结构变了路径直接失效。解决manifest 里的每个 bundle 绑定内容哈希而不是手写版本号资源引用统一走resources.load或 bundle 内的相对路径禁止在预制体里挂绝对路径。发布前加一步校验扫一遍所有.prefab和.json把db://开头的字符串全部打出来有一个算一个都改掉。另外热更完成后强制整包重启一次不要指望清 JS 缓存引擎侧的模块缓存比你想象的顽固。5.2 杠完之后断线重连手里少一张牌现象玩家杠了等补牌的时候断线重连回来手牌数量不对后面所有的出牌判定都跟着错。原因快照只存了手牌和副露没存杠后补牌待办这个中间态。服务端那边其实挂着一个待办任务客户端不知道重连之后状态机停在等待出牌玩家手里少一张出牌按钮却是亮的。解决把一局的状态机完整序列化包括pendingAction这类等待态。更彻底的做法是重连不重建状态而是走和正常流程完全一样的 apply 逻辑——把快照当初始状态把seq之后的增量事件依次喂给状态机状态机自己会走到正确的位置。这样一来只有一套代码路径不会出现正常流程对、重连流程错的分裂。5.3 回放对不上十有八九是Math.random现象客服拿牌谱回放跑到中盘突然和玩家的录屏不一样了牌墙里的牌对不上号。原因某个地方偷偷用了Math.random()——可能是洗牌、可能是 AI 出牌的随机种子、可能是发牌特效里的随机抖动被误写进了逻辑层。只要有一处回放就必然分叉。解决在core目录下全局禁用Math.random用 ESLint 规则或者简单的正则扫描卡在提交前。所有随机必须从SeededRandom实例取而实例的种子必须来自服务端下发。加一条自动化检查用同一份牌谱跑两遍比对每一步的完整状态哈希不一致就报错。这条检查跑在 CI 里比人工回放靠谱得多。5.4 打到第八局开始掉帧越打越卡现象第一局很流畅连打七八局之后帧率肉眼可见地掉牌桌上的牌越铺越乱。原因弃牌节点没回收。每张打出去的牌都instantiate一个预制体局末只做了active false或者干脆从父节点移除但没销毁节点还挂在内存里同时每次开局给按钮绑的事件监听没有off监听器随局数线性增长一次点击触发八次回调。解决弃牌用对象池局末全部put回池子所有on必须在onDestroy里配一个off或者用this.node.on配合节点销毁自动解绑。排查方法很土但有效打开 Creator 的节点树面板打三局看节点数是不是单调上涨涨了就是没回收。监听器泄漏看内存快照里的 listener 数量同样看是不是单调上涨。5.5 连点出牌本地和服务端打起来了现象手速快的玩家连点两下出牌界面上的牌飞出去了但服务器只认第一张第二张返回错误之后牌桌上显示的和实际状态错开必须刷新才好。原因输入没有锁。出牌动画播放期间按钮还是可点的客户端把两个意图都发了出去。解决双保险。客户端在发出意图的那一刻就锁住输入直到收到对应seq的广播再解锁服务端对超出当前seq的意图直接丢弃不做排队。两端都做是因为客户端锁可能被各种异常绕过比如动画被打断服务端的丢弃才是最后一道闸。6. 上线前的最后一道闸用十万局牌谱把规则跑成回归用例规则层的 bug 有个恶劣特性它不会崩只会悄悄算错。多算一番、少算一张、听牌漏了一个界面上一切正常只有玩家知道不对劲。这类问题靠人手点测试是发现不了的只能靠批量跑。我的做法是把线上牌谱导成 jsonl一行一局字段就三个seed、ops、expect这一局最终的结果。然后用 Node 脚本加载core目录下的规则代码逐局重演把算出来的结果和expect比。# tools/replay-check.sh —— 挂在发布流水线上跑不过就不许发版 #!/bin/bash set -e # 1. 从牌谱里抽 10 万局按 7:3 切成回归集和对照集 node tools/split-replay.js --input ./replay/2024*.jsonl --limit 100000 --ratio 7:3 # 2. 重演任何一局结果不一致就非零退出 node tools/replay-runner.js --dir ./replay/regression --concurrency 8 # 3. 输出耗时分布判定单次胡牌计算的 P99 有没有超过 2ms node tools/replay-bench.js --dir ./replay/regression --report ./out/bench.jsonreplay-runner.js内部干的事情很朴素buildWall()加shuffleWall()造牌墙按ops依次调用draw、discard、peng、gang、isStandardWin最后比对番型和输赢。因为规则层不依赖引擎这个脚本可以开 8 个并发进程跑十万局大概两分钟出结果。这套回归跑顺之后我还习惯在上线前过一张检查表逐项打勾缺一项就不发检查项判定标准不通过的典型表现牌墙完整性每局发完牌后四种花色的总数守恒某种牌出现 5 张胡牌判定十万局回归零差异番型算错、漏胡听牌提示与人工标注的 200 组牌型完全一致漏听、死听未过滤断线重连随机断点重连 1000 次状态哈希一致手牌数量错位牌谱回放同局跑两遍逐帧哈希一致出现随机分叉首包体积首包不超过约定阈值牌桌资源在分包里首次进入等待超过 5 秒长局内存连打 20 局节点数与内存不单调上涨第八局开始掉帧提示回归集和对照集一定要分开。用回归集调完之后拿对照集再验一遍能挡掉一批针对测试用例过拟合的改动尤其是改番型计算的时候。现在这套流程已经变成固定习惯每次动core/rule.ts里任何一行先本地跑一遍小样本提交之后流水线跑全量跑不过直接拦下来。有次改七对的判定本地小样本全绿全量跑到第三万局才暴露出四张相同牌被算成两对的问题如果靠人工点这个 bug 大概会带着上线然后被某个玩家在论坛上挂出来。希望帮到你。本文还有配套的精品资源点击获取