ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cocos Creator房卡麻将源码全链路拆解:架构、出牌与重连

Cocos Creator房卡麻将源码全链路拆解:架构、出牌与重连

简介:这是一套基于 CocosCreator 开发的房卡麻将客户端与服务端完整源码,面向希望研究多人棋牌游戏实现的中级开发者,可用于理解房间创建、房卡消耗、邀请对战、网络同步与计分结算等核心流程。压缩包约 16.43MB,文件总数及类型明细上游暂未提供;从工程描述看,内容覆盖客户端脚本、服务端通信、资源素材与项目配置等部分,工程一般按资源、脚本、场景等模块组织,便于梳理客户端表现层与服务端逻辑层的协作方式。已有 2248 人学习,说明该案例在棋牌类项目学习中具有一定参考热度。读者可重点研读 CocosCreator 场景组织、JavaScript 游戏逻辑、麻将牌型运算,以及服务端房间管理、WebSocket 实时通信和用户数据存储等实现,也可将其作为课程设计或棋牌项目原型的对照样本,仅建议用于学习参考。

1. 周五晚上的那次掉线,让我把 cocoscreator 房卡麻将源码翻了三遍

朋友组了个四人局,第三把刚听牌,客户端卡在出牌动画上不动了,服务端那边已经把这张牌算进了河牌,重进之后手牌还是上一轮的。这种「客户端看到的和服务端算的对不上」的问题,在 cocoscreator 房卡麻将 这类项目里几乎是最典型的一类 bug,也是我后来决定把整套客户端加服务端源码逐行拆一遍的直接原因。这份资源给的是端到端的东西:Cocos Creator 做的客户端、房间服和大厅服的逻辑、牌局状态机、发牌与胡牌判定、断线重连快照、战绩落库,能直接跑出一桌四人的完整流程。适合两类人:一类是想搞明白棋牌类房间服到底怎么分进程、怎么保证服务器权威的开发者;另一类是拿到一套地方麻将变种需求,需要一个能改规则、能换牌型的底座。下面按「是什么、怎么跑、核心链路、怎么改」的顺序往下拆。

2. 架构与选型:从 Cocos Creator 客户端到房间服的一条完整链路

先把技术栈摊开说清楚。客户端是 Cocos Creator + TypeScript,服务端主流的两种版本是 Node.js 和 Java,这套源码里用 Node.js 的比例更高(我看过的几个版本都是 Express/ws + Redis + MySQL 的组合)。如果你手上拿到的是 Java 版,分层思路完全一样,只是把下面讲的 ws 服务换成 Netty 的 Handler 而已。整体是典型的「网关 - 大厅 - 房间」三段式,不是单体把所有逻辑塞一个进程里。

2.1 客户端为什么选 Cocos Creator 而不是原生引擎

棋牌类项目的客户端有一个很硬的约束:它得同时跑在 H5、安卓、iOS,很多还要塞进小游戏平台。Cocos Creator 一套 TS 代码三端发布,这是它在这类项目里最大的价值点。牌面本身是图集 + Sprite,动画用 Tween 做位移和缩放就够了,不需要 3D 能力。

真正的成本在版本差异上。2.x 和 3.x 的 API 断代很厉害:2.x 加载资源是cc.resources.load,3.x 换成了resources.load走 assetManager,节点查找、动画组件、事件系统也都不一样。你拿到的源码如果是 2.4.x 写的,用 3.8 的编辑器打开会一片红,这不是代码错,是引擎断层。我一般会先看项目根目录的project.json(2.x)或者package.json里的creator.version字段(3.x),再决定装哪个版本的编辑器,别一上来就下最新版,那是最容易翻车的第一步。

另一个选型点是网络层。Cocos Creator 原生提供WebSocket,H5 和小游戏平台都能用,所以这类项目基本不会去引 socket.io,直接用裸 WebSocket 加自定义协议头,包体小、可控性强。代价是心跳、重连、粘包处理全部得自己写,这也是后面第 4 章要重点拆的部分。

2.2 服务端三层:网关、大厅、房间各管什么

