
简介一套基于CocosCreator引擎开发的房卡麻将棋牌游戏完整源码面向Cocos Creator游戏开发者与棋牌类项目学习者。该项目围绕房卡模式展开覆盖玩家创建私密房间、邀请好友开局、房卡购买与发放等核心链路可作为从零搭建棋牌游戏App的实战起点。资源压缩包仅35.08MB却包含3155个文件其中约652个js脚本承载游戏逻辑与服务器通信1012个json用于配置和数据表478个meta负责资源索引256个png贴图、70个mp3音效及25个anim动画组成表现层另有prefab/fire场景组件、md说明文档和license授权文件目录结构完整。当前已有4767人学习下载。源码除Cocos Creator场景搭建、动画制作、跨平台发布外还涉及WebSocket实时同步、数据库存储、房卡交易安全等关键模块配合thinks.txt开发者说明可系统理解麻将规则、房间状态同步与性能优化思路是一份兼顾实战与学习的游戏源码资料。1. 房卡麻将源码包到手先看这套 Cocos 项目的房卡业务放在哪拿到这份 CocosCreator 房卡麻将源码时大部分开发者的第一反应是直接双击打开编辑器跑一把。实际接触这类 Cocos 棋牌游戏工程后你会发现能不能在模拟器里看到牌桌并不是评估它的重点——重点是「创建房间、生成房卡号、等待加入、开牌结算、扣除房卡」这条链路是否完整。这套源码的价值也正在于此把房卡制麻将最核心的前端闭环做成了一个可以照着拆的 Demo。你想快速起一个 cocos-creator 棋牌游戏或者正在评估拿别人的工程二次开发要付出多少成本这份源码都值得先过一遍再判断。但有一句丑话要放在前面前端能跑通不等于能上线纯前端扣房卡在线上会被白嫖后面每一章我都会带着这个视角去拆。2. 版本识别与首跑8 处关键配置决定项目能不能打开拿到任何 Cocos 源码包第一步都不是急着看代码而是先确认它属于哪个引擎版本。Cocos Creator 2.x 和 3.x 的工程结构差异巨大打开方式也完全不同。用错编辑器版本轻则资源导入报错重则整个项目在启动阶段就白屏。这一章的目标只有一个让项目先在正确版本的编辑器里跑起来。2.1 先识别引擎版本2.x 和 3.x 的工程差异一眼就能看出来Cocos Creator 2.4 和 3.8 之间不是小版本迭代而是底层 API 的翻新。最直观的识别手段是看project.json里的引擎版本字段其次看脚本写法。2.x 的项目脚本常用cc.Class和cc.Component组合3.x 则全面转向 ES6 class 加ccclass装饰器。花两分钟确认版本能省掉后面一整个下午的排错时间。# 从项目根目录查看引擎版本 cat project.json | grep -E engine|version # 2.x 项目的返回形如 engine: 2.4.13 # 3.x 项目的返回形如 engine: 3.8.2 # 顺手看一下 assets 目录里 .meta 文件的规模 ls assets | head -20这段命令用来快速判断工程的引擎代际。project.json是 Cocos Creator 工程的脸面里面记录了编辑器版本、构建平台、模块设置。.meta文件是资源序列化标记2.x 和 3.x 的 meta 文件格式有细微差别但不需要深入研究——你只需要知道.meta存在说明资源已经被编辑器索引过直接拷贝能省去二次导入的等待。参数上引擎版本号的第一位数字是关键2.x 和 3.x 的 API 差异远比你想的大。打开任意一个.js或.ts脚本如果看到cc.Class({ extends: cc.Component })基本可以锁定 2.x如果看到const { ccclass, property } _decorator那就是 3.x 没跑了。这套源码里两种写法都会出现的话说明项目可能经过了跨版本迁移你需要额外关注迁移是否完整这不是危言耸听后面避坑章节会专门说。2.2 导入编辑器首跑场景名、首场景与常见的资源报错版本确认后用 Cocos Dashboard 新建一个空白工程然后把下载包里的assets、settings、package.json整体覆盖进新工程目录再重新打开。这样做的原因是直接双击打开别人的工程Dashboard 可能因为资源路径不匹配而拒绝加载。借用新工程外壳是比较稳的落地方式。# 假设你已经在 Dashboard 里创建了同名空工程 # 关闭编辑器后执行以下操作目录名只是示例 # 备份空工程自带的 assets mv assets assets_blank_backup # 把源码包里的 assets 软链/复制进来 cp -r ~/Downloads/cocos-fangka/assets ./assets # 回到 Dashboard 重新打开这个工程复制过程里如果 IDE 提示资源重复或脚本 GUID 冲突优先保留下载包里的.meta文件。Cocos 靠 GUID 索引资源一旦 meta 重新生成所有场景和预制体里的引用都会断掉轻则贴图丢失重则脚本组件全部失效。导入完成后在资源管理器里找到场景文件优先打开名字带hall或game的场景这就是大厅和牌桌的入口。这里整理了一张首跑阶段常见报错排查表报错现象原因处理方式脚本组件丢失.meta文件未随资源一起复制恢复原始 meta重新导入贴图显示紫色问号图片资源路径变更或丢失检查 assets 目录完整性启动后白屏首场景设置错误在项目设置里指定大厅场景为首场景音频播放失败资源下发现invalid audio删除对应.meta 后重新导入音频首跑阶段不要急着注册任何账号逻辑。通常源码包自带的假登录会直接跳到大厅——只要能进大厅、能点开创建房间弹窗这套工程的客户端就已经活了。接下来你只需要顺着界面操作把整条业务流标出来。2.3 跑通后要做的第一件事把房卡链路标出来项目跑起来后别急着打牌。你要做的是找到房卡扣费逻辑挂在哪里。棋牌源码最值的部分就是这套业务闭环而闭环的关键节点一般藏在两处创建房间时的参数面板和结算界面的扣费回调。# 全局搜房卡相关关键词锁定业务入口脚本 grep -rn roomCard\|room_card\|fangKa\|fangka assets/scripts --include*.js --include*.ts | head -30搜索命中后先看roomCard出现在哪些文件里。理想情况下你会得到一条从 UI 点击到房间创建、再到开牌扣费的调用链。如果搜索结果非常零散说明这套源码的房卡逻辑没有内聚二次开发时你需要自己抽象一层服务。这一步标记出来的调用链就是整个项目的地图后面所有修改都绕不开它。这里涉及的参数解释一下roomCard是房卡对象或房卡数量的字段名room_card是常见的 Python 后端字段命名风格fangKa则是拼音命名法。三种命名都很常见搜索时最好都带上。如果源码里根本没有房卡逻辑只有自由对战那这份资源的价值就要重新评估了——房卡模式才是这类项目的盈利核心。3. 房卡业务流程拆解房间配置、付费扣除与会话恢复跑通只是起点。从这一章开始我们把这条业务链路拆开看。房卡麻将的整个玩法可以浓缩成三件事配置房间、创建房间、结算扣卡。大多数源码在做第一件事时配置项过多第二件事时没做好状态同步第三件事时扣费逻辑写在了前端——这三处也是二次开发时最常返工的位置。3.1 用户状态机与界面栈从大厅到牌桌的切换依据棋牌客户端本质是一个界面状态机。大厅、创建房间面板、牌桌场景、结算弹窗每个界面都对应一个用户状态。状态切换的依据不只是用户点了什么按钮还要考虑断线重连——重连后用户应该回到哪个界面完全由服务端下发的房间状态决定。// 游戏全局状态机控制大厅/创建房间/牌桌/结算之间的跳转 const GameState { HALL: HALL, // 大厅浏览房间列表创建或加入 CREATE_ROOM: CREATE_ROOM, // 创建房间配置房间参数并支付房卡 PLAYING: PLAYING, // 牌桌打牌进行中 SETTLE: SETTLE // 结算展示本局得分与房卡扣除结果 }; // 界面路由由 UIManager 统一管理避免每个界面自己跳自己 class UIManager { constructor() { this._panelStack []; } pushPanel(state, panelPrefab) { // 入栈前先关闭当前顶层界面保持单界面可见 const top this._panelStack[this._panelStack.length - 1]; if (top) top.active false; const newPanel cc.instantiate(panelPrefab); this._panelStack.push(newPanel); newPanel.active true; } }这里的UIManager采用栈式管理路线是状态推进回退也是栈弹出。棋牌项目里一定要避免多界面同时叠加否则断线重连回来会出现多个面板互相遮挡的经典 bug。GameState里的每个状态都对应一个独立的界面预制体而状态切换只由两类事件驱动用户主动操作和服务端消息推送。前端的 push 面板只做展示真正的状态变更必须等服务端确认这条规则适用于所有棋牌源码。3.2 创建房间参数与付费计算底分、局数、谁出房卡创建房间面板看起来是一堆输入框本质是维护一个RoomConfig对象。局数、底分、玩法开关、癞子设置、付费方式这些字段会被序列化后发给服务端服务端再生成一个唯一的房卡号返回给房主。客户端本地也保留一份配置用于展示但真正决定规则的只能是服务端那份。// 创建房间的参数结构客户端与服务端通过同一个 JSON 协议交互 const RoomConfig { roundCount: 16, // 局数普通房 8 局畅玩房 16 局 baseScore: 1, // 底分用于结算时的倍数基础 gameMode: normal, // normal 普通场 / fast 快速场 withLaizi: true, // 是否带癞子癞子会显著改变胡牌算法 payType: AA, // AA 房主和玩家均摊 / OWNER 房主全包 maxPlayers: 4, needCards: 0 // 实际需要扣除的房卡数量由下方函数计算 }; // 房卡费用计算局数越长人均消耗越大 function calcRoomCards(config, isOwnerPay) { // 常见规则8 局需 2 张房卡16 局需 3 张房卡 const base config.roundCount 8 ? 3 : 2; if (isOwnerPay) return base; // AA 制下4 人房间每人各出 base 张但房主创建时需要一次性垫付 return base * config.maxPlayers; }calcRoomCards是房卡模式的核心之一。参数isOwnerPay决定是房主一个人全部承担还是人均 AA。实际线上项目房卡数目的计算应由服务端完成客户端这个函数只用于 UI 预览。如果你拿到这份源码发现扣费逻辑写在前端等于任何人都可以改内存把房卡数改成 0——部署前必须把这段逻辑搬到后端这是最优先的改造项。3.3 数据库三张核心表房间、对局、房卡流水房卡麻将的后端本质上需要三张表就够跑起来房间表存当前活跃房间信息对局表存每一局的结算快照房卡流水表存每个用户的房卡收支账。很多源码把这三张表合在一起或者干脆用 JSON 文件存做小型 Demo 没问题但一旦用户量上来一定会成为性能瓶颈。-- 房间表一个房间一行记录生命周期从建房到解散 CREATE TABLE room ( room_id VARCHAR(16) PRIMARY KEY COMMENT 房卡号/房间号, owner_uid INT NOT NULL COMMENT 房主用户ID, config_json TEXT NOT NULL COMMENT 房间配置序列化后的 JSON, status TINYINT DEFAULT 0 COMMENT 0待开始 1进行中 2已结束, create_time INT NOT NULL COMMENT 创建时间戳, expire_time INT NOT NULL COMMENT 房间解散时间戳 ); -- 对局表每打一局写一条记录结算结果 CREATE TABLE game_record ( id INT PRIMARY KEY AUTO_INCREMENT, room_id VARCHAR(16) NOT NULL, round_no INT NOT NULL COMMENT 第几局, settle_json TEXT NOT NULL COMMENT 本局结算详情, laizi INT DEFAULT -1 COMMENT 本局癞子牌ID-1表示无癞子 ); -- 房卡流水表任何房卡变动都留下记录可追溯 CREATE TABLE card_flow ( id INT PRIMARY KEY AUTO_INCREMENT, uid INT NOT NULL COMMENT 用户ID, change INT NOT NULL COMMENT 正数收入负数支出, room_id VARCHAR(16) NOT NULL, create_time INT NOT NULL, remark VARCHAR(64) COMMENT 如创建房间扣费、每日福利 );三张表的关联关系要说明一下game_record和card_flow都通过room_id关联到room但card_flow与room_id的关系是弱关联——理论上应该再存一份订单号因为同一局房间可能涉及多个用户的扣费与返还。字段change用正负号表达收支是最朴素的账本设计统计余额时直接SUM(change)即可。注意settle_json和config_json都是 TEXT 类型说明这套表设计是灵活优先查询统计只能依赖服务端代码解析 JSON适合中小项目。4. 麻将算法模块洗牌、胡牌判断与结算的代码释义麻将源码的技术含金量一半在牌数据编码另一半在胡牌判断。前者决定了你的内存占用量和序列化效率后者决定了你兼容多少种玩法。本章会把这两个算法核心摊开来讲明白。4.1 牌数据编码与洗牌发牌从牌墙到手牌的落库国内麻将的牌面可以统一编码成 0 到 33 的整数0-8 表示万子9-17 表示条子18-26 表示筒子27-30 表示风牌31-33 表示箭牌。用整数而不是字符串的好处是排序、比较、序列化都快得多尤其在服务端高频跑胡牌判断时int 数组就是最优解。import random # 一套完整的牌墙34 种牌各 4 张 TILES list(range(34)) * 4 def shuffle_and_deal(): wall TILES[:] random.shuffle(wall) # Fisher-Yates 洗牌保证每种牌恰好 4 张 # 前 52 张发给 4 个玩家每人 13 张 hands [wall[i * 13:(i 1) * 13] for i in range(4)] # 第 53 张给庄家作为庄家的第 14 张手牌 dealer_extra wall[52] hands[0].append(dealer_extra) # 剩余 83 张作为牌墙从第 54 张开始翻牌 wall_left wall[53:] return hands, wall_left hands, wall_left shuffle_and_deal() print(各玩家手牌数:, [len(h) for h in hands]) print(剩余牌墙数:, len(wall_left))TILES list(range(34)) * 4的关键在于 list 乘法会生成 136 个元素的副本每个牌型恰好出现 4 次。random.shuffle内部实现的就是 Fisher-Yates 算法均匀性足够满足线上需求。发牌时采取先发 52 张、再给庄家补第 53 张的顺序是为了确保庄家起手 14 张闲家 13 张。wall_left的数量如果不对通常是因为你在解码牌时把花牌也算了进去导致总数不等于 136——排查时就核对这个总数。4.2 胡牌判断七对、清一色与癞子回溯胡牌判断是麻将算法里的硬骨头。基础逻辑是手牌可以拆成「将牌一对 N 组面子顺子或刻子」。递归拆牌的思路是固定一对将牌剩下的牌递归尝试拆成面子拆成功了就胡。这个递归算法看起来简单但写成迭代很容易漏掉多对子组合的情况。def can_win(tiles): # tiles: 排序后的手牌 int 列表长度为 13 或 14 counts [0] * 34 for t in tiles: counts[t] 1 # 依次尝试拿掉任意一种牌作为将牌 for i, c in enumerate(counts): if c 2: counts[i] - 2 if can_form_sets(counts): counts[i] 2 return True counts[i] 2 return False def can_form_sets(counts): # 找到第一张剩余数量不为零的牌从它开始拆 i 0 while i 34 and counts[i] 0: i 1 if i 34: return True # 所有牌都拆完了当前拆法成立 # 尝试刻子三张相同的牌 if counts[i] 3: counts[i] - 3 if can_form_sets(counts): return True counts[i] 3 # 尝试顺子i 1, i 2 也有牌 # 注意索引 27-33 是风牌和箭牌不能组成顺子 if i 27 and counts[i 1] 0 and counts[i 2] 0: counts[i] - 1 counts[i 1] - 1 counts[i 2] - 1 if can_form_sets(counts): return True counts[i] 1 counts[i 1] 1 counts[i 2] 1 return False这段代码有两个关键参数要理解counts数组长度 34 对应四种花色 34 种牌型counts[i]是第 i 种牌的持有数量。while循环找第一张非零牌是剪枝策略因为顺子和刻子都由最小牌发起才不重不漏。特别注意i 27的边界限制字牌不能组顺子这是麻将规则里最常见的越界 bug 来源。这套递归实现支持七对、清一色等大多数常规玩法但需要额外检查清一色条件——即整手牌是否都属于同一花色。癞子万能牌的处理会让难度陡增。癞子可以作为任意一张牌参与拆牌本质是把癞子当作通配符传入递归函数每尝试一种组合时优先消耗癞子补齐缺口。核心思路是在can_form_sets里加一个laizi_count参数当需要的牌不存在但癞子还有剩余时用癞子代替。def can_form_sets_with_laizi(counts, laizi_left): i 0 while i 34 and counts[i] 0: i 1 if i 34: return True if counts[i] 3: counts[i] - 3 if can_form_sets_with_laizi(counts, laizi_left): return True counts[i] 3 # 尝试顺子优先消耗真实牌不足时用癞子补 if i 27: needed [i, i 1, i 2] use_laizi 0 for n in needed: if counts[n] 0: use_laizi 1 if use_laizi laizi_left: break else: # 从 needed 里移除对应牌laizi 减 1 for n in needed: if counts[n] 0: counts[n] - 1 else: laizi_left - 1 if can_form_sets_with_laizi(counts, laizi_left): return True # 还原 for n in needed: counts[n] 1 laizi_left use_laizi return False这段癞子版本的要点在于needed三元组三张牌里如果缺口数大于剩余癞子数这个顺子方案直接跳过。还原顺序要与消耗顺序完全相反否则递归回溯时牌数会对不上。癞子算法写错最常见的现象是「明明是两张 4 万加一张癞子却没让胡」排查时打印每一步的counts数组和laizi_left即可定位。4.3 结算数值翻倍规则与封顶细节胡牌之后进入结算这部分是棋牌源码里最容易出现经济漏洞的地方。常见翻倍规则有自摸×2、杠上开花×2、无癞子×2、清一色×4还要叠加封顶机制防止单局输赢过大。代码实现上通常是一个条件累计函数。function calcScore(baseScore, selfDraw, gangKai, noLaizi, qingYiSe, topLimit) { let multiple baseScore; if (selfDraw) multiple * 2; if (gangKai) multiple * 2; if (noLaizi) multiple * 2; if (qingYiSe) multiple * 4; // 封顶倍数超过上限时按上限计 return Math.min(multiple, topLimit); } // 示例底分 1 分的房间自摸 无癞子 4 分未超封顶 8 分 console.log(calcScore(1, true, false, true, false, 8)); // 输出 4topLimit是防失控的关键参数常见取值 8 分或 16 分。项目源码里如果没封顶测试时必然会出现单局输赢上百分的失控局面。结算模块还要注意一个细节得分应分为「胡牌者得分」和「闲家失分」两部分避免纯倍数笼统计算导致三家各扣一份、赢家拿三份的歧义。5. 避坑指南断线重连、顶号与热更新的 5 个典型翻车点源码能跑、算法能过只代表本地逻辑没崩。真正决定棋牌项目能不能活的是网络异常处理。我踩过的坑里断线重连和顶号问题占了七成而且源码包的默认实现基本都靠不住得自己补。5.1 断线重连后界面残缺现象牌打到一半切后台再切回来界面卡在出牌倒计时手牌区域空白牌桌只剩半张图。翻车原因客户端断线重连时只和服务端重新握了手没有拉取完整牌局状态。解决重连成功后必须主动请求一次全量快照——包括手牌、牌墙剩余数、当前出牌人、倒计时剩余时间。// 重连成功后请求牌局快照关键是要在 scene 加载完后再发 netFlow.onReconnect(() { const snapshotMsg { type: REQ_SNAPSHOT, roomId: currentRoomId, packetSeq: lastPacketSeq 1 // 服务端根据包序号补发缺口消息 }; ws.send(JSON.stringify(snapshotMsg)); });这里的packetSeq是排错核心。棋牌服务端一般按顺序给客户端推送每一步操作断线期间的消息不会重发而是靠快照一次补齐。快照消息体要包含完整的wallLeftCount和currentTurnUid否则客户端会陷入死等状态。5.2 顶号后双端互顶现象同一账号在两台设备登录后两台设备同时显示「操作中」胡牌操作被接受一次但界面重复弹出两次。翻车原因服务端没做单连接踢出旧连接没被强制断开。解决服务端在检测到新连接时必须主动向旧连接下发KICK_OUT消息并在数据库里标记旧的session_id失效。// 服务端伪代码新连接登录时踢掉旧连接 if (sessionMap[userId].socket) { sendKickOut(sessionMap[userId].socket, LOGIN_OTHER_DEVICE); } sessionMap[userId].socket newSocket;这个机制看似简单执行顺序很微妙先踢旧连接再建立新会话还是先建新再踢旧决定了用户会不会看到半秒钟的登录失败。常见做法是直接覆盖 sessionMap 后主动close()旧连接让客户端那边统一走断线重连逻辑。5.3 出牌倒计时与服务端时间不同步现象明明 15 秒出牌时间客户端显示只有 8 秒或者倒计时走完了服务端却不判超时。翻车原因客户端用了本地时间戳服务端也按自己收到消息的时间算两边时钟有偏差。解决统一以服务端时间为准下发倒计时开始时间戳server_ts客户端用server_ts duration - now计算剩余时间。// 服务端下发的剩余时间计算方式 const REMAIN_SECONDS Math.ceil( (turnDeadline.serverTs turnDeadline.durationMs - Date.now()) / 1000 );这里有个细节玩家的出牌操作消耗了网络延迟deadline应该按消息到达服务端的时间为基准而不是客户端点击按钮的时间。否则延迟高的玩家会出现「点击出牌但服务端认定超时」的投诉。5.4 癞子判断越界现象带癞子模式下13 张手牌 一枚癞子理论上胡牌范围很大但算法经常漏判偶尔误判。翻车原因我见过最典型的写法是把癞子的牌值当成 34 传入counts数组导致索引越界变成负数或把癞子和普通牌混在一起拆顺子。解决手牌数组中单独标记癞子或者拆牌前先把癞子从普通牌里剥离按万能牌计数传入递归函数。如果你在第 4 章的代码上直接加癞子先跑三个测试用例普通牌 两个癞子、七对带一个癞子、杠后补牌形成癞子胡。这三个用例过了再测复杂花色组合。5.5 用错编辑器版本直接报错现象打开工程后百行报错脚本组件大面积失效场景打白屏。翻车原因用 3.x 编辑器打开了 2.x 的工程或者反过来兼容不完全。解决严格按第 2 章的版本识别步骤先确认引擎版本再创建同版本空壳工程复制资源。如果你只有新版编辑器也别妄图让编辑器自动迁移项目迁移后你还是要重写大量 API 调用不如直接保持 2.x 环境。6. 从 Demo 走向线上一体化改造清单和 3 项验证前面几章把所有关键模块都拆解了一遍但这套源码本质还是单机 Demo 结构。要让它在线上稳定跑起来需要一份清晰的改造清单和验证路径这章就来收口。6.1 改造清单把纯前端逻辑搬到服务端的 5 个关键动作第一把「创建房间校验房卡数量」的逻辑整体抽离到服务端接口客户端只保留扣费结果的展示。第二把胡牌判断复制一份到服务端客户端负责发送操作码服务端校验后广播结果防止客户端算出的胡牌在多人仲裁时产生分歧。第三建立统一的netFlow消息调度层把 WebSocket 的收发、断线缓存、快照拉取独立成模块。第四对game_record表和card_flow表加索引按room_id和uid高频查询。第五引入热更新资源服务器将变动的场景和脚本走远程资源加载避免每次改玩法都要重新审核发版。这五件事做完这个源码才算从「能打开」进化为「能运行」。顺序上先做第 2 项和第 3 项这两项决定线上稳定性第 1 项涉及经济安全优先级最高。6.2 三项验证双端对局、异常流程、压力测试改造完的工程建议至少跑三遍验证流程。第一遍是双端模拟同一账号在 A 设备创建房间B 设备输入房卡号加入正常打满 8 局检查房卡是否按配置扣除。第二遍是异常流程中途杀进程模拟断线重启后确认快照恢复、不重牌、不倒计时错乱。第三遍是短时间压力测试循环创建房间、解散房间观察服务端连接数和内存是否有泄漏趋势。每轮验证都要记录card_flow表的变化。正常的账本应该是创建房间瞬间产生一笔负数流水打满局数后不再产生额外扣费。如果出现多次负数流水说明客户端重复提交了创建请求需要在服务端做幂等控制——同一个请求包的唯一 ID 只允许处理一次。这套源码拿下来之后我也走过不少弯路。最开始总觉得拿到源码马上就能改一版上线实际都在最基本的状态同步上反复打磨。现在养成一个习惯拿到任何 Cocos 棋牌源码先花半小时把协议链路和房卡流水画出来再开始动手改。这个习惯让后面返工的次数少了大半希望对你也有帮助。本文还有配套的精品资源点击获取