简介:这是一套基于 CocosCreator 开发的房卡麻将客户端与服务端完整源码,面向希望研究多人棋牌游戏架构、网络同步与房间运营机制的中高级开发者。项目围绕创建房间、房卡消耗、玩家交互、麻将牌逻辑、计分结算等核心流程展开,客户端侧重场景与 UI 表现,服务端涉及 WebSocket 实时通信、房间管理、用户数据存储及配置参数维护,可用于对照理解前后端职责划分与联调思路。压缩包为 zip 格式,整体约 16.43MB;上游清单中文件总数显示为 0、类型明细暂无数据,可结合 assets、scripts、scenes 等典型模块把握项目组织方式。目前已有 2248 人学习或下载,适合作为 CocosCreator 棋牌项目拆解、网络编程实践与游戏规则算法学习的参考案例。
1. 不要急着运行 Demo:先确认「服务端权威」覆盖到了哪一步
很多人下载了cocoscreator 房卡麻将 客户端+服务端 源码,第一件事是打开工程点击预览,看到牌桌能坐下、能发牌、能出牌,就断言“这套东西能用”。我拆过几套同类工程,结论相反:能跑起来的 Demo 和能承受四个玩家连续断线重连的成品,差距在服务端接管牌局状态的程度,不在 UI。这套源码的价值在结构上:房间、座位、发牌、结算被拆成独立服务端模块,Cocos Creator 那边只做表现和输入采集。适合两类人:想学房间制棋牌玩法怎么建模的后端,和要在 Cocos Creator 上做牌桌交互的前端。下面按“先看结构、再跑通、最后处理问题”的顺序走。
2. 客户端拆解:Cocos Creator 牌桌从场景树到状态机的四层结构
把整个assets目录打开,第一眼容易懵:脚本散在好几个文件夹里,预制体一堆,配置表又是 JSON 又是 TS。其实牌桌类项目的目录结构只有一件事要判断——逻辑和表现有没有分开。分开的工程改起来舒服,混在一起的工程改一个出牌动画可能要动三个文件。我一般先花二十分钟把下面这张表填完,填不出来的地方就是后面必然出问题的地方。
2.1 目录职责与四层划分
以常见的工程组织方式为例,客户端大致是这么几层:
| 目录 | 职责 | 改动频率 |
|---|---|---|
scripts/net/ | WebSocket 连接、心跳、消息编解码、断线重连 | 低 |
scripts/game/ | 手牌排序、可出牌过滤、牌型展示数据组装 | 中 |
scripts/ui/ | 面板打开关闭、动画播放、按钮态 | 高 |
resources/config/ | 牌型配置、房间规则、座位布局坐标 | 低 |
prefabs/table/ | 牌桌、手牌、弃牌堆、头像位预制体 | 高 |
判断标准很朴素:scripts/game/里不应该出现import { Node } from 'cc'。一旦出现,说明逻辑已经在操作节点,做单元测试或者做回放验证的时候就会很难。我在一个模拟项目里见过手牌排序写在TileView组件里,后来要加“听牌提示”,得把排序逻辑从视图里挖出来重写一遍,前后花了两天。
预制体这边有个细节值得留意:牌面通常用SpriteFrame图集 + 单张tile.prefab,靠spriteFrame切换显示。手牌区用一个Layout组件横排,出牌后不要立刻destroy,而是回收到对象池——一局牌下来出牌动作上百次,频繁创建销毁在中低端安卓机上会明显掉帧。
2.2 牌桌状态机:把“等出牌、等响应、结算”写成显式状态
网络抖一下,服务端推来的消息顺序就可能和本地动画错位。如果不加限制,会出现牌还没发完就能点出牌、结算面板弹出两次这种问题。做法是给牌桌一个显式状态机,所有输入先过状态检查。
// assets/scripts/game/TableState.ts export enum TableState { Idle = 0, // 未开局,房间列表或等待界面 Dealing = 1, // 发牌动画播放中,屏蔽全部操作 WaitOutCard = 2, // 轮到自己出牌 WaitAction = 3, // 已出牌,等其他人响应(吃碰杠胡) Settle = 4, // 单局结算面板 } // 只允许这些迁移,其余一律丢弃并打日志,不做“硬切” const TRANSITIONS: Record<TableState, TableState[]> = { [TableState.Idle]: [TableState.Dealing], [TableState.Dealing]: [TableState.WaitOutCard, TableState.WaitAction], [TableState.WaitOutCard]: [TableState.WaitAction, TableState.Settle], [TableState.WaitAction]: [TableState.WaitOutCard, TableState.Settle], [TableState.Settle]: [TableState.Dealing, TableState.Idle], }; export function canTransfer(from: TableState, to: TableState): boolean { return TRANSITIONS[from].includes(to); }这段逻辑的作用是把“消息”和“界面”中间加一道检查。服务端推NOTIFY_OUT_CARD时,如果本地还在Dealing,正常做法是把消息缓存下来等动画结束再消费,而不是直接切到WaitAction。丢弃和缓存是两回事——状态迁移可以丢弃,业务消息必须缓存,这个区别搞混了就会出现“别人出的牌我看不到”的诡异现象。
参数上,Dealing的停留时长建议跟发牌动画严格对齐。常见做法是动画duration设为 0.6 秒、每张间隔 0.05 秒,13 张走完约 1.25 秒,那么Dealing锁定时间就设 1.3 秒,在动画finished回调里主动尝试迁移。用定时器写死时间容易在低端机上提前解锁,用Tween的完成回调更稳。
提示:状态机不要做成“单向线性”,
Settle之后必须能回到Dealing(下一局)和Idle(解散房间),否则第二局开局会卡在结算态。
2.3 网络层与 UI 的桥接:消息分发别直接写进组件
最常见的写法是onMessage里一串if (cmd === 3001) {...} else if (cmd === 3002) {...},然后直接操作节点。工程小的时候没问题,房间消息加牌局消息加结算消息一多,这个函数会长到三百行,改一个协议要全文搜索。
我一般改成注册表 + 序列号去重:
// assets/scripts/net/NetClient.ts type Handler = (body: any) => void; export class NetClient { private ws: WebSocket | null = null; private handlers = new Map<number, Handler[]>(); private sendSeq = 0; private recvSeq = 0; // 已处理的下行水位 private heartbeatTimer = 0; on(cmd: number, h: Handler): void { const list = this.handlers.get(cmd) ?? []; list.push(h); this.handlers.set(cmd, list); } send(cmd: number, body: object): void { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; this.ws.send(JSON.stringify({ cmd, seq: ++this.sendSeq, body })); } private dispatch(pkt: { cmd: number; seq: number; body: any }): void { // 重连补包会重发历史消息,小于等于水位的直接丢 if (pkt.seq > 0 && pkt.seq <= this.recvSeq) return; if (pkt.seq > 0) this.recvSeq = pkt.seq; for (const h of this.handlers.get(pkt.cmd) ?? []) h(pkt.body); } startHeartbeat(): void { // 5 秒一次,服务端 15 秒没收到就判定掉线 this.heartbeatTimer = setInterval(() => this.send(1001, { t: Date.now() }), 5000) as any; } }几个参数值得说清楚。recvSeq去重是断线重连能用的前提,没有它,重连后补发的历史帧会把整局重新播一遍。心跳间隔 5 秒、服务端超时 15 秒,是三个心跳周期的余量,短于这个值在弱网下会频繁误判掉线。send里判断readyState而不是抛异常,是因为出牌按钮点击瞬间掉线属于常态,抛异常会打断 UI 流程。
还有一个地方容易忽略:Cocos Creator 在原生平台(Android / iOS)上,WebSocket由引擎封装,行为和浏览器端有细微差别。浏览器端关闭时立刻触发onclose,原生端可能延迟到下一帧。所以重连逻辑里不要依赖“连接关闭就一定连不上”,而是在发送超时或心跳无响应时主动做一次探测,避免出现界面显示在线、实际已断的死连接。
3. 服务端房间模型:房卡扣减、座位分配与断线重连的快照设计
服务端这边,整局逻辑的骨架就是房间对象。外地很多同类工程把房间、玩家、牌局写成三个互相引用的类,最后谁也说不清谁负责释放。我的习惯是明确职责边界:房间管生命周期和座位,牌局管牌和规则,玩家对象只做连接和会话的挂载点。三者通过 roomId 和 uid 关联,不互相持有完整引用。
3.1 房卡扣减的三个时机与幂等键
房卡属于消耗型道具,扣减时机不同,出问题的表现也不同:
| 扣减时机 | 优点 | 风险 |
|---|---|---|
| 建房即扣 | 逻辑简单,房间一旦创建就有成本 | 建房后没人进,玩家白亏;解散要退款逻辑 |
| 开局扣 | 人齐才消耗,体验好 | 要处理“扣了卡但开局失败”的回滚 |
| 结算扣 | 只对打过的局收费 | 中途解散有争议,且需要预扣额度 |
这套源码走的多是“开局扣 + 流水记录 + 失败回滚”的路子。具体实现上,扣减必须带幂等键,否则玩家手抖连点两次“开始”,房卡会被扣两轮。
// server/room/RoomService.ts async function startGame(uid: string, roomId: string, reqId: string) { // reqId 由客户端生成(uid + 时间戳 + 随机数),同一次请求只处理一遍 const key = `room:start:${roomId}:${reqId}`; const ok = await redis.set(key, '1', 'NX', 'EX', 30); if (!ok) return { code: 1002, msg: '重复请求' }; const room = await getRoom(roomId); const cost = room.rule.cardCost; // 例如 8 局房 2 张 const balance = await getCardBalance(uid); if (balance < cost) return { code: 1003, msg: '房卡不足' }; // 先写流水再扣余额,任何一步失败按 flowId 回滚 const flowId = await writeCardFlow({ uid, delta: -cost, type: 'start_game', roomId }); try { await decrCardBalance(uid, cost); await room.setState('Playing', { flowId }); return { code: 0, room: room.toPublic() }; } catch (e) { await rollbackCardFlow(flowId); throw e; } }逻辑上分四步:先做幂等判断,再校验余额,然后写流水、扣余额,最后改房间状态。流水先写是有意为之——线上对账靠流水,不靠余额快照。参数EX 30表示幂等键保留 30 秒,够覆盖玩家的重复点击窗口;如果开局动作本身可能超过 30 秒(比如要等匹配),那就把键放在业务表里而不是缓存里。
cardCost建议做成房间规则的字段而不是硬编码。8 局房和 16 局房消耗不同,带不带“大胡翻倍”又不同,规则一多,硬编码就会变成一堆 if。
3.2 座位分配与断线重连快照
座位分配本身不复杂:房间 4 个位置,进人时找第一个空位坐下,坐满即开局。真正决定体验的是断线重连。玩家切后台、地铁进隧道、接个电话,回来必须看到和掉线前完全一致的牌桌。
快照要包含的内容:
- 房间维度的:
roomId、roundIndex(第几局)、state、currentUid(轮到谁)、remainWall(牌墙剩余)、lastDiscard(最后打出的牌) - 座位维度的:
seat、uid、online、handCount(别人只给张数)、melds(副露)、score - 本人维度的:完整手牌、已摸到未出的牌、可执行操作列表
- 时间维度的:
deadline(本次操作截止的绝对时间戳)
// server/room/Room.ts function buildSnapshot(room: Room, uid: string) { return { roomId: room.id, roundIndex: room.roundIndex, state: room.state, currentUid: room.currentUid, remainWall: room.wall.length - room.cursor, lastDiscard: room.lastDiscard, deadline: room.actionDeadline, // 毫秒时间戳,客户端自己算倒计时 seats: room.seats.map(s => ({ seat: s.seat, uid: s.uid, online: s.online, handCount: s.hand.length, melds: s.melds, score: s.score, // 只有请求者本人能看到自己的手牌 hand: s.uid === uid ? s.hand : undefined, })), }; }deadline用绝对时间戳而不是剩余秒数,是因为秒数在传输和渲染之间会漂移。客户端拿到时间戳后自己算差值,再叠加一帧的渲染延迟,倒计时的误差能控制在 100ms 以内。用秒数的话,一个 15 秒的操作,客户端倒数完服务端可能还剩 14 秒,玩家点了会被判超时。
hand字段用undefined而不是空数组,序列化时会被JSON.stringify直接丢掉,包体能小一截——四家手牌全发下去,一副 136 张的牌局光手牌数据就好几 KB,弱网下这个差别能感觉到。
3.3 协议表设计:编号分段、心跳与错误码
协议最怕的是编号随手拍。常见做法是按业务段划分:1000 段系统(登录、心跳、踢下线),2000 段房间(创建、加入、离开、解散),3000 段牌局(出牌、吃碰杠胡、结算),4000 段管理后台。分段的好处是出问题时看抓包日志里的 cmd 就知道大概是哪个模块的锅。
// server/net/dispatcher.ts const routes = new Map<number, (ctx: Ctx, body: any) => Promise<void>>(); routes.set(1001, async (ctx) => { ctx.lastActive = Date.now(); }); // 心跳 routes.set(2001, async (ctx, body) => { await RoomService.create(ctx, body); }); routes.set(2002, async (ctx, body) => { await RoomService.join(ctx, body); }); routes.set(3001, async (ctx, body) => { await PlayService.discard(ctx, body); }); routes.set(3002, async (ctx, body) => { await PlayService.action(ctx, body); }); export async function onMessage(ctx: Ctx, raw: string) { let pkt: any; try { pkt = JSON.parse(raw); } catch { return ctx.close(4000, 'bad_packet'); } const handler = routes.get(pkt.cmd); if (!handler) return ctx.reply(pkt.cmd, { code: 4004, msg: 'unknown cmd' }); ctx.lastActive = Date.now(); await handler(ctx, pkt.body); // 统一在入口刷新活跃时间 }这里有个容易出问题的地方:await handler(...)里如果抛异常又没捕获,整个连接会静默失联。我的做法是在最外层包一层 try/catch,统一返回{ code: 5000 }并记一条带uid、cmd、入参的日志。牌局类业务的入参不大,全量打日志的成本可以接受,出问题时能直接复现。
还有一个细节:出牌、吃碰杠这类操作必须带roundIndex。玩家在结算面板停留很久,回来点了一下上一局的出牌按钮,如果不校验局数,会把上一局的操作打到这一局里。加上局数校验,不匹配直接返回“操作已过期”,比任何提示文案都干脆。
4. 牌局核心算法:洗牌、发牌与胡牌判定在服务端怎么写成可测的
牌和规则这部分,客户端算得再花哨都不作数,服务端算错一次就是真事故。所以核心算法要满足两条:可单测、可回放。可单测意味着算法不依赖网络和数据库,纯函数输入输出;可回放意味着只要给定随机种子和操作序列,能完整重演一局。
4.1 洗牌与牌墙:为什么不能用 Math.random 排序
先看错误写法,这条在不少工程里都见过:
// 错误示范:分布不均且依赖引擎排序实现 const wall = tiles.sort(() => Math.random() - 0.5);问题有两层。一是比较函数不满足传递性,排序结果并不是均匀随机排列;二是 V8 对短数组用插入排序、长数组用快排,不同长度下偏差程度不一样。136 张牌做几万次模拟就能看出某些牌型出现频率偏高。
正确做法是 Fisher-Yates 洗牌,随机源用密码学安全的:
// server/game/wall.ts import { randomInt } from 'crypto'; export function buildWall(laiZiCount = 0): number[] { const wall: number[] = []; // 0-26 万条筒各 1-9,27-33 字牌,每种 4 张 for (let t = 0; t < 34; t++) for (let i = 0; i < 4; i++) wall.push(t); // Fisher-Yates:从后往前,每次与 [0, i] 中随机一个交换 for (let i = wall.length - 1; i > 0; i--) { const j = randomInt(0, i + 1); // 上界不含,正好覆盖 0..i [wall[i], wall[j]] = [wall[j], wall[i]]; } return wall; }randomInt(0, i + 1)的上界是开区间,写randomInt(0, i)会漏掉最后一个位置,洗出来的牌墙末端分布不均——这种 bug 很难在测试里发现,但连续跑一万局统计某个位置的牌型频率就能看出来。
洗牌结果要落日志:seed、roomId、roundIndex、洗牌后的牌墙哈希。有了这个,玩家投诉“牌有问题”时可以完整复现,不至于只能靠嘴解释。
注意:
crypto.randomInt是同步阻塞调用,单次开销很小,但一局里别在循环里反复调用几十次去做别的随机(比如决定谁先手),统一在开局时批量取。
4.2 发牌与摸牌指针
发牌规则固定:庄家 14 张,其余三家 13 张。牌墙用单向递增的cursor,不要splice去删元素——splice是 O(n),还容易在杠后补牌时把索引搞乱。
// server/game/deal.ts export function deal(wall: number[], bankerSeat: number) { const hands: number[][] = [[], [], [], []]; let cursor = 0; // 先每人 13 张,按座位顺序 for (let n = 0; n < 13; n++) { for (let s = 0; s < 4; s++) hands[s].push(wall[cursor++]); } // 庄家补第 14 张 hands[bankerSeat].push(wall[cursor++]); // 留 14 张做杠后补牌 / 海底,不参与正常摸牌 const reserve = wall.slice(wall.length - 14); const drawEnd = wall.length - 14; return { hands: hands.map(h => h.sort((a, b) => a - b)), cursor, drawEnd, reserve }; }drawEnd是正常摸牌的边界,摸到这个位置就进入流局判定。留 14 张是按“最多四个杠 + 若干海底”的常见规则估的,具体数值跟着玩法走,但一定要写成常量集中配置,别散在各个判断里。
手牌排序在服务端也要做,而且要和客户端用同一套比较函数(万 < 条 < 筒 < 字)。两端排序不一致会导致客户端展示的手牌顺序和服务端计算听牌时的顺序不一致,出现“明明听牌了却不提示”的问题。
4.3 胡牌判定:递归拆解与癞子处理
基础胡牌判定是“3n+2”的拆解:先取一对将牌,剩下的牌递归拆成刻子或顺子。
// server/game/hu.ts // counts 长度为 34 的计数数组,need 为还需要拆出的面子数 function canFormMelds(counts: number[], need: number): boolean { if (need === 0) return counts.every(c => c === 0); let i = 0; while (i < 34 && counts[i] === 0) i++; if (i === 34) return false; // 试刻子 if (counts[i] >= 3) { counts[i] -= 3; if (canFormMelds(counts, need - 1)) { counts[i] += 3; return true; } counts[i] += 3; } // 试顺子:只对万条筒有效,且不能跨花色 if (i < 27 && i % 9 <= 6 && counts[i + 1] > 0 && counts[i + 2] > 0) { counts[i]--; counts[i + 1]--; counts[i + 2]--; const ok = canFormMelds(counts, need - 1); counts[i]++; counts[i + 1]++; counts[i + 2]++; if (ok) return true; } return false; } export function canHu(hand: number[], laizi = 0): boolean { const counts = new Array(34).fill(0); for (const t of hand) if (t !== laizi) counts[t]++; const laiziCount = hand.filter(t => t === laizi).length; // 枚举将牌:普通牌作将,或用两张癞子作将 for (let i = 0; i < 34; i++) { if (counts[i] >= 2) { counts[i] -= 2; if (fillWithLaizi(counts, laiziCount, (hand.length - 2) / 3)) { counts[i] += 2; return true; } counts[i] += 2; } if (laiziCount >= 2) { if (fillWithLaizi(counts, laiziCount - 2, (hand.length - 4) / 3)) return true; } } return false; }canFormMelds的关键在回溯完整:每层试完必须把计数还原,不还原的话第二次分支判断会用脏数据。这个 bug 表现为“某些牌型偶尔判对偶尔判错”,很难复现,写的时候就要盯住+= 3和++这几行。
i % 9 <= 6是防止顺子跨花色,比如 8 万 9 万 1 条不能被当成顺子。这是纯数字编码最容易踩的边界,写上注释比什么都强。
癞子部分,fillWithLaizi的常见做法是贪心 + 回溯:优先用癞子补刻子,补不出来再补顺子,都不行就退回。真要做严谨,用带记忆的搜索,状态压缩成(counts 指纹, 剩余癞子数),牌型最多几十种,记忆化之后单次判定在微秒级,一局里调用几百次完全无压力。
性能上,四家听牌计算一局大概几十次判定,Node.js 单进程跑几千个并发房间会开始吃力。常见优化是把canHu的结果按手牌指纹缓存,手牌最多 14 张,指纹用字符串拼接,命中率不低。
5. 联调处理问题:4 个让牌桌“看起来能跑”实则必然出问题的问题
联调阶段最容易产生错觉:单机点两下能出牌、能胡、能结算,就以为通了。真正的问题都在多人、弱网、重复操作这几个场景里。下面四条都是我实际遇到过、并且在不同工程里反复出现的。
5.1 客户端先出牌再等服务端回包
现象:四个客户端同时操作时,偶尔出现某个玩家的手牌数量和别人看到的不一致,下一局开局还会残留上一局的牌。
原因:客户端点击出牌后,本地直接从手牌数组里删掉这张牌并播放动画,同时把请求发给服务端。服务端校验失败(比如没轮到你)返回错误码,但客户端没有回滚逻辑,界面就永久偏离了真实状态。
解决:牌桌类项目一律走“服务端权威 + 本地预表现”。本地可以做视觉上的位移,但逻辑上的手牌数组不能动,必须等服务端广播NOTIFY_OUT_CARD之后再真正移除。如果要做点击反馈,把那张牌设成半透明并锁住,等服务端回包再定稿。这个改动量不大,但要改在状态机那一层,不能只在按钮上加个disabled。
5.2 断线重连后手牌翻倍
现象:玩家切后台 30 秒回来,手牌显示 26 张,出牌直接报错。
原因:重连走的是“拉快照 + 补历史帧”的流程,但客户端在拉快照之前没有清空本地状态,补发的历史帧又通过dispatch全部消费了一遍,同一批牌被 push 了两次。
解决:两道防线。一是 2.3 节里的recvSeq水位去重,水位以下的包直接丢;二是收到快照时先clearTable()再applySnapshot(),顺序不能反。另外服务端在补帧时要标记isReplay: true,客户端对重放帧只更新数据不播动画,否则回来的一瞬间满屏飞牌,体验更差。
5.3 房卡重复扣减或扣了没建房
现象:玩家反馈点了“创建房间”没反应,连点几次后房卡少了好几张,房间列表里一个都没有。
原因:扣卡和建房是两个异步步骤,中间任何一步超时或异常,都会留下“扣了卡但没房间”的中间态。加上没有幂等键,重复点击每次都走一遍完整流程。
解决:3.1 节的幂等键是必需的。另外把“写流水”和“扣余额”做成可回滚的组合,建房间失败时按flowId冲正。还有一点:客户端创建房间按钮点击后立刻置灰 3 秒,这不是防作弊,是防手抖,能挡掉绝大多数重复请求。
5.4 定时器没清导致房间不释放
现象:服务跑一天内存涨到几个 G,rooms这个 Map 的 size 只增不减。
原因:每个房间有一个 15 秒操作倒计时定时器,玩家出牌后会clearTimeout再设新的。但如果房间解散或者玩家退出,代码里只删了rooms里的引用,定时器还挂在事件循环上,回调里又引用了房间对象,垃圾回收回收不掉。
解决:房间对象加一个dispose(),里面把所有定时器、心跳、订阅关系一次性清干净,销毁入口统一走它。上线前用process.memoryUsage()打点,每小时记录一次堆大小,连续几小时单调上升就基本能定位到泄漏。
提示:
setInterval在高频房间场景下不如统一的tick循环。一个进程一个 200ms 的 tick,遍历活跃房间检查超时,比几千个定时器省资源得多,也更容易做“暂停服务时不触发超时”这类运营操作。
6. 上线前的最后一道工序:牌局回放与并发压测怎么做
真正让这套源码从“能玩”变成“敢上线”的,是回放能力。我的做法是:每一局在服务端落一条紧凑记录,包含seed、牌墙哈希、四家 uid 和座位、以及按seq排序的操作流(谁在第几手出了哪张、做了什么响应)。回放器只依赖这套记录和一个纯函数的牌局引擎,不需要网络、不需要数据库,跑在测试环境里就能把任意一局完整重放。
具体实现上,牌局引擎做成apply(state, action) => newState的形式,状态不可变,回放时按序折叠一遍就行。这套结构还有个附带好处:做规则改动时,把历史对局全部回放一遍,比对结算结果,能立刻发现“某个牌型从能胡变成不能胡”这种回归问题。我一般会攒 200 局真实对局的记录当回归集,改一行判定逻辑就跑一遍。
压测这块,别用“多少个连接”衡量,用“多少桌同时进行中的牌局”。一个空转的连接几乎不耗资源,一桌四人正常出牌的牌局才有意义。我的压测脚本会让机器人按真实节奏走:进房、坐下、每 3 到 8 秒出一次牌,胡了就结算再开下一局。观察三个指标就够了——单桌平均 CPU 时间、进程堆增长曲线、以及p99的消息处理延迟。前两个看容量,第三个看体验。
顺带说个技巧:牌桌类项目的日志量很大,别全量打。我一般只对“胡牌、杠、抢杠、流局、异常操作”这几类关键节点打全量日志,普通出牌只打一条uid + tile + seq的精简行。出问题时按roomId一捞,一局的完整脉络就出来了,比在海量日志里grep高效得多。
这套东西我第一次做的时候没做回放,线上出现一次结算分算错,只能靠玩家截图和三个人的口述去猜当时的手牌,折腾了两天才定位到是癞子作将的判定分支漏了一种情况。从那以后我每次接牌局类项目,第一件事就是把回放和回归集搭起来,规则改一行就跑一遍全量对局比对。希望帮到你。
本文还有配套的精品资源,点击获取