这三层的职责边界如果划不清楚,后面加功能会非常难受。常见的划分是这样:

层职责是否存状态
网关 gateway维持长连接、心跳、token 鉴权、消息转发、限流只存连接与玩家 id 的映射
大厅 hall登录、玩家信息、房卡余额、开房/加入房、战绩查询、邮件无长连接状态,走 Redis/MySQL
房间 room牌局状态机、发牌、出牌校验、结算、定时器、托管全量内存态,定期快照到 Redis

网关不碰业务,这是铁律。它只做一件事:收到客户端消息,按cmd路由到对应服务,收到服务的下行再按玩家 id 找到连接推回去。房间进程是重状态的地方,一桌牌的全部信息都在内存里,Redis 里只存一份用于崩溃恢复和跨进程查找的快照。

房间进程的分配方式,常见做法是按roomId做哈希,保证同一房间的所有消息永远落到同一个进程,省掉分布式锁。玩家数量上来之后,房间进程可以水平扩,网关那边维护一张「roomId → 进程地址」的路由表,Redis 里存一份共享版本。这套设计的好处是房间内逻辑可以是纯单线程的,不用考虑并发写牌墙的问题。

2.3 协议设计:一个 cmd 主号 + 子号的扁平结构

棋牌项目的消息量不大但类型杂,用 protobuf 有点重,绝大多数版本用的是 JSON,靠cmd主号分类、sub子号区分动作。协议定义一般长这样:

// assets/scripts/net/Protocol.ts // 主号按业务域拆分,一个域一个号段,方便后面按号段做权限和日志过滤 export enum Cmd { Login = 1001, // 登录、鉴权、重连 Hall = 1002, // 大厅:开房、进房、房卡、战绩 Room = 1003, // 房间内:坐下、准备、解散、托管 Game = 1004, // 牌局:发牌、出牌、碰杠胡、结算 Heartbeat = 1005, // 心跳,独立号段便于网关直接拦截处理 } // 牌局子号:服务端和客户端共用同一份枚举,改协议时两边一起改 export enum GameSub { Deal = 1, // 发牌,下行 Discard = 2, // 出牌,上行 + 下行广播 Action = 3, // 碰/杠/胡/过,上行 + 下行 Result = 4, // 单局结算,下行 Sync = 5, // 重连快照,下行 } export interface MsgPack<T = any> { cmd: number; // 主号,对应 Cmd sub: number; // 子号,对应 GameSub 等 seq: number; // 客户端自增序号,用于请求-应答配对 code?: number; // 服务端返回码,0 成功,非 0 为错误码 data: T; // 业务负载 }

seq这个字段容易被忽略,但很关键。客户端发一条出牌请求,服务端可能回「校验失败」,也可能回「成功 + 广播」,两者都带着同一个seq。客户端维护一个待应答的 Map,超时没有对应seq回来就重发或提示。没有这个字段,弱网下就会出现「点了没反应,其实服务端已经处理了」,玩家再点一次,出两张牌。

code字段放在协议头而不是 data 里,是为了让网关能在不解包业务的情况下做统一错误日志。实际项目里我会把错误码单独抽一个枚举文件,客户端和服务端各维护一份,靠构建脚本比对一致性,不然改着改着两边就对不上了。

3. 目录结构与启动流程:把客户端和服务端分别跑起来

拿到源码包第一件事不是打开编辑器,是先看目录。这类项目的目录结构有很强的共性,摸清了就知道该改哪个文件。

3.1 目录拆解:哪个文件夹管什么

典型的目录长这样,你对照自己手上的版本核一下:

