简介:Cocos Creator达达麻将棋牌游戏是一份基于Cocos Creator开发的完整棋牌游戏工程文件,面向Cocos Creator开发者、棋牌类项目学习者及计划上线运营的团队。项目采用JavaScript编写前端玩法逻辑,搭配Node.js后端服务与MySQL数据库,覆盖玩家登录、匹配、数据同步、积分存储等典型功能,是一套前后端结合的实战案例。包内含2000个文件,压缩包大小约51.62MB,文件以js脚本、json配置、png图片素材、prefab预制体、anim动画、mp3音频等类型为主,既有Cocos Creator的编辑器资源与场景文件,也包含C++、Java等原生平台适配代码,目录结构完整,便于按模块阅读。目前已有3242人学习/下载。通过该工程可以系统掌握麻将游戏的场景搭建、牌局规则实现、UI交互与状态管理,同时了解Node.js后端的并发处理与MySQL表设计,并参考安全性、性能优化、兼容性测试等上线运营要点,适合用于课程设计、项目参考或二次开发起点。
1. cocos creator 做达达麻将:不只是洗牌和出牌
麻将棋牌游戏是 cocos creator 开发者经常遇到的现实需求,看起来只是把牌洗一洗、发一发,但真把一桌麻将跑起来,你会发现它牵扯到复杂的 UI 层级、手牌排序、胡牌判定、网络同步和断线重连。达达麻将这类项目,表面上是四个玩家的休闲牌桌,实际是一个要求状态一致性和操作反馈都很高的准实时系统。用 cocos creator 做它,最大的优势是编辑器里就能完成从 2D 牌桌到 UI 动画的全部搭建,发布到微信小游戏或原生平台都不需要换引擎。但很多人在第一步就翻车:以为麻将只有「摸一张打一张」,结果做出来的 Demo 连基本的碰杠都反复出 Bug。
这篇文章会从架构设计、核心算法、网络方案和排错经验几个方向,完整拆解一个可复现的 cocos creator 麻将项目。适合已经会用 creator 做简单界面的开发者,也适合想接棋牌外包但还没系统梳理过技术方案的团队。我会尽量把边界条件和参数讲明白,让你照着做不会走太多弯路。
2. 架构与场景设计:从牌桌 UI 到游戏流程控制
2.1 牌桌场景的节点层级与 UI 布局
达达麻将的牌桌场景,我习惯分为四层:背景层、牌桌层、操作层和浮层。背景层固定不参与交互,用于承载牌桌纹理和房间信息;牌桌层放四家手牌、牌墙、弃牌区;操作层放按钮组(碰、杠、吃、胡、过)和出牌提示;浮层放倒计时、弹出消息和结算面板。
节点层级设计要让每个玩家的牌区有独立父节点,而不是把所有 Sprite 都挂在 Canvas 下。举个常见错误:有人为了让手牌重叠排序方便,把所有玩家的牌都塞到一个节点里,结果远端玩家的牌方向错乱。正确做法是每个座位一个 container 节点,本地玩家手牌在这个节点内统一管理,对家手牌朝下放置,左右两家手牌侧向排列。这样后续做动画和合牌时,只需要操作各自的 container 的坐标和角度。
UI 布局上要注意安全区适配。麻将牌的宽度一般是 40x56 或 60x84 像素,在 iPhone 刘海屏和安卓异形屏上,底部那条横线会挡住操作按钮。我在 Canvas 下加了一个 safeArea 组件,并且把操作层的按钮组固定在 safeArea 的 y 坐标范围内,而不是直接锚定屏幕底部。字号和按钮尺寸也建议用 UI 编辑器里的缩放模式,而不是代码里写死像素值。
2.2 游戏状态机与出牌流程控制
麻将的流程看起来是「发牌 → 摸牌 → 打牌 → 下家摸牌」,但中间穿插碰、杠、吃、胡、过牌、抢杠和包牌,控制逻辑很容易写成到处 goto 的烂代码。我一般会用一个状态机来管理牌桌阶段,状态只有 7 个:Idle、Dealing、WaitingDraw、WaitingDiscard、WaitingAction、CheckingHu、Settling。
关键代码是一个简单的状态基类:
// GameState.js export default class GameState { constructor(gameMgr) { this.gameMgr = gameMgr; } enter() {} update(dt) {} exit() {} onEvent(evt, data) {} }然后每个具体状态继承它,比如 WaitingDiscard 状态负责监听本地玩家的出牌事件:
// WaitingDiscard.js import GameState from './GameState'; export default class WaitingDiscard extends GameState { enter() { this.gameMgr.ui.showDiscardHint(); this.gameMgr.remoteStub.setDiscardTimer(20); } onEvent(evt, data) { if (evt === 'PLAYER_TAP_CARD') { const cardId = data.cardId; // 校验这张牌是否在手牌里 if (this.gameMgr.logic.canDiscard(cardId)) { this.gameMgr.doDiscard(cardId); this.gameMgr.changeState('WaitingAction'); } else { this.gameMgr.ui.showToast('这张牌不能出'); } } } }状态转移表放在 gameMgr 里统一注册,避免每个状态里写死下一个状态。比如胡牌判断分布在摸牌后、出牌后和杠后,它们都可以转向 CheckingHu,但 CheckingHu 结束后会回到 WaitingDiscard 还是进入 Settling,由胡牌玩家数量决定。状态机的优点是每个阶段的行为都在一个类里,调试时只需看当前状态的 enter 和 onEvent,不用满项目找回调。
2.3 选型理由:为什么用状态机而非自由回调
有些开发者习惯用事件总线直接把所有按钮 click 事件发出去,摸牌、出牌、碰杠都写成匿名回调。这在两三家交互时还能跑,一旦加上机器人托管、断线重连、回放系统,你会发现事件流完全不可控:同一个操作可能触发两次抢杠,或者摸牌动画还没播完就很突兀地弹出吃碰按钮。
状态机的核心价值是把「当前能做什么操作」限制在一个明确的集合里。比如在 WaitingDiscard 状态下,吃、碰、杠按钮是隐藏的,玩家只能点手牌;进入 WaitingAction 后,手牌区域又被锁定,只能点操作按钮或过。这样既保护了 UI 逻辑,也方便做 AI 托管:托管机器人只需要按状态机注册的合法动作里选一个执行,不可能铤而走险。
状态机还有个容易被忽略的好处:回放系统可以直接记录状态转移事件。后面要做观战和回放时,只需要把每个状态的 enter 事件推入事件流,而不是把所有 UI 点击都记录下来,数据量小得多。
3. 麻将核心逻辑:手牌组织、胡牌判定与番型计算
3.1 手牌数据结构与排序算法
达达麻将的牌分为万、条、筒、风、箭(以及可选的花牌),每张牌我习惯用整数编码:万 1-9 编码为 0-8,条 10-18,筒 19-27,风 28-31,箭 32-34。这样排序可以直接用数值排序。手牌数组用普通 Array 而不是对象映射,因为麻将手牌需要考虑重复牌张,Array 更直观,也便于插入和删除。
排序算法不用花哨,插入排序就够了,因为玩家每次只增加一张牌,大部分情况下手牌已经基本有序。我写了个通用比较函数:
// sortHandTiles.js export function sortHandTiles(tiles) { const sorted = [...tiles]; for (let i = 1; i < sorted.length; i++) { const key = sorted[i]; let j = i - 1; while (j >= 0 && sorted[j] > key) { sorted[j + 1] = sorted[j]; j = j - 1; } sorted[j + 1] = key; } return sorted; }这里有个细节:排序时不能只按数值,还要考虑花牌和百搭牌的归类。比如百搭牌在达达麻将的某些玩法里可以选择替代任何牌,但它本身需要显示在手牌的最右侧,不能参与普通排序。我一般给牌对象加一个 extra 字段,比如{id: 14, isWild: true},排序时先按普通牌排序,再把 extra 字段不为空的牌移动到末尾。
逻辑层的手牌和 UI 层的手牌节点要分开。逻辑层永远操作整数数组,UI 层只根据整数数组去创建或复用节点。这样对逻辑做单元测试非常方便,不需要实例化任何场景节点。
3.2 胡牌判定:从穷举到剪枝
胡牌判定是所有麻将项目里最容易出玄学 Bug 的地方。标准玩法是 N 个面子 + 一对将,或者特殊牌型如七对、十三幺。最简单可靠的算法是递归回溯:把手牌分成将牌和面子两部分,穷举每一种组合。
我用的算法核心是这样:
// checkHu.js const TILE_TYPE_COUNT = 34; function isHu(tiles, hasPair) { const counts = new Array(TILE_TYPE_COUNT).fill(0); for (const t of tiles) counts[t]++; // 尝试所有可能的将牌 for (let i = 0; i < TILE_TYPE_COUNT; i++) { if (counts[i] >= 2) { counts[i] -= 2; if (canFormMelds(counts)) { counts[i] += 2; return true; } counts[i] += 2; } } // 无将,特例:七对 if (!hasPair) { if (isSevenPairs(tiles)) return true; } return false; } function canFormMelds(counts) { for (let i = 0; i < TILE_TYPE_COUNT; i++) { if (counts[i] === 0) continue; // 刻子 if (counts[i] >= 3) { counts[i] -= 3; if (canFormMelds(counts)) { counts[i] += 3; return true; } counts[i] += 3; } // 顺子,注意条、万、筒的分界 const type = Math.floor(i / 9); if (i % 9 < 7 && counts[i] > 0 && counts[i+1] > 0 && counts[i+2] > 0 && Math.floor((i+1)/9) === type && Math.floor((i+2)/9) === type) { counts[i]--; counts[i+1]--; counts[i+2]--; if (canFormMelds(counts)) { counts[i]++; counts[i+1]++; counts[i+2]++; return true; } counts[i]++; counts[i+1]++; counts[i+2]++; } } return false; }这段代码的问题在于递归深度可能很大,34 万次操作在 PC 上没问题,但在低端安卓机上每帧做可能卡顿。常见优化有两种:一种是加记忆化,把 counts 数组哈希后缓存判断结果;另一种是提前剪枝,比如统计每个类型的剩余牌数,若某种类型的牌数不能组成任何面子,可以直接返回 false。我通常把牌分类为万条筒风箭,风箭只能组成刻子,不能参与顺子,所以第一个循环里,对风箭的类型直接跳过顺子判断。
达达麻将如果带花牌或百搭牌,胡牌判定会复杂一个量级。百搭牌可以是任意牌,简单做法是在枚举前把百搭牌剔除,然后对剩余牌做胡牌检查,并用百搭牌去填补缺失的刻子或对子。这个逻辑要非常小心,我曾经在百搭牌数量大于 2 时出现递归爆炸,后来限制了百搭替代范围,必须优先补成刻子,不允许一牌多填。
3.3 番型与听牌提示的实现边界
番型计算是麻将项目里最需要和策划对齐的部分。达达麻将如果只是休闲玩法,番型可能只需要支持平胡、自摸、清一色、碰碰胡、七对、十三幺这几种。我用一个配置表来定义番型,避免在代码里写死 if else。
每个番型检测函数传入手牌结构,返回是否为该番型并计算番数。注意番型之间往往有包含关系,比如清一色是七对的子集,结算时应该取最大番数,不是简单累加。我会先计算所有命中的番型,再按优先级过滤。
听牌提示则是个性能黑洞。如果要提示每一张可以胡的牌,标准做法是对手牌从 0 到 33 遍历,把这张牌加入手牌数组,然后调用胡牌判定。如果每帧都做一次全量扫描,性能会很难看。我一般只做一次,在玩家摸牌后或出牌前延迟 200 毫秒计算,计算结果缓存到 local 变量;如果手牌变动则重新计算。
这里有个边界:有些麻将规则允许「听牌时不允许换牌」,那么还需要额外维护听牌状态,并在玩家每次操作前检查是否违反规则。这块逻辑与番型计算耦合,最好单独拆一个 RuleManager 类,不要混进胡牌判定模块。
4. 网络对战与房间系统:用帧同步还是状态同步?
4.1 房间创建、加入与断线重连
达达麻将如果有联网对战需求,首先要把房间系统做对。房间服务端的职责是维护玩家列表、手牌数据、对局阶段和每步操作记录。客户端只负责渲染和操作上报。因为麻将不存在连续移动的物理对象,状态同步比帧同步更合适。
房间创建流程我用一条创建请求打通:
// RoomService.js import { NetworkManager } from './NetworkManager'; export function createRoom(playerId, rule) { return NetworkManager.post('/room/create', { playerId, rule: { mode: 'normal', rounds: 8, maxPlayers: 4, withWildCard: false } }); }加入房间则通过房间号或分享 token 来匹配。这里要注意:房间号建议用 6 位数字加校验位,避免玩家误输后进错房间。断线重连是棋牌游戏的重灾区,我的做法是客户端在启动时生成一个 sessionId,连接断开后重新连上时,服务端根据 sessionId 恢复玩家正在进行的对局,并把当前完整牌桌快照推给客户端。
快照数据结构建议包含服务端下发编号、手牌列表、牌墙剩余数量、当前操作玩家、当前操作类型、倒计时结束时间。客户端收到快照后,不能用快照直接覆盖 UI,要先比对快照编号和自己的本地操作编号,按偏移补齐操作事件。否则会出现玩家明明点了一张牌,但同步后那张牌又回来的灵异现象。
4.2 状态同步方案:每秒 10 次的快照加上操作确认
麻将的操作是离散的,所以状态同步不需要高频。我一般设置服务端每秒最多推送 10 个快照,实际上一次摸牌操作完成后才推送一次就够了。关键是操作确认机制:客户端上报操作后,服务端返回一个 ack,ack 里包含操作序号和这条操作引起的牌面变化。如果客户端在 3 秒内没收到 ack,就弹出网络提示,并且禁止继续操作。
快照数据的设计我比较倾向于增量快照,因为全量手牌数据每次推代价大。增量快照只包含一个事件序列,从上次确认的位置开始:
// Snapshot.js { snapshotId: 1042, events: [ { type: 'DRAW', player: 0, tile: 23, from: 'wall' }, { type: 'DISCARD', player: 0, tile: 12 }, { type: 'CHI', player: 1, tiles: [11, 12, 13] } ], wallCount: 44, currentPlayer: 1 }客户端播放这个事件序列,再驱动自己的状态机。注意这里的 tile 是服务端下发的逻辑牌值,客户端不能用本地随机生成的牌去覆盖。这也是很多人做棋牌连不上对战的原因:本地模拟和网络同步混用,牌墙数据对不上。
4.3 帧同步在麻将里的困境
有些做即时对战出身的团队会习惯性用帧同步,但在麻将里我不推荐。帧同步要求所有客户端以相同输入序列推进确定性模拟,而麻将的初始发牌虽然可以随机种子一致,但玩家的操作是在不同时间点发生的,争抢动作比如两家同时胡牌,需要先判定优先级。帧同步协调这种优先级很麻烦,而且服务器做权限校验时,不能只看输入,还得重算牌面状态。
状态同步在棋牌里几乎是不二选择。服务端是关键权威,客户端可以做预测,但最终以服务端快照为准。哪怕客户端本地动画延迟了,只要最终状态对上,玩家也不会觉着奇怪。麻将的流畅感更多来自合理的动画耗时,而不是低时延。操作后牌从摸到打出,动画播放 0.3 秒,加上下家摸牌动画 0.2 秒,玩家感受到的节奏刚刚好。
5. 达达麻将的 5 个避坑记录:动画、触摸、合牌与性能
5.1 动画与触摸的冲突:为何点牌没反应
现象:玩家在出牌阶段点击手牌没有任何反应,但牌面明明显示正常。原因:手牌节点的触碰监听被摸牌动画的 tween 占用了,或者节点上挂了一个透明度渐变的 BlockInput 组件。解决:在状态切换时,显式调用this.node.off(Node.EventType.TOUCH_END)来清理监听,而不是依赖自动移除。
我踩过更深的坑是:动画播放过程中,节点被移出了屏幕,但触摸监听还保留。玩家点击屏幕空白区域,结果触发了一个还在缓动中的牌节点事件。后来我在所有手牌节点的 touch 回调里加了一个保护判断:只有当前状态是 WaitingDiscard 时才响应。这个判断成本极低,但能杀掉一半的触摸玄学。
5.2 手牌合牌(手风琴)算法在异型屏上的翻车
现象:某玩家的手牌很多时,横向排列超出屏幕,点其中的牌会错位。原因:手风琴算法只考虑了按牌数等距排列,没有考虑 safeArea 的左右边界。解决:动态计算可用宽度,用 16 张牌总宽度除以可用宽度得到步长,如果步长小于单张牌宽度的 0.7 倍,就限制步长最小值,并略微缩小牌之间宽度。
代码里我会在每次手牌变化后重新布局一次:
// HandTilesLayout.js layout(tiles, containerWidth) { const tileWidth = this.tileNode.width; const gap = Math.max(tileWidth * 0.3, (containerWidth - tileWidth) / (tiles.length - 1)); let x = -(tiles.length - 1) * gap / 2; for (const tile of tiles) { tile.node.x = x; tile.node.y = 0; tile.node.setSiblingIndex(tile.indexInHand); x += gap; } }但是这么写有个问题:如果 gap 计算出来的值比 tileWidth 还大,牌与牌之间空隙太大,看起来散落。我会加一个上限:gap = Math.min(gap, tileWidth * 0.6),确保牌始终有重叠,符合真实手牌观感。
5.3 胡牌判定在花牌和百搭牌上的漏判
现象:某个玩家手牌明显已经是胡牌,但系统不提示胡。原因:百搭牌被当作普通牌参与了顺子组合,或者花牌计数没有从胡牌判定里剔除。解决:在胡牌判定前,先把花牌单独存起来,从手牌数组里过滤出去;百搭牌则标记为 wild,进入判定函数前把 wild 牌从 counts 里扣掉,再在递归组合中尝试让 wild 替代缺失牌。
这里最容易漏掉的是「百搭牌不能替代风牌刻子」这种地方规则。建议把规则配置抽出来,用 map 管理,例如WILD_CAN_REPLACE = ['wan', 'tiao', 'tong'],不要硬编码。没有一套规则能覆盖所有地方麻将,提前做成配置表对后续迭代非常有帮助。
5.4 节点复用导致的牌面错乱
现象:洗牌后新一局开始,手牌区域还残留上一局的部分牌背图。原因:用了节点池复用牌节点,但重置节点时没有清理旧 SpriteFrame。解决:节点复用时,强制清空 texture,并恢复默认大小和旋转。
牌节点是棋牌游戏里高频对象,节点池必不可少。我写了一个getTileNode()来从池里取节点,取出来后立即执行:
getTileNode() { const node = this.pool.get(); node.active = true; node.getComponent(Sprite).spriteFrame = null; node.angle = 0; node.scale = 1; node.opacity = 255; return node; }这三个重置缺一个都会在特殊场景下触发鬼畜。特别是 opacity 不重置,玩家上一次弃牌时把某张牌淡出,新一局这张牌就变成半透明,非常难排查。
5.5 性能:牌桌长时间挂机后内存增长
现象:玩家挂机半小时后,游戏开始卡顿,内存占用从 200M 涨到 400M。原因:倒计时 Timer 和 tween 没清理,每次操作创建新的 tween 对象进入引擎的 tween 管理器,节点销毁了但 tween 还挂着。解决:在状态退出时调用Tween.stopAllByTarget(node),并且房间关闭时清理所有事件监听。
还有一个容易忽略的点:事件监听器重复注册。在 breakpoint 断线重连后,避免在onEnable里再次注册触摸事件,否则同样的操作会触发两次回调。我通常在初始化时只注册一次,后续通过 enabled 控制。
6. 往产品化再走一步:牌桌回放与观战系统
6.1 记录对局事件流
达达麻将做完基础对战功能后,最值得投入的产品化方向是回放和观战。回放系统的核心不是录屏,而是记录一条事件流。前面状态机的 enter 事件、玩家操作事件、服务端快照事件,本质上就是回放所需的全部素材。我在每次状态转移时,把{ time, state, event, data }push 进本地数组。
服务端如果需要在云端存储回放,只需要让客户端上传这个事件数组,或者服务端在自己侧记录同样的操作。注意不要记录 UI 动画参数,只记录逻辑事件。回放时客户端自己根据事件流重放这些逻辑状态,再驱动相同的更新和动画。
6.2 回放与观战的快进/拖拽实现
回放播放器需要支持暂停、单步、快进和进度拖拽。我用一个异步播放循环,每次从事件流头部取事件,根据事件的 time 字段计算延迟,然后执行并更新 UI。因为事件流通常只有几百条,性能压力不大。
拖拽实现则要放弃逐事件播放的思路,改为重建到目标时间点:
// ReplayPlayer.js jumpTo(timeMs) { this.reset(); for (const evt of this.eventList) { if (evt.time <= timeMs) { this.applyEvent(evt); } else { break; } } this.currentTimeMs = timeMs; }这里有个坑:如果事件之间有时间间隔,跳转后需要暂停在最后一个事件播放完成的状态。我处理办法是记录每一段时间的动画时长,跳转时把最后一条事件强制以零延迟应用,否则画面会卡在半途中。观战系统可以复用回放播放器,只是事件来源变成实时推送。
6.3 用 MVC 还是 MVVM 并不重要,关键是事件有 ID
很多人在做回放时,习惯把 UI 操作直接记录下来,比如「x=120, y=340 点击了一下」。这种做法在回放时完全无法复现,因为 UI 坐标和牌局状态没有强关联。我的习惯是给每个事件绑定逻辑对象 ID,无论是 handTileId、playerIndex 还是 actionType。回放时先根据 ID 找到对应的逻辑节点,再执行事件。
这样回放和观战的底层就统一了,以后如果要做 AI 复盘、牌桌数据统计,这套事件流都是现成的基础设施。最后说一个我的教训:第一次做回放时,我用时间戳差值来驱动播放,结果在低性能手机上动画间隔变得忽快忽慢。后来我改为使用 creator 的 update 累计时间,再和事件 time 做比对,而不是用 setTimeout。这个细节直接影响回放手感。
回放做出来后,给测试和策划用非常香。很多难以复现的牌桌 Bug,有了一条事件流就能直接定位到是哪一步逻辑出错。一套好的事件流设计,比多写一百行 UI 缓存代码都管用。这套方案我至今还在用,希望帮到你。
本文还有配套的精品资源,点击获取