简介:这份源码资源面向具备一定游戏开发基础、希望研究多人实时对战架构的开发者,提供基于CocosCreator引擎的房卡麻将完整客户端与服务端实现。项目围绕房卡模式展开,涵盖房间创建、邀请对战、房卡消耗、网络同步、用户认证与计分结算等核心环节,适合作为学习CocosCreator游戏开发流程与Node.js服务端设计的实践案例。压缩包为zip格式,整体约16.43MB,源码目录按资源、脚本、场景等模块组织,客户端脚本负责游戏逻辑、UI交互与动画处理,服务端则涉及网络通信、房间管理与数据存储,便于按模块对照研读。目前已有2240人学习下载,读者可从中理解JavaScript游戏逻辑编程、WebSocket实时通信、数据库操作及麻将规则算法的落地方式,并借鉴房卡麻将的运营机制与工程结构,提升多人在线游戏的开发与排错能力。
1. 房卡麻将这套源码,到底能不能拿来直接做二次开发
如果你手上正拿着一份 cocoscreator 房卡麻将 客户端+服务端 源码,第一反应大概率是:这东西能不能跑起来、能不能改、能不能上线。我见过太多团队在这上面翻车——有人以为拿到源码就等于拿到产品,结果客户端能跑、服务端连不上;有人把房卡模式当成普通棋牌改,改到一半发现结算逻辑全在服务端硬编码里。房卡麻将和普通麻将最大的区别不在玩法,而在“房间”这个核心对象:谁开房、谁进房、房卡怎么扣、打完几局怎么解散、掉线了怎么托管,这一整套流程是客户端和服务端强耦合的。这份源码的价值在于它把网络同步、房间状态机、结算校验这三块最难啃的骨头搭好了骨架,你要做的是换皮、调规则、接支付。适合谁?适合已经有棋牌运营经验、手里有合规资质、想省掉从零搭框架那三到六个月的人。不适合谁?不适合想拿源码直接上线、连服务端部署都没碰过的新手,因为这套东西的坑全在联调和状态同步上,不在界面。
2. 先看懂这套源码的骨架:客户端与服务端各自管什么
2.1 客户端在 cocoscreator 里负责的三件事
拿到源码先别急着改代码,先跑一遍目录结构。cocoscreator 项目的 assets 下一般会分几个大块:场景文件、预制体、脚本、资源图集。房卡麻将的客户端核心脚本通常集中在assets/scripts/game和assets/scripts/net两个目录。前者管牌桌表现——发牌动画、出牌、吃碰杠胡的按钮状态;后者管网络——WebSocket 或 Socket 的连接、心跳、消息编解码。
你要先确认一件事:客户端是不是“薄客户端”。所谓薄客户端,就是所有规则判定都不在本地做,本地只负责把玩家操作发给服务端,等服务端回包再播动画。房卡麻将必须这么做,否则改个本地牌型就能胡牌。判断方法很简单,搜一下客户端里有没有完整的胡牌判定算法,如果有,那这份源码的架构就有问题,你得把判定逻辑挪走或者至少做二次校验。
// assets/scripts/net/SocketManager.js 典型结构 const SocketManager = { ws: null, // 心跳间隔,房卡麻将一般 5~10 秒,太短费电,太长掉线检测慢 heartbeatInterval: 8000, reconnectMax: 3, connect(url, token) { this.ws = new WebSocket(url); this.ws.onopen = () => { // 连上后第一件事是鉴权,不是直接进房 this.send({ cmd: 'auth', token: token }); this.startHeartbeat(); }; this.ws.onmessage = (evt) => { const msg = JSON.parse(evt.data); // 按 cmd 分发到各业务模块,别在这里写业务逻辑 MessageDispatcher.dispatch(msg); }; this.ws.onclose = () => this.tryReconnect(); }, startHeartbeat() { this.timer = setInterval(() => { this.send({ cmd: 'ping', t: Date.now() }); }, this.heartbeatInterval); } };这段代码的关键参数是heartbeatInterval和reconnectMax。心跳间隔设 8 秒是常见做法,因为移动网络下 NAT 超时通常在 30 秒到 5 分钟,8 秒能保证连接不被中间设备掐断。重连次数别设太大,3 次够了,超过 3 次说明网络环境已经不适合继续游戏,应该弹提示让玩家重进。逻辑说明:鉴权必须在 onopen 里第一时间发,不能等玩家点进房再发,否则服务端无法把连接和玩家绑定,后面所有房间消息都会丢。
2.2 服务端房间状态机才是真正的心脏
服务端的目录结构因语言而异,常见的是 Node.js 或 Java。不管哪种,你都要找到“房间”这个类。房卡麻将的服务端核心是一个房间状态机,状态大致是:等待中、发牌中、出牌中、结算中、已解散。每个状态能接收的消息是固定的,比如“等待中”只能收加入房间和准备,“出牌中”只能收出牌和操作。
// server/room/Room.js 状态机片段 const ROOM_STATE = { WAITING: 'waiting', PLAYING: 'playing', SETTLE: 'settle', DISMISS: 'dismiss' }; class Room { constructor(config) { this.roomId = config.roomId; this.state = ROOM_STATE.WAITING; // 房卡扣除时机:创建房间时扣,不是开局时扣 this.cardCost = config.cardCost || 1; this.players = []; this.roundTotal = config.roundTotal || 8; // 常见 8 局或 16 局 this.roundCurrent = 0; } onMessage(player, msg) { // 状态不匹配直接丢弃,这是防作弊的第一道门 if (!this.canHandle(msg.cmd)) { return this.sendError(player, 'invalid_state'); } this.handlers[msg.cmd].call(this, player, msg); } canHandle(cmd) { const allowMap = { [ROOM_STATE.WAITING]: ['join', 'ready', 'leave'], [ROOM_STATE.PLAYING]: ['discard', 'action', 'trustee'], [ROOM_STATE.SETTLE]: ['ready_next', 'leave'] }; return (allowMap[this.state] || []).includes(cmd); } }这段代码里最值得你盯住的是canHandle。很多二手源码在这里偷懒,只判断玩家在不在房间里,不判断状态,结果就是玩家在结算阶段还能发出牌消息,服务端一处理就崩。参数上,roundTotal决定一局房卡能打几圈,8 局是主流,16 局适合熟人局。cardCost是开房扣的房卡数,这个值必须和数据库扣卡逻辑在同一个事务里,否则会出现扣了卡没开成房,或者开了房没扣卡。
2.3 客户端和服务端的消息协议要对齐
联调阶段最耗时的就是消息对不上。客户端发{cmd:'discard', tile:5},服务端读的是msg.tileId,这种低级错误能查半天。我的习惯是先把协议表拉出来,用一张表锁死字段名和类型,客户端和服务端各生成一份常量。
| 消息 cmd | 方向 | 关键字段 | 说明 |
|---|---|---|---|
| auth | C→S | token | 连接后立即发 |
| join | C→S | roomId | 加入指定房间 |
| ready | C→S | 无 | 准备状态 |
| deal | S→C | tiles, seat | 发牌,tiles 是手牌数组 |
| discard | C→S | tile | 出牌 |
| action | C→S | type, tile | 吃碰杠胡 |
| settle | S→C | scores | 结算分数 |
协议表定下来之后,客户端和服务端各自写一个编解码层,业务代码只碰对象,不碰 JSON 字符串。这样后面换协议格式(比如从 JSON 换 protobuf)只改编解码层,不动业务。
3. 把源码跑起来:本地联调的最小步骤
3.1 服务端启动与数据库准备
先看服务端有没有 README 或者 config 目录。通常需要三样东西:运行环境、数据库、配置文件。以 Node.js 服务端为例,常见依赖是 mysql 和 redis。redis 用来存房间快照和 session,mysql 存玩家账号和战绩。
# 1. 安装依赖 npm install # 2. 建库,字符集用 utf8mb4,别用 utf8,否则表情昵称会乱码 mysql -u root -p -e "CREATE DATABASE majiang DEFAULT CHARSET utf8mb4;" # 3. 导入表结构,sql 文件一般在 server/sql 下 mysql -u root -p majiang < server/sql/init.sql # 4. 改配置,数据库和 redis 地址 vim server/config/default.json配置文件里重点看三个值:db.host、redis.port、game.port。game.port是 WebSocket 监听端口,客户端连的就是这个。启动命令一般是npm run start或node app.js。启动后看日志有没有room server listening on 7001这类输出,有就说明服务端起来了。
3.2 客户端在 cocoscreator 里改连接地址
用 cocoscreator 打开客户端项目,找到网络配置。常见位置是assets/scripts/config/GameConfig.js或者直接在 SocketManager 里写死。把地址改成你本机的 IP,注意不能用 localhost,因为模拟器或真机访问不到。
// assets/scripts/config/GameConfig.js export const GameConfig = { // 本地联调用局域网 IP,真机调试必须用这个 serverUrl: 'ws://192.168.1.100:7001', // 平台标识,服务端可能根据这个走不同逻辑 platform: 'dev', // 是否打印协议日志,联调阶段开,上线关 debugProtocol: true };改完在 cocoscreator 里点预览,看控制台有没有auth success或类似的回包。如果没有,先查服务端日志有没有收到连接,再查客户端有没有报跨域或连接被拒。WebSocket 没有跨域问题,但端口不通会直接报错。
3.3 开一局的最小验证路径
联调不要一上来就四个人打,先用两个客户端加一个机器人。步骤是:客户端 A 创建房间,拿到 roomId;客户端 B 用 roomId 加入;服务端补两个机器人凑齐四人;两边都点准备;服务端发牌;A 出牌,看 B 那边有没有同步显示。
// 服务端补机器人的常见做法 function fillRobots(room) { while (room.players.length < 4) { const robot = createRobotPlayer(); // 机器人座位号要按顺序补,不能随机,否则座位错乱 robot.seat = room.players.length; room.players.push(robot); } // 人满后自动开局,房卡麻将一般不需要所有人准备 if (room.players.length === 4) { room.startGame(); } }这段逻辑里robot.seat必须按顺序补,我见过随机补座位的源码,结果机器人坐在了已有玩家的位置上,发牌直接覆盖。验证同步就看一件事:A 出牌后,B 的牌桌上那张牌有没有消失,B 的手牌有没有更新。如果 B 没反应,查服务端有没有把 discard 消息广播给房间内所有人,而不是只回给发送者。
4. 二次开发绕不开的四个改造点
4.1 换皮:从图集到音效的替换顺序
换皮不是把图片换掉就完事。cocoscreator 里图片资源被预制体和图集引用,直接删图会导致预制体报错。正确顺序是:先替换图集里的单图,保持文件名和尺寸不变;再改预制体上的颜色和字体;最后换音效。图集替换时注意 plist 文件里的坐标,如果你换了尺寸,plist 要重新生成。
# 用 texturepacker 重新打图集的常见命令 TexturePacker --sheet ui.png --data ui.plist --format cocos2d \ --max-size 2048 --trim-mode None assets/ui_raw--max-size 2048是移动端安全值,超过 2048 有些老机型加载不了。--trim-mode None保证每张图的坐标和原始一致,避免预制体里的引用错位。
4.2 改规则:胡牌判定和番型计算在哪改
房卡麻将的规则改动集中在两个文件:胡牌判定和番型计算。胡牌判定通常在服务端的HuChecker或类似类里,番型计算在ScoreCalculator。改之前先确认客户端有没有同步的判定逻辑,如果有,两边都要改,否则会出现客户端显示能胡、服务端判不能胡的玄学问题。
// server/game/HuChecker.js 七对判定片段 function isSevenPairs(tiles) { // tiles 是排序后的手牌数组 let pairCount = 0; const countMap = {}; for (const t of tiles) { countMap[t] = (countMap[t] || 0) + 1; } for (const k in countMap) { if (countMap[k] === 2) pairCount++; // 四个算两对,这是七对里最容易漏的 else if (countMap[k] === 4) pairCount += 2; else return false; } return pairCount === 7; }这段代码里countMap[k] === 4的处理是血泪经验,很多源码只判了 2,结果豪华七对判不出来。参数上,tiles必须是排序后的,否则某些依赖顺序的判定会出错。
4.3 接支付:房卡充值的回调怎么保证不丢单
支付回调是房卡麻将最容易出资金问题的地方。核心原则:回调必须幂等,同一笔订单多次回调只能加一次房卡。做法是在数据库建一张订单表,用订单号做唯一索引,回调时先 insert,插入成功才加卡,插入冲突就直接返回成功。
-- 订单表结构,order_no 唯一索引是防重复的关键 CREATE TABLE `order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL, `player_id` bigint NOT NULL, `card_num` int NOT NULL, `status` tinyint DEFAULT 0, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;回调处理时用INSERT IGNORE或捕获唯一键冲突,冲突就说明已经处理过,直接返回成功给支付方,避免支付方一直重试。
4.4 加托管:掉线后怎么让机器人接管
托管逻辑分两块:检测掉线和接管出牌。检测靠心跳超时,服务端在心跳表里记最后一次 ping 时间,超过 15 秒没 ping 就标记为掉线。接管出牌时,机器人按最简单的策略打——打孤张、留对子,不要写太复杂,托管只是过渡,不是 AI。
// server/game/Trustee.js 简单出牌策略 function trusteeDiscard(handTiles) { // 优先打孤张,即没有相邻牌也没有对子的牌 const sorted = handTiles.slice().sort((a, b) => a - b); for (const t of sorted) { const hasNeighbor = sorted.includes(t - 1) || sorted.includes(t + 1); const hasPair = sorted.filter(x => x === t).length >= 2; if (!hasNeighbor && !hasPair) return t; } // 全是关联牌就打最小的 return sorted[0]; }托管出牌必须走和服务端正常出牌一样的校验流程,不能绕过,否则托管状态下可能打出非法牌。
5. 避坑与排查:联调阶段最常见的五个翻车现场
5.1 客户端能连上但收不到发牌消息
现象是客户端 auth 成功,进房也成功,但服务端发牌后客户端没反应。原因通常是消息分发没注册 deal 命令,或者注册的 cmd 名字和服务端发的不一致。解决方法是打开 debugProtocol,把收到的原始消息打出来,对比协议表。我一般会在 MessageDispatcher 里加一行日志,把所有未注册的 cmd 打出来,这样一眼就能看到哪个消息没接住。
5.2 四个人准备后只发了三个人的牌
现象是房间显示四人,发牌后有一个座位空着。原因是服务端补机器人时座位号重复,或者玩家加入时座位分配用了数组长度但没考虑已有人退出。解决方法是座位分配用一个独立的 seat 池,0 到 3 依次分配,退出后回收。排查时看服务端日志里每个玩家的 seat 值,有重复就是这个问题。
5.3 结算分数对不上,客户端显示赢服务端显示输
现象是同一局,客户端算出来是 A 胡,服务端算出来是 B 胡。原因是客户端和服务端各有一套胡牌判定,且规则不一致。解决方法是把判定统一到服务端,客户端只做展示。如果客户端必须做预判,那预判结果只能用于 UI 提示,最终以服务端为准。排查时把两边的判定输入和输出都打日志,对比差异。
5.4 房卡扣了但房间没开起来
现象是玩家点创建房间,房卡扣了,但没进房间。原因是扣卡和创建房间不在同一个事务里,扣卡成功但创建房间失败。解决方法是把扣卡和创建房间放在一个数据库事务里,或者先创建房间再扣卡,扣卡失败就解散房间。排查时看订单表或流水表,有没有扣卡记录但没有对应的房间记录。
5.5 真机上打几局就断线重连
现象是模拟器正常,真机打几局就掉线。原因是心跳间隔太长,移动网络下 NAT 超时被掐断。解决方法是在真机调试时把心跳间隔从 8 秒改成 5 秒,同时开启 WebSocket 的自动重连。另外检查客户端有没有在切后台时暂停心跳,切后台应该继续发心跳或者主动断开,避免服务端误判掉线。
6. 上线前必须做的三件事和一个压测技巧
第一件事是关掉所有 debug 开关。客户端和服务端的 debugProtocol、debugLog 全部关掉,否则日志量在线上能把磁盘写满。第二件事是检查房卡扣减的幂等性,用脚本模拟同一笔订单回调十次,看房卡只加一次。第三件事是把服务端的房间快照落 redis,进程重启后能恢复房间,不然一重启所有进行中的局全没了。
压测技巧:不要用真实客户端压,用脚本模拟 WebSocket 连接。写一个 Node.js 脚本,开 500 个连接,每个连接走 auth、join、ready、出牌流程,看服务端 CPU 和内存。重点看房间数到 200 以上时,广播消息的耗时有没有明显上升。如果上升明显,说明广播是遍历所有房间的,要改成按房间 ID 索引。
// 压测脚本片段,模拟玩家出牌 const WebSocket = require('ws'); const clients = []; for (let i = 0; i < 500; i++) { const ws = new WebSocket('ws://127.0.0.1:7001'); ws.on('open', () => { ws.send(JSON.stringify({ cmd: 'auth', token: 'test_' + i })); // 鉴权后延迟进房,避免瞬间冲击 setTimeout(() => { ws.send(JSON.stringify({ cmd: 'join', roomId: 'test_room' })); }, 100 + i * 10); }); clients.push(ws); }这个脚本里100 + i * 10是错峰进房,避免 500 个连接同时 join 把服务端打挂。压测时观察服务端日志有没有room not found或player already in room,有就说明房间管理有并发问题。
我自己的习惯是,拿到任何一份棋牌源码,先不读业务代码,先跑通一局,再抓包看协议,最后才动规则。这套顺序能帮你省掉至少一周的瞎改时间。希望帮到你。
本文还有配套的精品资源,点击获取