project/ ├── client/ # Cocos Creator 工程根目录 │ ├── assets/ │ │ ├── scripts/ │ │ │ ├── net/ # 网络层:连接、协议、消息分发 │ │ │ ├── hall/ # 大厅界面逻辑 │ │ │ ├── game/ # 牌桌界面、手牌管理、动画 │ │ │ └── common/ # 工具类、事件总线、音效 │ │ ├── resources/ # 动态加载的图集、预制体 │ │ └── scenes/ # Login / Hall / Game 三个主场景 │ └── settings/ # 构建配置、平台参数 ├── server/ │ ├── app/ │ │ ├── gateway/ # ws 连接管理、心跳、鉴权 │ │ ├── hall/ # 登录、开房、战绩 │ │ ├── room/ # 房间逻辑、状态机 │ │ └── game/ # 牌型判定、发牌算法 │ ├── config/ # 服务端配置:端口、DB、房间规则 │ └── app.js # 入口 ├── config/ # 客户端和服务端共用的玩法表(JSON/Excel 导出) └── tools/ # 导表脚本、压测脚本、日志分析

config/这一层是重点。房卡麻将的玩法差异(几人局、带不带风牌、能不能吃、几个癞子)如果全写死在代码里,加一个地方变种就要改一遍判定逻辑,后面会非常痛苦。规范的版本会把这些做成表,服务端开房时读表生成rule对象,判定函数只认rule不认具体玩法。

3.2 服务端:依赖装好、配置改对、进程起来

服务端跑不起来,九成是环境问题,不是代码问题。按这个顺序来:

# 1. 确认 Node 版本,老项目大多锁在 14.x / 16.x,直接上 20 可能编译不过 node -v # 2. 装依赖,注意有些版本带了原生模块(如 bcrypt),Windows 下需要编译工具链 cd server npm install # 3. 起 Redis 和 MySQL,房间快照和战绩分别落这两边 redis-server --port 6379 mysql -u root -p -e "CREATE DATABASE majiang DEFAULT CHARSET utf8mb4;" # 4. 导入初始表结构,一般 tools/ 或 server/sql/ 下会有 .sql 文件 mysql -u root -p majiang < server/sql/init.sql # 5. 改配置:数据库、Redis、对外监听地址 vim server/config/config.json # 6. 启动 node server/app.js

第六步之前建议先把config.json里的日志级别调到 debug,启动时会打印每个服务监听的端口。正常起来应该是网关一个端口(比如 9001)、大厅 HTTP 一个端口(9002)、房间进程若干端口(9010 起)。如果只打印了网关没打印房间,多半是房间进程的启动参数里读了 Redis 里的进程注册表但没连上,去看 Redis 连接那行日志。

3.3 客户端:版本对上,地址改对,预览跑通

客户端这边有两个必改项。第一个是assets/scripts/net/NetConfig.ts(有的版本叫Const.ts或GameConfig.ts)里的服务器地址:

// assets/scripts/net/NetConfig.ts export const NetConfig = { // 本地联调指向自己机器的网关端口;真机调试要改成局域网 IP,不能写 127.0.0.1 wsUrl: 'ws://192.168.1.20:9001/ws', // 大厅的 HTTP 接口,用于登录和拉战绩 httpUrl: 'http://192.168.1.20:9002/api', // 心跳间隔,服务端一般 30s 断连,客户端要小于这个值 heartbeatInterval: 15000, // 请求超时,超时后走重发或提示 requestTimeout: 8000, };

第二个是引擎版本。前面说过,看project.json或package.json里的 creator 版本号,然后在 Cocos Dashboard 里装对应版本的编辑器,用「添加项目」指向client/目录打开。打开之后先别急着运行,在编辑器里点一下场景,看 Console 有没有报「资源丢失」的红字——预制体引用断掉在老项目里特别常见,尤其是图集被重新打包过的情况。

预览跑通的标准是:浏览器打开预览页,输入账号密码,能进大厅,能看到开房按钮。这一步不涉及牌局逻辑,纯登录链路。

3.4 从开房到胡牌:一条最小验证路径

