资讯动态

基于 Cocos Creator 的麻将棋牌游戏全链路与断线重连实践

发布时间:2026/10/27 13:06:23 来源:尧图企业网站定制
简介围绕 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 人规则P04 人并发加入房号不重复发牌与配牌洗牌、发牌、留底、定庄随机种子可记录P0同一种子可复现同一副牌出牌回合摸牌、出牌、吃碰杠胡响应、超时托管P0每步状态可回放胡牌判定平胡、七对、十三幺按房间配置启用P0与标准牌例对拍 100 组结算番型计算、房卡或积分扣除、战绩落库P0同一局只结算一次断线重连断网 30 秒内重进恢复当前牌局P0断网演练全部通过观战与回放按操作序列重放整局P1回放与实战一致运营数据房间数、在线人数、异常率、留存P1后台看板能出数热更新不发版修正客户端逻辑P2老版本能被引导更新把“断线重连”放在 P0 是很多人会犹豫的地方。移动端网络切换基站、锁屏、来电任何一次中断都会让玩家离开牌桌如果重连做不好玩家回来看到的是一张空桌子或者已经结束的结算页这局的分就直接变成投诉。所以这套方案从一开始就把“快照 增量回放”当成核心能力设计而不是上线前临时补的补丁。另一个容易被低估的是“结算幂等”。棋牌游戏的结算涉及房卡、积分、战绩三条写入任何一条重复执行都会造成账目对不上。需求阶段就要明确一局牌有且只有一个全局唯一的结算序号服务端以这个序号作为幂等键重复请求直接返回上次结果。这一条不定下来后面写多少防御代码都是打补丁。2.2 引擎与语言选型为什么是 Cocos Creator 而不是纯 H5 或原生棋牌类项目的选型核心诉求是“一套代码覆盖尽量多的渠道同时牌桌这种 2D 场景要稳”。纯 Canvas 手写渲染在牌桌这种元素多、层级深、动效频繁的场景里等于自己造一遍引擎时间成本不划算原生双端开发则意味着两套代码两份人力。方案优势短板是否适合本项目纯 H5 Canvas 手写包体最小、可控场景图、资源管理、动效全自理不适合工期不可控原生双端性能上限高两套代码、发版周期长不适合人力不够Cocos Creator TypeScript2D 场景成熟、多端发布、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: Recordstring, 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: OmitGameEvent, 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; // 必须是 3n2 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 Promisevoid((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 秒再恢复牌桌原样回来”。这种演示比讲十页架构图有说服力因为它证明了你考虑过真实使用场景而不是只写了个能跑的程序。如果时间允许再补一个对比同一局牌分别用正常流程和“疯狂点击出牌按钮”的方式操作展示服务端校验如何把非法请求挡回去。这能直接回应“为什么判定要放服务端”这个常见提问也顺便说明了你对安全边界的理解。从那以后我每次做这类项目都会在开发阶段就把验收清单列出来写完一个模块就对着清单跑一遍而不是等到最后集中测。踩过的坑告诉我棋牌类的问题几乎都不是“写不出来”而是“没想到会有这种情况”。希望这套结构能帮到你少走几段我走过的弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