简介:围绕 Cocos Creator 构建的达达麻将类网络棋牌游戏项目,面向具备 JavaScript 基础、希望了解棋牌前后端联调与上线流程的开发者,可用于学习麻将规则实现、场景界面搭建和服务器通信组织方式。压缩包约 51.62MB,共约 2000 个文件,以 json 配置、js 脚本、meta 资源索引、png 贴图、mp3 音效为主,另有 prefab 预制体、anim 动画、plist 图集以及 java、cpp、h 等原生与 SDK 相关代码,保留客户端工程、Node.js 服务端与 MySQL 脚本的目录痕迹。已有 3260 人学习,内容覆盖牌型逻辑、用户交互、状态管理、玩家匹配与数据同步等环节,并附大量 md 说明与构建配置,便于按模块梳理工程结构,对照理解热更新、安全防护、性能优化与多端兼容等上线要点。
1. 从能发牌的 Demo 到能上线运营的产品,差的是整条对局链路
一个能自动配牌的麻将 Demo,和一个能上线运营的棋牌游戏,中间隔着的不是美术资源,而是房间生命周期、断线重连、结算幂等,以及一堆只有真机上才会冒出来的问题。这套基于 cocos creator 的达达麻将棋牌游戏方案,要解决的不是“牌怎么发”,而是从开房、对局、结算到运营数据回收的完整链路:四个人进同一个房间,网络抖一下要能回到原来的牌桌,结算只能发生一次,线上出了异常要在日志里查得到。它适合正在做棋牌类毕设或课程设计、需要一套能跑通全流程结构的人,也适合手里已经有 Demo、想补齐上线运营环节的开发者。下面按需求、架构、实现、避坑、验收的顺序拆开讲。
2. 需求拆解与技术选型:一条对局链路要拆成多少个状态
2.1 玩法需求清单:把“打麻将”拆成能验收的条目
麻将这个玩法听起来简单,落到工程上是一串强顺序的状态,任何一个环节没兜住,线上就是一个“卡在牌桌上出不来”的工单。我一般先把需求列成“能被测试点出来”的条目,而不是写一句“支持四人麻将”这种没法验收的话。下面这张表是排期时用的粒度,每一行后面都对应一个具体模块。
| 需求项 | 说明 | 优先级 | 验收方式 |
|---|---|---|---|
| 开房与加入 | 房主建房生成 6 位房号,其余玩家输码进入,支持 2/3/4 人规则 | P0 | 4 人并发加入房号不重复 |
| 发牌与配牌 | 洗牌、发牌、留底、定庄,随机种子可记录 | P0 | 同一种子可复现同一副牌 |
| 出牌回合 | 摸牌、出牌、吃碰杠胡响应、超时托管 | P0 | 每步状态可回放 |
| 胡牌判定 | 平胡、七对、十三幺,按房间配置启用 | P0 | 与标准牌例对拍 100 组 |
| 结算 | 番型计算、房卡或积分扣除、战绩落库 | P0 | 同一局只结算一次 |
| 断线重连 | 断网 30 秒内重进恢复当前牌局 | P0 | 断网演练全部通过 |
| 观战与回放 | 按操作序列重放整局 | P1 | 回放与实战一致 |
| 运营数据 | 房间数、在线人数、异常率、留存 | P1 | 后台看板能出数 |
| 热更新 | 不发版修正客户端逻辑 | P2 | 老版本能被引导更新 |
把“断线重连”放在 P0 是很多人会犹豫的地方。移动端网络切换基站、锁屏、来电,任何一次中断都会让玩家离开牌桌,如果重连做不好,玩家回来看到的是一张空桌子或者已经结束的结算页,这局的分就直接变成投诉。所以这套方案从一开始就把“快照 + 增量回放”当成核心能力设计,而不是上线前临时补的补丁。
另一个容易被低估的是“结算幂等”。棋牌游戏的结算涉及房卡、积分、战绩三条写入,任何一条重复执行都会造成账目对不上。需求阶段就要明确:一局牌有且只有一个全局唯一的结算序号,服务端以这个序号作为幂等键,重复请求直接返回上次结果。这一条不定下来,后面写多少防御代码都是打补丁。
2.2 引擎与语言选型:为什么是 Cocos Creator 而不是纯 H5 或原生
棋牌类项目的选型,核心诉求是“一套代码覆盖尽量多的渠道,同时牌桌这种 2D 场景要稳”。纯 Canvas 手写渲染在牌桌这种元素多、层级深、动效频繁的场景里,等于自己造一遍引擎,时间成本不划算;原生双端开发则意味着两套代码两份人力。
| 方案 | 优势 | 短板 | 是否适合本项目 |
|---|---|---|---|
| 纯 H5 Canvas 手写 | 包体最小、可控 | 场景图、资源管理、动效全自理 | 不适合,工期不可控 |
| 原生双端 | 性能上限高 | 两套代码、发版周期长 | 不适合,人力不够 |
| Cocos Creator + TypeScript | 2D 场景成熟、多端发布、TS 类型约束 | 需要控制 DrawCall 与包体 | 适合 |
选 TypeScript 不只是“写起来舒服”。牌型判定、番型计算、协议结构这三块是客户端和服务端要共用同一份逻辑的,用 TS 写一份放在共享目录里,两边引用同一个 npm 包,能省掉大量“客户端算出来能胡、服务端说不能胡”的对拍工作。这类问题在联调期最耗时间,血泪经验就是能共用就别抄两遍。
引擎版本上,团队内部要锁死一个具体小版本并写进 README,不要出现“有人用这个版本、有人用那个版本”的情况。不同小版本之间的资源加载行为和构建产物可能有差异,一旦发版时才发现,回滚成本很高。构建配置里把分包规则提前定好:牌桌基础图集、牌面图集、特效、音效分四个包,首包只留登录和大厅,牌桌资源进房时再拉。
2.3 客户端与服务端的职责边界:胡牌判定一定不能放客户端
这条边界如果一开始划错,后面基本要重构。客户端只做三件事:渲染、输入采集、表现层动画。所有“能不能胡”“这张牌能不能碰”“这把该谁摸牌”的判断,全部在服务端完成,客户端拿到的只是结果和用于播放动画的参数。
原因很直接:客户端代码是可以被逆向的。如果胡牌判定放在本地,改一个返回值就能实现“把把自摸”,这类作弊在棋牌品类里是最常见的攻击面。正确的做法是客户端只上报“我想打哪张牌”“我想碰”,服务端校验合法性后广播事件,客户端根据事件播动画。即便客户端本地为了手感做了一层预判,那也只能是“视觉上先动”,最终以服务端事件为准,不一致就回滚。
这条边界还带来一个额外好处:回放和观战几乎是免费的。因为整局的状态变化完全由事件序列驱动,把这串事件存下来,换一个客户端按顺序播一遍,就是一局完整的回放,不需要额外写一套录制逻辑。做毕设的时候这一点很加分,答辩时能现场演示“同一局牌,换个视角重放一遍”,比口头讲架构有说服力得多。
3. 总体架构设计:客户端三层结构与服务端房间模型
3.1 客户端三层:表现层、逻辑层、数据层怎么切
客户端按三层切分,目的不是画架构图好看,而是让“断线重连”和“回放”这两件事不用改表现层代码。
数据层只干一件事:持有服务端下发的快照和事件流,维护一个本地牌局状态对象。它不关心节点、不关心动画,纯数据。逻辑层负责把状态变化翻译成“需要播什么动画”的指令队列,比如“座位 2 打出一张五万”,翻译成“从座位 2 的手牌区移除一张五万,飞向牌河第 14 个位置”。表现层拿到指令队列后按顺序播放,播完回调通知逻辑层可以取下一条。
这样切的好处是,重连时只要把数据层重置成服务端快照、把指令队列清空,表现层重建一次就行,不用去猜“现在这个动画播到一半怎么办”。我一般会在表现层加一个isPlaying锁,动画队列非空时禁用玩家操作按钮,避免玩家在动画期间连点导致本地状态和服务端错位。
代码目录大致这样组织,共享的类型定义放在 common 下,两端引用同一份:
// assets/scripts/common/protocol.ts —— 客户端与服务端共用的协议定义 export enum Phase { WAIT_READY = 'WAIT_READY', // 等待准备 DEALING = 'DEALING', // 发牌中 WAIT_DISCARD = 'WAIT_DISCARD', // 等待当前玩家出牌 WAIT_ACTION = 'WAIT_ACTION', // 等待其他人吃碰杠胡 SETTLE = 'SETTLE', // 结算 } // 所有下行事件都带 seq,客户端据此判断是否漏包 export interface GameEvent { seq: number; type: 'DEAL' | 'DRAW' | 'DISCARD' | 'MELD' | 'HU' | 'SETTLE'; seat: number; payload: Record<string, unknown>; } export interface RoomSnapshot { roomId: string; seq: number; // 快照对应的全局序号 phase: Phase; currentSeat: number; seats: SeatSnapshot[]; discardPile: { tile: number; from: number }[]; remainingCount: number; // 只下发剩余张数,不下发具体牌 deadline: number; }注意remainingCount只给张数不给具体牌。一旦把牌墙内容下发到客户端,抓包就能看到所有人的牌,这个漏洞在棋牌项目里是致命的。凡是“客户端不需要知道”的信息,一律不下发,这是设计协议时的第一原则。
3.2 服务端房间与牌局状态机
服务端以“房间”为最小单位,一个房间对象持有座位数组、牌墙、牌河、当前阶段、当前座位、操作截止时间。牌局推进完全由状态机驱动,每个事件处理完必须落到一个明确的阶段,不允许出现“阶段没变但数据改了”的中间态。
状态迁移大致是:WAIT_READY全员准备后进DEALING,发牌完成进WAIT_DISCARD,当前玩家出牌后进WAIT_ACTION,其他玩家在超时时间内响应,无人响应则轮到下家摸牌回到WAIT_DISCARD,有人胡牌进SETTLE。每个阶段都有对应的合法消息白名单,比如WAIT_ACTION阶段只接受吃碰杠胡和“过”,收到出牌请求直接拒绝。
房间对象还需要一个“操作截止时间”。棋牌类最怕玩家挂机,一个人卡住三个人等。做法是每次进入等待阶段就设置deadline = now + timeout,服务端用一个定时器扫房间,超时未操作的玩家自动托管,托管策略按规则执行:能胡就胡,能碰就碰,否则打摸到的第一张。托管状态要广播给所有人,客户端在头像上显示托管标识,避免队友误判。
房间销毁也要有明确规则。常见做法是结算后保留一段时间供玩家看战绩和再来一局,超时无人续局则销毁。销毁时必须把内存里的房间对象从索引里摘掉,否则房间号会一直被占用,新玩家建房时撞号。这个坑很隐蔽,压测时才会暴露。
3.3 协议设计:消息序号、幂等与增量同步
协议层有三个关键设计,缺一个断线重连就做不干净。
第一是全局递增的seq。服务端每产生一个会影响牌局状态的事件,就把seq加一并附在事件上。客户端本地记录lastSeq,收到事件时检查event.seq === lastSeq + 1,不连续就主动触发重连拉快照。这样能把“客户端状态悄悄错了”变成“立刻发现并自愈”。
第二是请求幂等。客户端发的每个请求带一个客户端生成的reqId,服务端处理完把结果按reqId缓存一小段时间(比如 30 秒)。网络抖动导致客户端重发时,服务端直接返回缓存结果,不会出现“一次出牌扣了两张牌”。这个缓存的过期时间不能太长,否则内存吃不消;也不能太短,要覆盖正常的重试窗口。
第三是快照与增量分离。快照是“当前全量状态”,增量是“seq 之后发生的事”。重连时服务端先回快照,再回放快照 seq 到当前 seq 之间的所有事件。客户端拿到后先重建全量,再按序应用增量,最后才放开操作。这个顺序不能反,反了就会出现“先播动画后建场景”的错乱。
// server/room.ts —— 事件广播带序号,并写入历史供重连回放 function emit(room: Room, event: Omit<GameEvent, 'seq'>) { room.seq += 1; const full: GameEvent = { ...event, seq: room.seq }; room.history.push(full); // 环形缓冲,只保留最近 N 条 if (room.history.length > 512) room.history.shift(); for (const client of room.clients) { if (client.online) client.send(full); } return full; }history用环形缓冲限制长度,是因为一局牌的事件量有限(几百条),保留最近 512 条足够覆盖任何一次重连回放,同时不会因为房间长期不销毁导致内存缓慢增长。如果产品要求支持完整回放,那历史事件应该落库而不是留在内存,这两件事要分开设计。
4. 核心模块实现:胡牌判定、出牌回合与断线重连
4.1 牌的数据结构与胡牌判定算法
牌用整数编码,全项目统一,客户端和服务端引用同一份常量。这个约定看起来小事,但实际项目里最常见的联调事故就是“客户端把一条当成一万”。编码规则写死在共享文件里,谁都不许在业务代码里写魔法数字。
// common/tile.ts —— 全局唯一的牌编码,客户端与服务端共用 // 0-8: 一万到九万;9-17: 一条到九条;18-26: 一筒到九筒 // 27-33: 东南西北中发白 export const TILE_KIND = 34; export const isHonor = (t: number) => t >= 27; // 字牌不能组顺子 export type HandCounts = number[]; // 长度 34,下标是牌,值是张数 /** 七对:14 张,7 个对子。四张相同按两对算,具体按房间规则开关 */ export function isSevenPairs(counts: HandCounts): boolean { let pairs = 0; for (let i = 0; i < TILE_KIND; i++) { if (counts[i] % 2 !== 0) return false; pairs += counts[i] / 2; } return pairs === 7; } /** 把剩余牌全部拆成面子(刻子或顺子),meldsNeeded 是还需要几个面子 */ function canFormMelds(counts: HandCounts, meldsNeeded: number): boolean { if (meldsNeeded === 0) return counts.every((c) => c === 0); let i = 0; while (i < TILE_KIND && counts[i] === 0) i++; if (i === TILE_KIND) return false; // 尝试刻子:同一张牌取三张 if (counts[i] >= 3) { counts[i] -= 3; if (canFormMelds(counts, meldsNeeded - 1)) { counts[i] += 3; return true; } counts[i] += 3; // 回溯必须还原 } // 尝试顺子:只有数牌可以,且不能跨花色(i % 9 <= 6) if (!isHonor(i) && i % 9 <= 6 && counts[i + 1] > 0 && counts[i + 2] > 0) { counts[i]--; counts[i + 1]--; counts[i + 2]--; if (canFormMelds(counts, meldsNeeded - 1)) { counts[i]++; counts[i + 1]++; counts[i + 2]++; return true; } counts[i]++; counts[i + 1]++; counts[i + 2]++; } return false; } /** 胡牌判定入口:counts 是手牌,已亮出的面子不参与计算 */ export function canHu(counts: HandCounts, enableSevenPairs = true): boolean { const total = counts.reduce((a, b) => a + b, 0); if (total % 3 !== 2) return false; // 必须是 3n+2 if (enableSevenPairs && total === 14 && isSevenPairs(counts)) return true; const meldsNeeded = (total - 2) / 3; for (let i = 0; i < TILE_KIND; i++) { if (counts[i] >= 2) { counts[i] -= 2; // 先定将牌 const ok = canFormMelds(counts, meldsNeeded); counts[i] += 2; // 无论成败都还原 if (ok) return true; } } return false; }这段代码有两个细节值得单独说。一是回溯必须还原现场,canFormMelds里每一处-=都对应一处+=,少写一个就会污染下一次尝试,表现为“明明能胡却判不出来”,而且很难复现。二是顺子的判断条件i % 9 <= 6,它保证七万、八万、九万不会跨到一条上,字牌则直接排除。如果项目规则里七对允许“四张相同算两对”,isSevenPairs就不用改;如果规则不允许豪华七对,那就要在判断前把四张的牌单独剔除或者直接返回 false,这一条要和玩法策划确认清楚再写。
性能上不用担心。手牌最多 14 张,递归深度最多 4 层,单次判定在微秒级。真正需要关注的是服务端在出牌响应阶段要遍历所有玩家的可操作项(能不能碰、能不能杠、能不能胡),这时候把判定做成“只对相关牌型做增量判断”会比全量重算更稳,但这是优化项,不是第一版必须。
4.2 出牌回合的状态机与服务端校验
出牌是整个牌局里最容易出问题的一步,因为它同时涉及顺序、持牌、阶段三个维度的校验。我一般把校验写成“一串 fail-fast 的前置判断”,任何一条不满足就带错误码返回,绝不进入业务逻辑。
// server/handler.ts —— 处理一次出牌请求 async function handleDiscard(room: Room, seat: number, tile: number, reqId: string) { // 1. 幂等:同一 reqId 重复到达,直接返回上次结果 const cached = room.replyCache.get(reqId); if (cached) return cached; // 2. 阶段校验:只有等待出牌阶段接受出牌 if (room.phase !== Phase.WAIT_DISCARD) return reject('PHASE_MISMATCH'); // 3. 顺序校验:必须轮到该座位 if (room.currentSeat !== seat) return reject('NOT_YOUR_TURN'); // 4. 托管校验:托管中的玩家不接受手动操作 if (room.seats[seat].trustee) return reject('IN_TRUSTEE'); // 5. 持牌校验 const hand = room.seats[seat].hand; const idx = hand.indexOf(tile); if (idx < 0) return reject('TILE_NOT_IN_HAND'); if (room.seats[seat].frozenTile === tile) return reject('TILE_FROZEN'); // 6. 执行:扣牌、进牌河、切阶段、广播 hand.splice(idx, 1); room.discardPile.push({ tile, from: seat }); room.phase = Phase.WAIT_ACTION; room.actionDeadline = Date.now() + room.rule.actionTimeout; const evt = emit(room, { type: 'DISCARD', seat, payload: { tile } }); room.replyCache.set(reqId, evt); return evt; }frozenTile是“刚摸上来的那张牌”的占位。有些规则不允许摸到就打,要求必须先打出手牌里的旧牌,这个字段就是用来兜这条规则的。如果规则允许摸打,把这一步去掉即可,但字段建议保留,方便不同玩法配置。
replyCache的清理时机也要设计。我一般是在进入结算阶段时统一清空当前房间的缓存,或者在写入时检查容量超过阈值就淘汰最旧的。如果不清理,一个长生命周期的房间会慢慢吃内存;如果清得太早,玩家重试时又会重复执行。经验值是缓存保留 60 秒,配合客户端 5 秒一次的重试节奏,足够覆盖绝大多数网络抖动。
响应阶段(吃碰杠胡)的处理逻辑类似,但要额外注意优先级。同一张打出的牌,可能同时有人能胡、有人能碰,规则上胡优先于碰,碰优先于吃。实现上不要按到达顺序处理,而是收集所有玩家的响应意向,等超时或者全部响应后统一按优先级裁决,再广播最终结果。这个“先收集后裁决”的模式能避免“第一个人先点了碰,第二个人后点胡被吞掉”的争议。
4.3 断线重连:快照 + 序号回放
重连是这套方案里最值得投入的部分。设计目标很明确:玩家断网 30 秒内回来,看到的牌桌和断线前一致,且能继续操作。
服务端在玩家重连时做四件事:校验身份令牌,取出房间快照,找出快照seq之后的历史事件,把这两部分一起下发。快照里不能包含别人的手牌,只能给张数。历史事件只回放和该玩家可见信息相关的那部分,比如别人摸牌只广播“某座位摸牌”而不带牌面。
// server/rejoin.ts —— 重连时组装快照与增量 function buildRejoinPayload(room: Room, seat: number, lastSeq: number) { const snapshot = { roomId: room.id, seq: room.seq, phase: room.phase, currentSeat: room.currentSeat, seats: room.seats.map((s, i) => ({ seat: i, handCount: s.hand.length, melds: s.melds, trustee: s.trustee, online: s.online, })), discardPile: room.discardPile, remainingCount: room.wall.length, deadline: room.actionDeadline, }; // 只回放客户端缺失的那一段,避免重复播放 const events = room.history.filter((e) => e.seq > lastSeq && e.seq <= room.seq); return { snapshot, events, selfHand: room.seats[seat].hand }; }客户端侧的关键是“先清场再重建”。很多人重连出问题,是因为直接在旧场景上叠加快照,旧的牌节点没销毁,新的又加上去,牌桌上出现两套牌。正确顺序是:停掉所有动画、销毁牌桌节点、按快照重建、按序应用增量事件、最后放开操作。
// client/reconnect.ts —— 客户端重连主流程 async function rejoin(roomId: string, seat: number) { const lastSeq = Store.lastSeq; // 断线前记录的序号 const resp = await net.request('REJOIN', { roomId, seat, lastSeq }); if (!resp.ok) return toLobby(resp.code); // 房间已销毁等情况 TableView.reset(); // 先清场,销毁所有牌桌节点 TableView.applySnapshot(resp.snapshot); TableView.setSelfHand(resp.selfHand); // 自己的手牌单独下发 for (const evt of resp.events) { // 只回放缺失的增量 await TableView.applyEvent(evt); Store.lastSeq = evt.seq; } TableView.setInteractive(true); // 回放完成才允许操作 }await TableView.applyEvent这里必须是串行的,不能并发播放,否则动画顺序会乱。如果增量事件比较多(比如断线时间较长),可以在回放时用一个“快速模式”跳过动画,直接落终态,避免玩家回来还要看十秒钟的回放。
还有一种边界情况要处理:玩家重连时牌局已经结束。这时候服务端应该返回结算结果而不是快照,客户端直接跳到结算页展示战绩。这条分支不写,玩家回来会卡在“正在恢复牌局”的 loading 里出不来。
4.4 牌桌渲染:分帧加载与 DrawCall 控制
牌桌场景的元素数量比想象中多:四个座位的手牌、牌河、副露区、头像、倒计时、按钮、特效。一次性加载全部资源,进场会卡一到两秒,低端机上甚至能看到黑屏。
资源加载按“先能看,再好看”的顺序分三批。第一批是牌桌底板和座位框,第二批是牌面图集,第三批是特效和音效。每批之间让出一帧,让 loading 动画有机会渲染,不然进度条是死的。
// client/loader.ts —— 牌桌资源分帧加载 async function loadTableAssets(onProgress: (loaded: number) => void) { const groups = ['table/base', 'table/tiles', 'table/effects']; let loaded = 0; for (const group of groups) { await new Promise<void>((resolve) => { resources.loadDir(group, (err, assets) => { if (err) { console.error('[load] group failed:', group, err); resolve(); // 单组失败不阻断整体,走兜底图 return; } loaded += assets.length; onProgress(loaded); resolve(); }); }); // 让出一帧,保证 loading 动画持续渲染 await new Promise((r) => setTimeout(r, 16)); } }DrawCall 是牌桌性能的第二道坎。牌面用的是图集,理论上可以合批,但一旦你在牌上面加了描边、阴影、层级不同的特效,合批就断了。我的做法是:牌面本身用同一张图集,牌上的角标(比如癞子标记)做成同一图集内的子图,不要用额外的 Label 或者独立 Sprite;玩家头像单独一个小图集;特效层用单独的图集并控制在三种以内。
还有一条容易忽视的:手牌排序。每次摸牌、出牌都要重排手牌,如果每张牌都改一次父节点或者 zIndex,引擎会触发大量重排。做法是只更新每张牌的目标位置,用一个统一的布局函数在下一帧批量设置,避免逐张触发布局计算。这个小改动在低端机上能省下明显的帧时间。
5. 避坑与排查:上线运营阶段最容易翻车的五个点
5.1 低端机发烫掉帧
现象:中低端机型打两三局后机身明显发热,帧率从 60 掉到 30 以下,牌桌动画开始卡顿。
原因通常不是渲染量本身,而是“每帧都在做的事”太多。常见的三个:一是倒计时用update里每帧改 Label 文本,字符串拼接触发 GC;二是牌桌上有大量setTimeout定时器在跑,每个定时器都在打乱事件循环;三是粒子特效没有做对象池,每次出牌新建一批节点,旧的没有回收。
解决方式是把倒计时改成每秒更新一次文本,用整数秒驱动;所有定时逻辑收拢到一个统一的调度器里,按房间帧同步驱动,不要散落各处;特效做成对象池,预创建固定数量循环复用。改完之后用引擎自带的性能面板对比,正常情况下 DrawCall 能压到 30 以内,帧率稳定。
5.2 重连后重复结算
现象:玩家在网络差的场景下断线,重连后战绩里同一局出现两条记录,或者房卡被扣了两次。
原因基本是结算请求没有幂等键。客户端在没收到结算回包时会重试,服务端两次都当作新请求处理,写了两遍库。另一种情况是服务端广播结算事件后、客户端确认前断线,重连又触发一次结算流程。
解决方式是给每一局生成唯一的settleId,服务端在写库前先查这个 ID 是否存在,存在就直接返回上次结果。数据库层面给settleId加唯一索引做最后一道防线,任何绕过应用层的重复写入都会被数据库拦下。这条我建议必修,不要指望应用层逻辑永远正确。
5.3 热更新后老用户白屏
现象:发了新版本,一部分用户更新后打开是白屏,清缓存重进又好了。
原因是资源版本和代码版本不匹配。热更新只更新了脚本,但脚本引用的新图集还没下载完,或者本地缓存里还是旧图集的引用,加载时找不到资源直接抛异常,表现层就白屏了。
解决方式是版本号统一管理:脚本、资源、配置用同一个版本号,更新时先比对版本清单,全部下载校验通过后再切换。加载失败要有兜底分支,缺图时用占位图而不是抛异常中断整个场景。另外给客户端加一个“连续两次启动失败自动清缓存重下”的保险,这个保险能救回大部分线上白屏。
5.4 牌局分布被玩家质疑
现象:运营一段时间后,有玩家在反馈里说“好牌总在固定几个人手里”,要求解释发牌逻辑。
原因往往不是发牌有问题,而是发牌过程的随机种子没有记录,出问题时无法自证。没有种子,就没法复现那一局,也没法向玩家证明发牌是随机的。
解决方式是在每局开始时生成种子并随房间记录落库,种子参与洗牌算法,任何时候都能用这个种子复现整副牌的顺序。同时把种子和牌局 ID 一起写进战绩,玩家质疑时可以后台复现。这件事的价值不在技术难度,而在运营期的信任成本,属于必须有、平时用不上的东西。
5.5 线上出问题查不到日志
现象:玩家反馈“卡住了”,但服务端日志里只有一条“连接断开”,没有任何上下文,排查全靠猜。
原因是日志只记了结果没记上下文。棋牌类的问题几乎都和状态有关,光看一条断连日志什么都定位不了。
解决方式是给每个请求打一个链路 ID,从客户端发起、网关接收、房间处理到广播回包,全链路带同一个 ID。日志里至少要有房间号、座位、当前阶段、当前序号、请求类型这几个字段。另外在客户端加一个轻量的行为上报,把关键操作(出牌、碰、胡、重连)带上时间戳上报,出问题时两边日志一对就能还原现场。日志量会涨,但按天切分并保留七天,成本可控。
6. 验收与答辩:怎么证明这套方案扛得住上线
验收这件事,我一般不看“功能能不能点通”,而是看“异常情况下能不能自愈”。功能跑通是最低要求,真正决定这套方案能不能上线的是几个极端场景。
下面这张表是验收清单,每一项都要有明确的通过标准,不能写“正常”这种模糊描述。
| 验收项 | 方法 | 通过标准 |
|---|---|---|
| 断网重连 | 对局中关闭网络 10 秒、30 秒、60 秒后恢复 | 30 秒内恢复牌桌且可继续操作 |
| 重复请求 | 用工具重放同一出牌请求 5 次 | 只扣一张牌,返回结果一致 |
| 结算幂等 | 重放同一结算请求 | 战绩只有一条,房卡只扣一次 |
| 牌型判定 | 用 100 组标准牌例对拍 | 判定结果全对,耗时低于 1ms |
| 并发开房 | 模拟 200 人同时建房加入 | 房号不重复,无人卡在加入中 |
| 托管超时 | 人为挂机等待超时 | 自动出牌,队友能看到托管标识 |
| 发牌可复现 | 用记录的种子重跑一局 | 牌序完全一致 |
压测不用一上来就搞几十万并发,棋牌类先按“单房间生命周期正确”做,再做“多房间并发”。我一般的顺序是:先用脚本模拟 50 个房间跑完整局,看有没有内存泄漏和房间不销毁;再把并发拉到 500 房间,观察 GC 和延迟;最后才做峰值压测。前两步能解决八成问题,直接上峰值压测只会看到一堆噪声。
答辩时的讲法也有讲究。不要从头讲“麻将是什么”,评委比你清楚。开场直接给一张图:从开房到结算的状态流转,标出哪几个环节做了幂等、哪几个环节做了快照。然后挑一个点深入,比如断线重连,现场演示“断网 30 秒再恢复,牌桌原样回来”。这种演示比讲十页架构图有说服力,因为它证明了你考虑过真实使用场景,而不是只写了个能跑的程序。
如果时间允许,再补一个对比:同一局牌,分别用正常流程和“疯狂点击出牌按钮”的方式操作,展示服务端校验如何把非法请求挡回去。这能直接回应“为什么判定要放服务端”这个常见提问,也顺便说明了你对安全边界的理解。
从那以后我每次做这类项目,都会在开发阶段就把验收清单列出来,写完一个模块就对着清单跑一遍,而不是等到最后集中测。踩过的坑告诉我,棋牌类的问题几乎都不是“写不出来”,而是“没想到会有这种情况”。希望这套结构能帮到你,少走几段我走过的弯路。
本文还有配套的精品资源,点击获取