把整条链路验一遍,比逐个模块单测有效得多。按这个顺序走:

  1. 浏览器开四个标签页(或者一个浏览器 + 三个无痕窗口),分别登录四个测试账号。测试账号一般在server/sql/init.sql里预埋了。
  2. 账号 A 点开房,选规则(比如 4 人局、8 局、带风牌),确认后服务端应该给 A 返回roomId,同时创建一个 Room 实例。
  3. 另外三个账号在大厅输入roomId进房,四个座位坐满之后房主点开始。
  4. 观察服务端日志:应该依次打印「发牌」「庄家 = seat 0」「等待出牌」。客户端这边应该看到手牌 13 张(庄家 14 张)。
  5. 庄家随便出一张,看其余三家能不能收到「广播出牌」的下行消息。
  6. 一直打到有人胡牌,看结算界面和数据库game_record表里有没有落一条战绩。

这条路径走通,说明协议、状态机、结算三条线都是通的。走不通的时候,卡在哪一步就把那一步的日志单独拎出来看,别一次改好几个地方。

4. 核心模块拆解:一次出牌从点击到广播的完整链路

这章按消息流向拆,从客户端点击出牌开始,一路跟到服务端广播出去,中间经过的四五个环节都会贴代码。

4.1 客户端网络层:心跳、重连、消息分发

网络层是所有客户端 bug 的高发区。一套能用的实现至少要有三件事:连接建立后的鉴权、定时心跳、断线后的退避重连。

// assets/scripts/net/NetManager.ts import { Cmd, GameSub, MsgPack } from './Protocol'; import { NetConfig } from './NetConfig'; export class NetManager { private static _inst: NetManager; static get inst() { return this._inst || (this._inst = new NetManager()); } private ws: WebSocket | null = null; private seq = 1; private handlers = new Map<string, (data: any) => void>(); private hbTimer = 0; private retry = 0; private url = ''; private token = ''; connect(url: string, token: string) { this.url = url; this.token = token; this.ws = new WebSocket(url); this.ws.onopen = () => { this.retry = 0; // 建连第一件事是鉴权,不然网关不知道这条连接属于谁 this.send(Cmd.Login, 1, { token }); this.startHeartbeat(); }; this.ws.onmessage = (ev: MessageEvent) => { let pack: MsgPack; try { pack = JSON.parse(ev.data as string); } catch (e) { console.warn('[net] 包解析失败', ev.data); return; } // 心跳回包直接吞掉,不进业务分发 if (pack.cmd === Cmd.Heartbeat) return; const key = `${pack.cmd}_${pack.sub}`; const fn = this.handlers.get(key); if (fn) fn(pack.data); }; this.ws.onclose = () => { this.stopHeartbeat(); // 指数退避重连,最多 5 次,避免服务端挂了被客户端打成 DDoS if (this.retry < 5) { const delay = Math.min(1000 * Math.pow(2, this.retry), 15000); this.retry++; setTimeout(() => this.connect(this.url, this.token), delay); } }; } private startHeartbeat() { this.hbTimer = setInterval(() => { // 心跳带一个递增时间戳,服务端可用来算 RTT this.send(Cmd.Heartbeat, 0, { t: Date.now() }); }, NetConfig.heartbeatInterval) as unknown as number; } private stopHeartbeat() { if (this.hbTimer) { clearInterval(this.hbTimer); this.hbTimer = 0; } } send(cmd: number, sub: number, data: any) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; const pack: MsgPack = { cmd, sub, seq: this.seq++, data }; this.ws.send(JSON.stringify(pack)); } on(cmd: number, sub: number, cb: (data: any) => void) { this.handlers.set(`${cmd}_${sub}`, cb); } }

关键参数说明一下。heartbeatInterval取 15 秒是有讲究的:服务端一般把 30 秒没收到心跳的连接判死,客户端取一半,留出一次丢包的容错空间。retry上限 5 次配合指数退避,是为了避免服务端重启时几百个客户端同时重连把新进程打挂。onclose里没有立刻重连而是走setTimeout,也是这个原因。

handlers用cmd_sub拼 key,好处是不用写 switch。但要注意同一个cmd_sub只能注册一个回调,如果牌桌和大厅都监听Game.Result,后注册的会覆盖前面的。实际项目里我会在on里加一层判断,重复注册时打警告,不然这种覆盖问题排查起来非常玄学。

4.2 出牌交互:点击到上行的完整封装

