
简介这是一套基于Cocos Creator引擎开发的达达麻将棋牌游戏完整项目源码面向具备一定JavaScript基础、希望学习网络棋牌游戏前后端架构的开发者与独立游戏团队。项目以Cocos Creator完成图形界面与场景设计前端逻辑用JavaScript实现麻将规则、用户交互与状态管理后端采用Node.js处理登录、匹配与数据同步并以MySQL存储玩家信息、游戏记录与积分同时涉及热更新、安全性防护、性能优化与多端兼容等上线运营要点。资源包共约2000个文件以json配置、js脚本、meta资源描述、png图片、prefab预制体、anim动画、mp3音效及sql脚本等为主压缩包约51.62MB目录结构完整便于按模块查阅。目前已有3232人学习下载适合作为棋牌类项目从开发到上线的参考实例帮助读者理解前后端协作流程与核心模块组织方式。1. 达达麻将棋牌游戏从 Cocos Creator 工程到能上桌的完整牌局很多人第一次搜「cocos creator达达麻将棋牌游戏」心里想的其实是同一件事我能不能用 Cocos Creator 把一套能跑起来的麻将牌局做出来而不是只停留在画几张牌、摆几个按钮。达达麻将这类地方麻将玩法核心不是美术而是「规则引擎 状态同步 出牌交互」这三件事能不能咬合。Cocos Creator 在这里的价值是把牌桌 UI、触摸交互、动画表现和逻辑层用同一套 TypeScript 工程管起来省掉前后端两套语言来回翻译的成本。这篇笔记面向的是已经会一点 Cocos Creator、想真正落地一个麻将棋牌项目的开发者从工程结构讲到牌型判定、出牌流程、打包 APK 的实操把能抄的代码和会翻车的地方都摊开讲。2. 达达麻将的工程结构与牌局数据模型怎么定2.1 为什么逻辑层必须和渲染层彻底分开麻将棋牌游戏和普通休闲游戏最大的区别是它的「正确性」要求极高。一张牌算错番、一个吃碰顺序判错玩家立刻就能感觉到而且这类 bug 极难复现。所以我在做达达麻将这类项目时第一条铁律就是牌局逻辑层不引用任何 cc 节点、不引用任何 UI 组件纯 TypeScript 类能脱离 Cocos 运行时单独跑单元测试。常见做法是建三个目录core纯逻辑、viewCocos 组件与预制体、net通信与房间。core里放牌墙、手牌、牌型判定、番型计算view只负责把core抛出的状态渲染成牌面。这样做的直接好处是你可以在 Node 环境里用脚本模拟一万局验证胡牌判定和番数计算而不用每次都在编辑器里点半天。// core/MahjongTile.ts // 牌的唯一标识花色 点数0-8 万9-17 条18-26 筒27-33 字牌 export enum TileSuit { WAN 0, TIAO 1, TONG 2, ZI 3, } export class MahjongTile { readonly id: number; // 0 ~ 33 constructor(id: number) { if (id 0 || id 33) throw new Error(非法牌 id: ${id}); this.id id; } get suit(): TileSuit { if (this.id 9) return TileSuit.WAN; if (this.id 18) return TileSuit.TIAO; if (this.id 27) return TileSuit.TONG; return TileSuit.ZI; } get rank(): number { // 字牌 rank 从 1 开始方便做顺子判断时排除 return this.suit TileSuit.ZI ? this.id - 26 : (this.id % 9) 1; } // 是否可以作为顺子的一部分字牌不行 get isSequenceable(): boolean { return this.suit ! TileSuit.ZI; } }这段代码的关键点是id的编码方式。把 34 种牌压成一个 0~33 的整数后面所有手牌、牌墙、弃牌堆都可以用number[]表示排序、去重、哈希都快。rank对字牌单独处理是因为字牌只能做刻子不能做顺子判定逻辑里要频繁区分。参数上唯一要守的是id边界越界直接抛错别让它悄悄流进判定函数里变成玄学 bug。2.2 手牌用数组还是用计数表新手最容易踩的坑是手牌用MahjongTile[]存然后每次判胡都去filter、sort。牌局一多GC 压力上来低端机上就能看到明显卡顿。我一般会把手牌在逻辑层表示成一张「计数表」长度 34 的number[]counts[id]表示这张牌有几张。// core/HandTiles.ts export class HandTiles { // counts[i] 表示 id 为 i 的牌有几张取值 0~4 private counts: number[] new Array(34).fill(0); add(id: number): void { if (this.counts[id] 4) throw new Error(牌 ${id} 超过 4 张); this.counts[id]; } remove(id: number): void { if (this.counts[id] 0) throw new Error(手里没有牌 ${id}); this.counts[id]--; } // 导出成排序后的 id 数组只给 UI 层用 toSortedIds(): number[] { const result: number[] []; for (let i 0; i 34; i) { for (let c 0; c this.counts[i]; c) result.push(i); } return result; } getCount(id: number): number { return this.counts[id]; } }计数表的好处是判胡、判碰、判杠全是 O(34) 的常数级操作而且天然防止「同一张牌出现 5 次」这种脏数据。toSortedIds只在刷新 UI 时调用逻辑层内部永远不碰数组排序。参数上要注意的是add里的 4 张上限校验这是麻将规则硬约束早校验早暴露问题别等到判胡时才发现数据已经烂了。2.3 牌墙与发牌随机种子要能复现达达麻将这类游戏如果要做回放或者断线重连牌墙必须是可复现的。我一般用一个带种子的伪随机数生成器来洗牌而不是直接Math.random()。// core/SeededRandom.ts export class SeededRandom { private seed: number; constructor(seed: number) { this.seed seed 0; } // xorshift32够用且快 next(): number { let x this.seed; x ^ x 13; x ^ x 17; x ^ x 5; this.seed x 0; return this.seed / 0xffffffff; } nextInt(maxExclusive: number): number { return Math.floor(this.next() * maxExclusive); } } // core/Wall.ts export class Wall { tiles: number[] []; constructor(rng: SeededRandom) { // 每种牌 4 张共 136 张 for (let id 0; id 34; id) { for (let k 0; k 4; k) this.tiles.push(id); } // Fisher-Yates 洗牌 for (let i this.tiles.length - 1; i 0; i--) { const j rng.nextInt(i 1); [this.tiles[i], this.tiles[j]] [this.tiles[j], this.tiles[i]]; } } draw(): number { const t this.tiles.pop(); if (t undefined) throw new Error(牌墙已空); return t; } }种子从房间创建时下发服务端和客户端用同一个种子牌墙就完全一致。这样断线重连只需要重放操作序列不用同步整副牌。参数上seed用 32 位无符号整数xorshift32的周期足够覆盖一局牌别用Math.random混进来否则回放必崩。3. 胡牌判定与番型计算达达麻将规则引擎怎么写3.1 标准胡牌判定的递归骨架胡牌判定的本质是14 张牌能否拆成 4 个面子顺子或刻子加 1 对将。最稳的写法是递归回溯配合计数表。// core/HuChecker.ts export function canHu(counts: number[]): boolean { // 先找将牌 for (let i 0; i 34; i) { if (counts[i] 2) { counts[i] - 2; if (canFormMelds(counts, 0)) { counts[i] 2; return true; } counts[i] 2; } } return false; } // 判断剩下的牌能否全部组成面子 function canFormMelds(counts: number[], start: number): boolean { // 找到第一张还有的牌 let i start; while (i 34 counts[i] 0) i; if (i 34) return true; // 全部拆完 // 尝试刻子 if (counts[i] 3) { counts[i] - 3; if (canFormMelds(counts, i)) { counts[i] 3; return true; } counts[i] 3; } // 尝试顺子i, i1, i2 必须同花色且不是字牌 if (i 27 i % 9 6 counts[i 1] 0 counts[i 2] 0) { counts[i]--; counts[i 1]--; counts[i 2]--; if (canFormMelds(counts, i)) { counts[i]; counts[i 1]; counts[i 2]; return true; } counts[i]; counts[i 1]; counts[i 2]; } return false; }这段代码有两个容易写错的地方。第一顺子判断里i 27排除了字牌i % 9 6保证不会跨花色比如 8 万和 1 条不能连。第二递归时传start i而不是i 1因为拆完一组后当前牌可能还有剩余。参数上counts是原地修改再还原调用方传进来之前最好 clone 一份避免污染手牌状态。3.2 七对、碰碰胡这些特殊牌型怎么加标准胡判定跑通后特殊牌型就是并列判断。七对最简单计数表里恰好有 7 个 2。export function isSevenPairs(counts: number[]): boolean { let pairs 0; for (let i 0; i 34; i) { if (counts[i] 2) pairs; else if (counts[i] ! 0) return false; // 有单张或刻子就不算七对 } return pairs 7; } export function isAllTriplets(counts: number[]): boolean { // 碰碰胡4 个刻子 1 对将不能有顺子 let pairCount 0; for (let i 0; i 34; i) { if (counts[i] 2) pairCount; else if (counts[i] ! 0 counts[i] ! 3) return false; } return pairCount 1; }番型计算我一般做成「规则表 逐条匹配」的结构每条规则是一个函数输入是手牌、副露、胡牌方式输出是否成立和番数。这样加地方规则时只加函数不动主流程。达达麻将如果有多地玩法这套结构能让你把「血战到底」「推倒胡」拆成不同规则集而不是写一堆 if-else。3.3 听牌提示与出牌建议听牌提示是玩家体验的关键。做法是对当前 13 张手牌枚举 34 种可能的进张逐个加进去跑canHu能胡的就是听牌。export function getTingTiles(counts: number[]): number[] { const result: number[] []; for (let id 0; id 34; id) { if (counts[id] 4) continue; // 已经 4 张不可能再进 counts[id]; if (canHu(counts)) result.push(id); counts[id]--; } return result; }这个函数在每次出牌后调用一次34 次canHu单次判定是常数级整体开销可以忽略。参数上注意counts[id] 4的跳过否则会算出「第五张牌」这种不存在的听牌。出牌建议可以在此基础上再算「打掉某张后听牌张数」给新手一个高亮提示这是留存率很敏感的功能。4. 出牌交互与状态同步从点击牌到牌桌刷新4.1 触摸选牌与出牌的最小闭环Cocos Creator 里牌面通常是预制体每张牌挂一个脚本处理点击。核心是「选中态」和「出牌态」两个状态机。// view/TileView.ts import { _decorator, Component, Node, EventTouch, Vec3 } from cc; const { ccclass, property } _decorator; ccclass(TileView) export class TileView extends Component { property raiseY 20; // 选中时抬高的像素 private selected false; private basePos new Vec3(); onTileClick: ((node: Node) void) | null null; start() { this.basePos this.node.position.clone(); this.node.on(Node.EventType.TOUCH_END, this.onTouch, this); } private onTouch(_e: EventTouch) { this.setSelected(!this.selected); this.onTileClick?.(this.node); } setSelected(v: boolean) { this.selected v; const p this.basePos.clone(); if (v) p.y this.raiseY; this.node.setPosition(p); } }raiseY是选中时牌抬高的距离一般 15~25 像素太小看不出选中太大牌会盖住上家。onTileClick回调交给牌桌控制器统一处理TileView 本身不碰逻辑。这里有个血泪经验TOUCH_END和TOUCH_START别混用麻将出牌要的是「按下再抬起」的确认感用TOUCH_START会导致误触出牌。4.2 牌桌状态机谁该出牌、能不能碰牌局状态我一般用一个显式状态机表示而不是散落的布尔变量。状态含义允许的操作Waiting等待发牌无Dealing发牌中无MyTurn轮到我出牌出牌、暗杠、补杠WaitAction等待我响应吃、碰、杠、胡、过OtherTurn别人回合无Settle结算查看结果状态切换由服务端事件驱动客户端只做表现。比如收到draw事件进入MyTurn收到discard事件后如果自己有可操作牌就进WaitAction超时未响应自动「过」。参数上超时时间一般设 8~15 秒太短玩家来不及反应太长牌局拖沓。4.3 操作优先级胡 杠 碰 吃多人同时能操作时优先级必须明确否则会出现「我碰了但别人胡了」的争议。常见做法是服务端收集所有玩家的可操作项按优先级排序最高优先级玩家先响应其他人等待。// core/ActionPriority.ts export enum ActionType { HU 4, GANG 3, PENG 2, CHI 1, PASS 0, } export function pickHighest(actions: { type: ActionType; seat: number }[]) { return actions.sort((a, b) { if (b.type ! a.type) return b.type - a.type; // 同类型按座位顺序离出牌者近的优先 return a.seat - b.seat; })[0]; }这段逻辑必须放在服务端客户端只做展示。我见过把优先级放客户端算的项目结果不同客户端算出的结果不一致直接翻车。参数上seat的顺序要按实际座位方向定义清楚别一会儿顺时针一会儿逆时针。5. 打包 APK 与真机适配Cocos Creator 出包的避坑清单5.1 构建参数怎么设Cocos Creator 打包 Android构建面板里几个参数直接决定能不能跑起来。渲染后端选 GLES2 兼容性最好GLES3 在部分老机型上有黑屏报告。MD5 Cache建议开启热更新时能避免缓存问题。屏幕方向麻将一般锁横屏。包名用反域名格式别用默认的com.cocos.demo否则上架会被打回。构建完成后用 Android Studio 打开build/android工程检查minSdkVersion麻将游戏建议不低于 21覆盖绝大多数设备。targetSdkVersion跟着 Google Play 要求走国内渠道各自有要求提前查清楚。5.2 真机上的三个高频问题第一个是牌面模糊。原因是设计分辨率设得太低或者牌图没做多倍图。解决方法是设计分辨率至少 1280x720牌面用 2 倍图Cocos 的fitHeight/fitWidth按机型选。第二个是点击不灵敏。麻将牌小触摸区域要放大。可以在 TileView 上加一个透明的UITransform尺寸比牌面大 20%专门接触摸。第三个是低端机掉帧。多半是牌面节点太多每张牌一个 DrawCall。优化手段是用SpriteAtlas把牌面打成图集同图集的节点能合批。136 张牌如果都在一个图集里DrawCall 能从上百降到个位数。5.3 热更新与资源版本棋牌游戏上线后改规则、加玩法是常态热更新能力要提前留。Cocos Creator 的 Asset Bundle 机制可以把牌面、音效、配置表拆成独立 bundle规则配置用 JSON 放远程改数值不用重新出包。参数上 bundle 的优先级和依赖关系要在assets里配清楚别让两个 bundle 引用同一个资源导致重复加载。6. 达达麻将常见问题排查五个真实踩坑记录现象胡牌判定偶尔漏判同一手牌有时能胡有时不能。原因canHu里对counts原地修改后没完全还原某条递归分支提前 return 时漏了还原。 解决所有修改counts的地方用try/finally或先 clone 一份再操作判定函数保持纯函数语义。现象断线重连后牌墙顺序和原来不一样。原因洗牌用了Math.random或者种子在重连时重新生成。 解决种子在房间创建时生成一次存服务端重连时下发同一个种子客户端用SeededRandom重建牌墙。现象打包 APK 后真机启动黑屏编辑器里正常。原因渲染后端选了 GLES3或者某个原生插件没配好。 解决先切回 GLES2 验证再逐个排查插件。构建日志里native相关报错要重点看。现象多人同时点碰结果两个人都碰了。原因操作优先级判定放在了客户端或者服务端没做互斥。 解决所有操作请求先到服务端服务端按优先级选一个赢家其他人收到「操作无效」回滚 UI。现象牌面图集打了但 DrawCall 没降。原因牌面节点层级里插了不同图集的装饰节点打断了合批。 解决把同一图集的节点放在连续层级中间不插其他图集节点用Sprite的atlas属性确认归属。7. 用模拟对局验证规则引擎一万局跑通再上桌规则引擎写完别急着接 UI先在 Node 环境里跑模拟对局。我一般写一个simulate.ts让四个「机器人」随机出牌跑到有人胡为止统计胡牌率、平均局时长、异常抛出次数。// tools/simulate.ts import { SeededRandom } from ../core/SeededRandom; import { Wall } from ../core/Wall; import { HandTiles } from ../core/HandTiles; import { canHu } from ../core/HuChecker; function runOneGame(seed: number): { hu: boolean; turns: number } { const rng new SeededRandom(seed); const wall new Wall(rng); const hands [new HandTiles(), new HandTiles(), new HandTiles(), new HandTiles()]; // 发牌每人 13 张 for (let round 0; round 13; round) { for (let seat 0; seat 4; seat) hands[seat].add(wall.draw()); } let turns 0; while (wall.tiles.length 0) { turns; const seat turns % 4; hands[seat].add(wall.draw()); if (canHu(hands[seat].toSortedIds().reduce((acc, id) { acc[id]; return acc; }, new Array(34).fill(0)))) { return { hu: true, turns }; } // 简化随机打一张 const ids hands[seat].toSortedIds(); hands[seat].remove(ids[rng.nextInt(ids.length)]); } return { hu: false, turns }; } let huCount 0; const N 10000; for (let i 0; i N; i) { const r runOneGame(i); if (r.hu) huCount; } console.log(胡牌率: ${(huCount / N * 100).toFixed(2)}%);这段脚本的价值在于它能在几秒内跑完一万局把规则引擎里的边界问题全逼出来。参数上N设一万起步太少统计不稳。跑通后你会对「平均几巡胡牌」「哪种牌型最容易出」有直觉这些数据反过来能指导 AI 难度和番型平衡。我自己的习惯是任何规则改动先跑模拟再看 UI。有一次改碰碰胡判定模拟里胡牌率从 18% 掉到 3%一查是把顺子误判成了刻子。这种问题如果靠手点可能一周都发现不了。希望这套从工程结构到模拟验证的路子能帮你把达达麻将这类项目真正推到能上桌的程度。本文还有配套的精品资源点击获取