客户端出牌不是拿到点击就直接发,中间要有本地校验和动画预约,不然会出现「发了但不合法被退回」的糟糕体验。

// assets/scripts/game/HandController.ts import { NetManager } from '../net/NetManager'; import { Cmd, GameSub } from '../net/Protocol'; export class HandController { private selectedTile: number = -1; // 当前选中的手牌索引,-1 表示未选 private locked = false; // 出牌锁,防止连点发两次 onTileClick(index: number) { if (this.locked) return; if (this.selectedTile === index) { // 再点一次同一张牌 = 确认出牌 this.doDiscard(index); return; } this.selectedTile = index; this.refreshHighlight(); // 抬高手牌、显示出牌按钮 } private doDiscard(index: number) { const tile = this.hand[index]; // 本地先判一次:不能出刚摸的那张以外的牌(依玩法而定),也不能出空 if (tile == null) return; this.locked = true; // 先播抬牌动画,动画时长 200ms,和后面的服务端回包并行,减少感知延迟 this.playPickAnim(index); NetManager.inst.send(Cmd.Game, GameSub.Discard, { tile, // 牌的编码:花色 << 4 | 点数 from: index, // 手牌下标,服务端用它做二次校验 }); } // 服务端广播回来后调用,解除锁定 onDiscardConfirmed() { this.locked = false; this.selectedTile = -1; } // 被服务端拒绝时调用,回滚动画并提示 onDiscardRejected(code: number) { this.locked = false; this.playRollbackAnim(); console.warn('[game] 出牌被拒', code); } }

locked这个标志位是血泪经验。弱网环境下手感延迟很明显,玩家会连点,没有锁就是发两条出牌请求,服务端如果没做幂等,直接出两张。服务端的幂等后面 4.3 会讲。

牌的编码方式值得单独说一句:用位运算花色 << 4 | 点数把一张牌压成一个整数,比传{suit: 1, point: 5}这种对象省一半带宽,而且在服务端做排序、去重、判胡的时候非常方便。0-8 是万,9-17 是条,18-26 是筒,27 起是风牌和箭牌,这是通用编码。

4.3 服务端房间上下文:出牌校验与广播

服务端收到出牌请求,必须做三件事:校验合法性、更新状态、广播给所有人(包括出牌者自己,保证表现一致)。

// server/app/room/Room.js const { canDiscard } = require('../game/rule'); class Room { constructor(roomId, rule) { this.roomId = roomId; this.rule = rule; // 玩法配置,从 config/ 表里读出来 this.seats = []; // 下标即座位号,0 号位为庄 this.state = 'WAITING'; // WAITING / PLAYING / SETTLE / CLOSED this.wall = []; // 牌墙,发牌时初始化 this.turn = 0; // 当前该谁出牌 this.version = 0; // 状态版本号,每次变更 +1,重连时比对用 this.lastDiscard = null; // 最后一张打出的牌,判碰杠用 } onDiscard(seatId, tile, fromIndex) { // 1. 基础校验:状态、轮次、座位 if (this.state !== 'PLAYING') return this.reject(seatId, 4001, '非牌局中'); if (seatId !== this.turn) return this.reject(seatId, 4002, '未轮到你'); const seat = this.seats[seatId]; if (!seat.online) return this.reject(seatId, 4003, '玩家已离线'); // 2. 幂等校验:用 version + 最近一次出牌做去重 // 客户端重发时会带同一个 seq,服务端记录后直接返回上次结果 const dupKey = `${seatId}_${tile}_${this.version}`; if (this._recentDiscards.has(dupKey)) { return this.reply(seatId, 0, this._recentDiscards.get(dupKey)); } // 3. 牌在不在手里:不能信客户端传的下标,必须按牌本身找 const idx = seat.hand.indexOf(tile); if (idx < 0) return this.reject(seatId, 4004, '牌不在手中'); // 4. 玩法校验:是否允出手、是否必须打刚摸的牌、是否报听锁定 const err = canDiscard(seat, tile, this.rule); if (err) return this.reject(seatId, 4005, err); // 5. 真正落地:从手牌移除,进河牌,切换轮次 seat.hand.splice(idx, 1); this.discardPool.push({ seat: seatId, tile }); this.lastDiscard = { seat: seatId, tile }; this.turn = (seatId + 1) % this.seats.length; this.version++; // 6. 记录幂等结果,广播给全桌(含出牌者) this._recentDiscards.set(dupKey, { seatId, tile }); this.broadcast('Game', 'Discard', { seat: seatId, tile, turn: this.turn, version: this.version, }); // 7. 推牌墙:该下家摸牌,或者牌墙空了走流局 this.dealNext(); } }

第 3 步那个「按牌本身找下标而不是信客户端」的细节,看起来多余,实际上防住了改包。客户端传上来的fromIndex只能是参考,服务端必须自己定位。同理,tile也必须在服务端的seat.hand里能找到,这两条是防止非法手牌的最低要求。

第 2 步的幂等键用seatId_tile_version三元组,是因为同一个玩家在同一个状态下不可能合法地打出两张一模一样的牌(手里有两张同牌也不行,第一张打出去 version 就变了)。这个设计比给每条消息存一个全局 seq 表要省空间,代价是_recentDiscards需要在换局时清空。

this.turn的推进用取模,简单但要注意跳过离线托管的座位。实际代码里一般是this.nextOnlineSeat(this.turn),托管状态下由服务端代为出牌,但轮次要正常走。

4.4 胡牌判定与结算:递归拆解 + 服务端唯一权威

胡牌判定是这类项目里唯一有点算法味道的地方。标准胡的核心是:把 14 张牌拆成「n 个刻子或顺子 + 1 对将」。

// server/app/game/hu.js // tiles: 已排序的手牌编码数组,长度必须是 3n+2 // 返回是否可胡 function canHu(tiles) { if (tiles.length % 3 !== 2) return false; // 依次尝试每一对作为将牌 for (let i = 0; i < tiles.length - 1; i++) { if (tiles[i] !== tiles[i + 1]) continue; // 只有相同的两张才能做将 if (i > 0 && tiles[i] === tiles[i - 1]) continue; // 跳过重复枚举,减少递归 const rest = tiles.slice(0, i).concat(tiles.slice(i + 2)); if (canSplit(rest)) return true; } return false; } // 判断剩余的 3n 张能否全部拆成刻子或顺子 function canSplit(arr) { if (arr.length === 0) return true; const first = arr[0]; // 尝试刻子:三张相同 if (arr[1] === first && arr[2] === first) { if (canSplit(arr.slice(3))) return true; } // 尝试顺子:连续三张,且不能跨花色 // 花色边界:0-8 万、9-17 条、18-26 筒,27 起为字牌不能组顺 const suit = Math.floor(first / 9); if (first < 27 && arr.indexOf(first + 1) >= 0 && arr.indexOf(first + 2) >= 0) { if (suit === Math.floor((first + 2) / 9)) { const rest = arr.slice(); rest.splice(rest.indexOf(first), 1); rest.splice(rest.indexOf(first + 1), 1); rest.splice(rest.indexOf(first + 2), 1); if (canSplit(rest)) return true; } } return false; } module.exports = { canHu };

这段递归的规模很小(14 张牌,最多几百次分支),单次判定微秒级,不需要记忆化。真正要小心的是七对和十三幺这些特殊牌型,它们不走canSplit的路子,得在canHu入口单独判:

// 七对:14 张牌恰好 7 个对子,且没有四张相同(四张算两对,具体看玩法) function isSevenPairs(tiles) { if (tiles.length !== 14) return false; let pairs = 0; for (let i = 0; i < tiles.length; i += 2) { if (tiles[i] !== tiles[i + 1]) return false; pairs++; } return pairs === 7; }

按玩法配置决定调用顺序:rule.allowSevenPairs为真时先判七对,再判标准胡。这个开关就来自config/里的玩法表,不需要改代码。

结算部分的原则是:客户端只做展示,一切数值以服务端为准。服务端在SETTLE状态下算出四家的输赢分、房卡消耗、战绩记录,一次性下发Result消息,客户端拿到什么显示什么。房卡的扣减要和战绩落库放在同一个事务里,先扣后写、写失败回滚,不然会出现「房卡扣了但战绩没记录」的对账问题。

4.5 断线重连:一份按座位裁剪的快照

回到开头那个场景:玩家掉线重进,怎么保证他看到的手牌是对的?答案是不靠客户端记,靠服务端下发快照。

// server/app/room/Room.js buildSnapshot(seatId) { const me = this.seats[seatId]; return { roomId: this.roomId, state: this.state, version: this.version, // 客户端比对版本号,旧快照直接丢弃 turn: this.turn, dealer: this.dealer, // 自己的手牌全给,别人的只给数量——这是防作弊的底线 hand: me.hand.slice(), melds: me.melds.slice(), // 已碰杠的组合 others: this.seats.map(s => ({ seat: s.seat, online: s.online, handCount: s.hand.length, melds: s.melds, score: s.score, })), wallCount: this.wall.length, discardPool: this.discardPool.slice(), lastDiscard: this.lastDiscard, }; }

version字段是重连的关键。客户端带着本地version来请求重连,服务端比对:如果客户端的版本比服务端旧,就下发全量快照;如果一致,只补发增量(比如只补最后一张出牌)。这样在弱网反复重连的情况下不会每次全量重传。

others里只给handCount不给具体牌,这个裁剪必须在服务端做,不能指望客户端「不显示」。抓包能看到别人手牌的客户端,不管界面藏得多好都是漏洞。

5. 避坑与排查:本地跑这套源码最容易翻车的 5 个点

下面五条是我在这类项目上真实遇到过的,按「现象 → 原因 → 解决」写,你照着对号入座就行。

5.1 客户端一直提示连接失败,控制台只有一句 WebSocket closed

现象:编辑器里预览,点登录转圈,Console 里WebSocket connection to 'ws://127.0.0.1:9001/ws' failed,服务端日志一条连接记录都没有。

原因:三个可能。一是服务端网关只监听了内网地址或者端口不对;二是wsUrl的路径/ws和后端注册的路由不一致(有的版本注册在根路径);三是浏览器预览页是 https,而 ws 没加 s。

解决:先在浏览器地址栏直接访问http://127.0.0.1:9001/,看有没有返回;再用curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" http://127.0.0.1:9001/ws手动握手,返回 101 就说明服务端没问题。服务端起在 0.0.0.0 上,路径按server/config里的wsPath填。真机调试时把127.0.0.1换成局域网 IP,手机和电脑要在同一个网段。

5.2 进桌之后手牌显示错乱,筒条万混在一起或者缺张

现象:牌局开始,手牌区显示的牌和实际应该发到的牌对不上,有的是空白,有的是重复牌。

原因:多半是编码和解码不一致。服务端用花色*9 + 点数编码,客户端按花色 << 4 | 点数解析,两套规则对不上就会全乱。另一个可能是图集里牌的切片顺序和编码顺序不一致,resources/tiles这个图集的子图命名没对上。

解决:先把服务端下发的手牌数组打出来,和客户端解析后的数组对比。编码规则必须全项目统一,我一般会把它写成一个单独的TileCode.ts/tile_code.js,两边引用同一份规则(或者用工具从这里生成另一份)。图集这块,打开预制体看 SpriteFrame 的名字,正常应该是tile_01到tile_34这种有序命名,缺张就是美术资源不全。

5.3 打完一局房卡没扣,或者扣了两次

现象:结算界面显示消耗房卡,但数据库里余额没变;或者余额减了双倍。

原因:没扣是因为扣减逻辑放在客户端或者房间进程的内存里,没走数据库;扣双倍是因为结算消息重发或者房间进程重启后重放了结算。

解决:房卡扣减必须走「数据库事务 + 唯一键」。给game_record表加一个UNIQUE(room_id, round_no),扣减语句写成UPDATE player SET card = card - ? WHERE id = ? AND card >= ?,靠 affectedRows 判断是否扣成功。房间进程重启后从 Redis 恢复快照时,先查这条战绩存不存在,存在就跳过结算。这一条不改,上线之后对账能让人崩溃。

5.4 断线重连回来,手牌是空的但牌局还在继续

现象:玩家断网重连,能进房间,但手牌区一片空白,也不能出牌,只能看着别人打。

原因:客户端重连后只重新登录,没有主动拉快照;或者服务端Sync消息发了,但客户端的handlers里没有注册Game_Sync的处理函数(常见于大厅和牌桌的场景切换时把监听器清了没加回来)。

解决:在登录成功的回调里判断「是否在房间中」,是的话立刻发Cmd.Game / GameSub.Sync请求全量快照。服务端buildSnapshot返回的version要在客户端存一份,后续所有增量消息都校验version,小于本地版本的直接丢弃——这条能顺带解决重连瞬间「旧消息覆盖新状态」的问题。注册监听放在场景onLoad里,onDestroy里反注册,别用全局单例存匿名函数,会漏。

5.5 构建到小游戏平台后,图集和配置表 404

现象:编辑器预览一切正常,构建出来跑,牌面全是白块,控制台一堆Failed to load resource。

原因:动态加载的资源放在resources/之外,或者配置表是用fs读的本地文件——小游戏环境没有文件系统。

解决:所有需要在运行时加载的资源,统一放assets/resources/下,用resources.load按路径加载。配置表在构建前用导表脚本转成 JSON 或 TS 常量,跟着代码一起打包,不要运行时读 Excel 或本地 json 文件。另外小游戏平台对包体有大小限制,图集要提前压缩,麻将牌这种纯色块图用 PNG8 就够了。

6. 二次开发:改玩法、加机器人、做验证

拿到这套源码之后,最省力的改造方向是「配置驱动」而不是「改判定代码」。加一种地方玩法,标准流程是三步:先在config/的玩法表里加一行(人数、带不带风牌、能不能吃、几个癞子、房卡消耗规则),写导表脚本导出成 JSON 和 TS 常量,然后检查canHu和canDiscard这两个函数里有没有硬编码的玩法判断。有就把它换成读rule.xxx的分支。

举个具体例子,如果要加「必须缺一门才能胡」的规则,改动点只有一处:

// server/app/game/hu.js function canHuWithRule(tiles, rule) { if (!canHu(tiles) && !isSevenPairs(tiles)) return false; // 缺一门:手里不能同时出现三种花色 if (rule.mustLackOneSuit) { const suits = new Set(tiles.filter(t => t < 27).map(t => Math.floor(t / 9))); if (suits.size >= 3) return false; } return true; }

客户端完全不用改,因为它只负责展示Result。这就是把规则判断全放在服务端的好处。

加机器人补位是第二个常见需求。别一上来就写复杂的 AI,先做「能出合法牌」的托管逻辑:机器人拿到手牌后,按rule过滤掉不能出的牌,从剩下的里挑一张,优先打孤张(周围没有搭子的牌),其次打边张。二三十行代码就能让一桌跑起来,够用来做压测和联调。真要做有水平的 AI,再单独接一层评估函数,别混在房间逻辑里。

验证方法上,我一般会写一个脚本自动跑 500 局「四个机器人互打」,全部在服务端内存里跑,不连客户端,直接调Room的方法。跑完检查三件事:每局的牌总数守恒(手牌 + 河牌 + 牌墙 = 136 或对应玩法的总数)、没有出现非法胡牌、没有死循环(每局都在 300 步内结束)。这三个断言能覆盖掉绝大多数状态机写错的情况。

还有个技巧值得说:version字段在调试时非常好用。把每次version++的地方都打一条日志,牌局出问题时看日志里 version 是连续递增还是有跳跃,跳跃的地方就是漏了某个状态变更,直接定位到函数。从那以后我每次改房间逻辑,都强制在本地跑一遍 500 局自动对局,跑通了才敢提交——这个习惯帮我挡掉了不止一次上线事故。希望这套拆解能帮你把这套源码跑起来,也顺手改出自己想要的玩法。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进