☰
Cocos Creator麻将棋牌开发:牌桌架构、胡牌算法与帧同步实践
2026/10/10 3:11:31 网站建设 项目流程

简介:一套基于Cocos Creator开发的达达麻将棋牌游戏完整资源包,面向具备一定JavaScript基础、希望掌握网络棋牌游戏前后端全流程的开发者。项目以达达麻将的玩法规则与交互体验为核心,覆盖Cocos Creator场景搭建、JS游戏逻辑、Node.js服务端通信、MySQL玩家数据存储,并涉及安全性、性能优化、热更新等上线运营层面的实际考虑,适合学习、二次开发或毕业设计参考。

资源共2000个文件,压缩包约51.62MB。文件以js脚本、json配置、png美术、meta资源索引为主,包含prefab预制体、anim动画、mp3音效等类型,整体目录结构清晰,便于按模块查阅。目前已有3242人学习/下载。

内部还附带原生平台适配代码、构建配置与证书等材料,可帮助理解棋牌游戏从客户端逻辑到原生打包上线的完整链路。无论用于学习Cocos Creator还是二次开发棋牌项目,都有较好参考价值。

1. Cocos Creator 做麻将棋牌:达达麻将为什么值得拆开讲

接到一个用 Cocos Creator 做达达麻将棋牌游戏的模拟项目X时,我以为这品类是最省心的:玩法成型、美术量小、逻辑就“摸牌-出牌-胡牌”。真上手才发现,牌桌状态机、四端同步和低端安卓机的性能,每一项都能让排期翻倍。下面会从工程骨架、胡牌算法到联机帧同步、上线排查,把整个落地路径拆一遍。

适合两类人:准备从零搭麻将棋牌的开发者,以及项目做到一半被资源加载、断线重连和低端机卡顿折磨的熟手。新手能照着搭场景和判牌;熟手可以直接跳到避坑和帧同步章节对着参数检查。

2. 牌桌工程骨架:用 Cocos Creator 搭出大厅与牌桌的节点结构

2.1 场景分层:为什么要把大厅和牌桌拆成两个独立场景

做麻将棋牌第一个决定不是选算法,而是工程结构。很多新手会把牌桌做成大厅场景里的一个 Prefab,想进桌就实例化、退桌就销毁。这个做法在单机 Demo 里没问题,联机项目里会连续踩坑:大厅常驻的商城、红点、公告逻辑会和牌桌动画抢主线程;牌桌场景里的牌墙贴图、四端光照和倒计时组件全部驻留内存,低端安卓机一玩久就重启。

我一般会把“大厅”和“牌桌”拆成两个独立场景。切桌时只传 roomId、seatIndex 和可选快照,牌桌逻辑全部收敛在牌桌场景自己身上。这样大厅出问题不会带崩对局,牌桌每局结束也可以直接重载场景来复位状态,连手动清理节点都省了。

Cocos Creator 2.x 里常见写法是用 director.loadScene,3.x 更推荐把牌桌做成独立 Bundle,首包只放大厅和公共 UI,对局场景按需下载。麻将棋牌的首包通常要压到很小,尤其是发到小游戏平台,平台对包体有硬上限,牌桌资源走 Bundle 加载几乎是必须的。

场景切换参数用全局单例管理:

// 场景切换参数,挂在常驻单例上 export class GameSceneArgs { static roomId = ""; static seatIndex = -1; static snapshot: string | null = null; }

逻辑说明:进桌之前先 set 好参数,再加载牌桌场景;牌桌场景加载完之后读参数并立刻走初始化流程。seatIndex 用于确定本机玩家坐几号位,这会影响手牌区旋转方向。

参数说明:如果使用场景参数,重连侧非常依赖这一层。断线重连时服务端返回一个全量快照,先写入 GameSceneArgs.snapshot,再加载牌桌场景,牌桌逻辑启动时看到 snapshot 就直接恢复状态,比在牌桌内部异步等待网络包要稳得多。

2.2 牌桌节点树:四个座位的手牌区怎么设计

牌桌场景的节点树我习惯这样组织:

MahjongTable (Canvas) ├── TableBg // 背景,带多分辨率适配脚本 ├── WallArea // 牌墙(4面) ├── Seat0/Hand // 手牌容器,正下方 ├── Seat1/Hand // 右方,牌面朝向旋转 90° ├── Seat2/Hand // 上方,旋转 180° ├── Seat3/Hand ├── DiscardArea/Seat0..3 // 各家出牌区 ├── ActionBar // 碰/杠/吃/胡按钮组 ├── TimingWheel // 倒计时 └── TipsLayer // 飘字、结算弹窗

每个座位一个容器节点,而不是每个座位独立排一套手牌节点。原因是麻将牌桌是同一个牌型,只是朝向不同,把容器旋转一下就复用同一套布局逻辑。给手牌容器挂一个 SeatView 脚本,由它管理座位上的手牌、出牌和碰杠区。

四个手牌区做成 Prefab 最节省:一个 SeatPrefab 包含手牌容器、出牌容器和碰杠容器,四个座位分别实例化并设置不同的旋转角度。牌桌最忌讳的是把每个座位的手牌槽位全部手动摆在场景里,一旦要加听牌提示或调整牌距,要改四份。

Prefab 实例化之后,座位脚本还需要知道自己属于哪个玩家:

// SeatView.ts 挂载在座位容器上 import { _decorator, Component } from 'cc'; const { ccclass, property } = _decorator; @ccclass('SeatView') export class SeatView extends Component { @property seatIndex: number = 0; /** 该座位当前的手牌计数,34 位数组 */ hand: number[] = new Array(34).fill(0); reset() { this.hand.fill(0); // 清空出牌区、碰杠区的子节点 this.node.getChildByName('Discard')?.destroyAllChildren(); } }

逻辑说明:这段代码对应节点结构里的“座位视图”脚本。seatIndex 用于广播消息时判断要不要把数据包分发到该视图;hand 数组存的是牌序,不是节点列表,节点只做表现。这样逻辑层和表现层分离后,联机同步只需要同步“哪张牌”,不需要关心屏幕上牌怎么排。

参数说明:座位视图的 reset 要在开新局时统一调用。如果只清理手牌容器而忘记清理出牌区,上一局打出的牌会一直挂在桌上,视觉上就是“牌桌越打越满”。

2.3 麻将牌对象池:低端机不卡的根本操作

麻将一局同屏最多一百多张牌。牌从牌墙到手牌,从手牌到出牌区,再从出牌区到碰杠区,一局里每张牌至少被移动两三次。如果每次移动都 new 一个节点、用完就销毁,Cocos Creator 的 GC 会在低端安卓机上造成肉眼可见的卡顿,出牌瞬间掉帧是棋牌项目最常见的性能投诉。

对象池是这类项目的标准解法。常见做法是维护一个 Node 池,取牌时从池里拿,如果池空才实例化;弃牌时把节点回收而不是销毁。

// CardPool.ts 全局对象池 import { Node, instantiate, Prefab } from 'cc'; export class CardPool { private static readonly _pool: Node[] = []; /** 从池中取一张牌节点 */ static get(prefab: Prefab): Node { let node = this._pool.pop(); if (!node) { node = instantiate(prefab); } node.active = true; return node; } /** 回收一张牌节点 */ static put(node: Node): void { node.active = false; node.removeFromParent(); this._pool.push(node); } }

逻辑说明:get 先 pop,池空才 instantiate,所以局内重复对局不会反复创建节点;put 把节点隐藏并移出场景树,但保留引用,等待下次复用。要注意 removeFromParent 之后节点不会把坐标复位,所以取出来之后要重新 setPosition。

参数说明:对象池不是越大越好。麻将牌同屏峰值在 130 张左右,池容量建议设为 160~200。超过这个数说明有节点没有被回收,应该排查泄漏而不是扩容。每次新开一局时不需要清池,让池保留峰值大小,反而能避免下一局的创建抖动。

3. 从摸牌到胡牌:麻将核心逻辑与判牌算法落地

3.1 牌编码:用 0~33 还是用枚举加 value

麻将牌要进算法,第一件事是定编码。很多初学者直接用“牌名字符串”到处传,比如 “wan8”“tiao3”,联机协议、存档、日志都要跟着处理字符串,性能又差又容易出错。这里应该用整数编码,让牌能直接做数组下标。

我常用的编码是:花色占高位,数值占低位。筒、条、万、字分别对应 0~3,普通牌值 1~9,字牌值 1~7,编码公式为suit * 16 + value。之所以用 16 而不是 9,是为了让每一张牌的编号都错开,同时便于用位运算判断花色。

// 牌编码与解析 export const SUIT_TONG = 0; export const SUIT_TIAO = 1; export const SUIT_WAN = 2; export const SUIT_ZI = 3; export function encode(suit: number, value: number): number { return suit * 16 + value; } export function decode(card: number): { suit: number; value: number } { return { suit: Math.floor(card / 16), value: card % 16 }; } // 判断是不是字牌(值 1~7,无法组成顺子) export function isZiCard(card: number): boolean { return card >= SUIT_ZI * 16; }

逻辑说明:encode/decode 是全局唯一的转换入口,联机协议、AI、胡牌判定都走这两个函数。isZiCard 用编号范围判断,避免每次解码再比较。

参数说明:这里的 16 是故意留空隙的。如果用 9,筒 9 编码为 8,条 1 编码为 9,会把两个花色在数值上拼在一起,做顺子判断时容易误判。留 16 之后,每个花色占 16 个编号,互不干扰,后续加“红中”“百搭”等特殊牌也只要扩展编码段。

手牌数据结构我推荐 34 位计数数组:hand[i] 表示第 i 种牌有几张。麻将规则和牌的张数相关,计数数组比“排好序的牌列表”更适合做递归判定,查听牌和算番也快得多。

// 34 位计数数组 export function emptyHand(): number[] { return new Array(34).fill(0); } // 给手牌添加一张 export function addTile(hand: number[], card: number): void { const suit = Math.floor(card / 16); const value = card % 16; hand[suit * 9 + (value - 1)]++; }

逻辑说明:参数 card 是 encode 后的牌编号;函数内部还原花色和数值后,映射到 34 位数组下标。value 从 1 开始,数组下标要减一;字牌也落在 27~33。写 addTile 时统一用suit * 9 + (value - 1),不要手写 switch。

参数说明:这个数据结构同时支撑胡牌判定和听牌计算,后续算番也要基于它。如果项目要做“赖子/癞子”玩法,建议在编码层预留一个特殊编号,不要在 34 位数组里硬塞。

3.2 胡牌判定:枚举雀头加递归拆面子

胡牌判定是麻将项目最容易写错也最难查的核心方法。标准型胡牌用递归就能搞定,但要注意两点:第一,递归只能拆顺子或刻子,不能拆雀头;第二,字牌不存在顺子。

先看核心的拆面子函数:

// cnt: 34 位计数数组 // idx: 从哪个位置开始往下拆,保证不重复 function canFormMelds(cnt: number[], idx: number): boolean { while (idx < 34 && cnt[idx] === 0) idx++; if (idx >= 34) return true; // 试刻子:三张相同 if (cnt[idx] >= 3) { cnt[idx] -= 3; if (canFormMelds(cnt, idx)) { cnt[idx] += 3; return true; } cnt[idx] += 3; } // 试顺子:仅序数牌可以 if (idx < 27) { const v = idx % 9; if (v <= 6 && cnt[idx + 1] > 0 && cnt[idx + 2] > 0) { cnt[idx]--; cnt[idx + 1]--; cnt[idx + 2]--; if (canFormMelds(cnt, idx)) { cnt[idx]++; cnt[idx + 1]++; cnt[idx + 2]++; return true; } cnt[idx]++; cnt[idx + 1]++; cnt[idx + 2]++; } } return false; }

逻辑说明:从下标 0 开始扫描到第一张非零牌,优先拆刻子,再拆顺子,找不到就返回 false。关键是把递归后还原的数量加回去,保证回溯完整。因为 idx 只增不减,每个分支最多尝试 34 个位置,速度足够解析所有标准型牌型。

参数说明:idx < 27是顺子判定边界。27 到 33 是字牌位置,字牌不能组成顺子。如果漏掉这个边界,可能把两张顺子和一张无关字牌错误组合。

有了拆面子,再枚举雀头:

export function isStandardHu(cnt: number[]): boolean { let total = 0; for (const n of cnt) total += n; if (total !== 14) return false; for (let i = 0; i < 34; i++) { if (cnt[i] >= 2) { cnt[i] -= 2; if (canFormMelds(cnt, 0)) { cnt[i] += 2; return true; } cnt[i] += 2; } } return false; }

逻辑说明:标准胡牌 = 一对雀头 + 四个面子。首先检查总张数必须是 14,防止异常数据把非听牌状态判成胡牌。然后枚举哪一张牌作雀头,去掉它之后再拆面子。这个算法对 14 张手牌判定一次只有几十次递归,即便通过广播算听牌也不会有性能压力。

参数说明:total 检查必须在循环之前做。如果省略,手牌 15 张时可能被拆成“一对雀头+五个面子”,返回 true,这在真实牌局里是错的。杠牌之后手牌张数会变化,但胡牌判定时仍然以当前可见的牌型为准,总数恰好 14 是硬条件。

3.3 听牌与特殊牌型:七对和十三幺单独处理

标准型胡牌判定只覆盖“四面子一雀头”。麻将规则里还有七对、十三幺等特殊牌型,不同地区的规则差异又大。达达麻将这类项目的做法是做成“规则配置表”,把是否启用七对、是否包含字牌番种等作为参数下发,而不是写死在算法里。

七对判定逻辑非常直白,就是每种牌张数都是偶数且对数为 7:

export function isSevenPairs(cnt: number[]): boolean { let pairs = 0; for (let i = 0; i < 34; i++) { if (cnt[i] % 2 !== 0) return false; pairs += cnt[i] / 2; } return pairs === 7; }

逻辑说明:偶数检查放在循环内,只要出现奇数张就直接失败。pairs 累加得到总对数,最后必须是 7 对。标准型胡牌里也可能出现“全是刻子+一对”的情况,但它不能替代七对,所以两种判定分开。

参数说明:如果项目支持“七小对”和“豪华七对”的不同番值,这里的判定结果还要和算番模块联动,不能只返回 true/false。

听牌计算的代码很简单——遍历 34 种牌,补一张后看是否胡:

export function getTingTiles(cnt: number[]): number[] { const result: number[] = []; for (let i = 0; i < 34; i++) { if (cnt[i] >= 4) continue; // 这种牌已经断了,不能补 cnt[i]++; if (isStandardHu(cnt) || isSevenPairs(cnt)) { result.push(i); } cnt[i]--; } return result; }

逻辑说明:把每张牌尝试加入手牌,然后分别用标准型和七对判定。听牌结果用于客户端显示“听五条、八条”等提示。

参数说明:cnt[i] >= 4的边界很重要,一副牌每种最多 4 张,超过 4 说明数据异常,直接跳过。最终是否允许胡牌必须由服务端判定,客户端只做展示,否则玩家离线改包就能作弊。

4. 达达麻将避坑记:洗牌、动画与多分辨率的三处翻车现场

棋牌开发在代码量上不大,但找 bug 通常靠真机实测。下面这几条是这类项目里反复出现的坑,按“现象 → 原因 → 解决”记录。

4.1 洗牌太“玄学”:玩家连猜三把牌,牌序竟然有规律

现象:某次内部测试,一个玩家连续三局都能在摸牌后精准猜到下一张是什么,围观的人直呼“洗牌玄学”。我排查发现,客户端用的Math.random()做 Fisher-Yates 洗牌,而某些平台的引擎初始化时把随机种子固定了,导致每局牌序有可复现性。

原因:伪随机数生成器在未注入随机种子时,部分环境会使用相同初始状态;牌墙顺序一旦固定,玩家多打几把就能摸出规律。棋牌项目最不能接受的就是牌序可预测。

解决:洗牌前先注入随机熵,做法是用系统级随机源生成种子,不要直接用时间戳Date.now(),因为同一毫秒内开局会得到相同种子。

function shuffleTiles(tiles: number[]): number[] { const result = tiles.slice(); // 优先使用平台强随机接口生成随机数 // 小游戏环境如果没有 crypto,要换成平台提供的随机数能力 const rand = (max: number): number => { if (typeof crypto !== 'undefined' && crypto.getRandomValues) { const buf = new Uint32Array(1); crypto.getRandomValues(buf); return buf[0] % max; } return Math.floor(Math.random() * max); }; for (let i = result.length - 1; i > 0; i--) { const j = rand(i + 1); [result[i], result[j]] = [result[j], result[i]]; } return result; }

逻辑说明:先用crypto.getRandomValues产生系统级随机数,再用它驱动 Fisher-Yates。crypto在 H5 环境可用,发到部分小游戏平台时可能不存在,这时需要换平台提供的随机数接口,而不是退回Math.random。

参数说明:rand(max)里用取模运算会把分布稍微偏移,对麻将洗牌来说不会影响公平性,但如果你有完美均匀的要求,用“拒绝采样”更稳妥。洗牌完做一个校验:牌墙张数必须等于总牌数(移除花牌后通常 108 或 136 张),并且每张牌恰好出现 4 次,防止资源或逻辑层出错让牌数不对。

4.2 发牌动画比数据快:手牌还没摸完,碰杠按钮已经亮了

现象:第一局打完,第二局开始时,牌墙动画还在发牌,界面上的“碰/杠”按钮就提前亮了出来,而且亮出的牌不是本局该有的牌。

原因:发牌动画和牌桌逻辑用的同一个节点池,上一局回收的牌节点在动画播放时被新的摸牌动作复用,导致动画“串场”。本质是表现层和数据层没有分离开:数据层已经进入下一局,而动画层还在处理旧的牌节点。

解决:把“逻辑数据更新”和“表现动画”分成两条线。牌桌逻辑先更新 hand 数组,再触发动画;动画只负责把节点移动到指定位置,不允许在动画节点上直接改动牌数据。另外把上一局尚未播完的动画全部终止再开局。

// 开局前终止所有旧动画 export function stopAllCardAnimations(seatViews: SeatView[]): void { seatViews.forEach(view => { view.node.getComponentsInChildren(Tween).forEach(t => t.stop()); }); // 回收散落在出牌区/碰杠区的残留节点 CardPool.clear(); }

逻辑说明:开局第一行就把上局残留的节点回收,并停掉所有 Tween。Tween 是 Cocos Creator 的补间动画组件,已经播放的动画如果不 stop,回调仍会触发,和新的动画互相覆盖。

参数说明:清池的时机要在“新牌墙生成前”,不要在上局结算时就清;结算界面播放时玩家还能看牌型,节点被提前回收会导致结算界面白屏。

4.3 多分辨率适配:刘海屏和全面屏上的按钮被“吃掉”

现象:测试机里一台 16:9 和一台 20:9 的机型,牌桌按钮在大屏上整体偏上,右下角的“听牌提示”直接顶进屏幕圆角区域。

原因:只用 Widget 做四向对齐,画布适配模式没有处理非安全区。Cocos Creator 默认的适配对传统 16:9 没问题,全面屏下有刘海或底部横条,界面控件就撞进禁区。

解决:画布适配模式固定为SHOW_ALL,UI 根节点上挂 SafeArea 组件(如果项目版本没有内置,自己写一个读取平台的 safeArea 值),把关键操作按钮整体向内收缩安全区距离。

// 简易 SafeArea 脚本:把节点强制移动到安全区内 import { _decorator, Component, UITransform, view } from 'cc'; const { ccclass } = _decorator; @ccclass('SafeArea') export class SafeArea extends Component { start() { const safe = view.getSafeArea(); const ui = this.node.getComponent(UITransform); const canvas = view.getVisibleSize(); // 以根节点中心为基准,计算出安全区偏移 const offsetX = safe.x - (canvas.width - safe.width) / 2; const offsetY = safe.y - (canvas.height - safe.height) / 2; ui.setAnchorPoint(0.5, 0.5); this.node.setPosition(offsetX, offsetY); } }

逻辑说明:取平台 safeArea 后按画布尺寸算出偏移,再把节点移动到安全区中心。按钮组统一放进这个受保护容器,而不是每个按钮单独做 Widget 对齐;单独对齐在不同机型上会有累计误差。

参数说明:这套方案对左上角的大厅返回按钮、右侧的操作按钮组都有效。SafeArea 只挂一层,不要给每个子节点都挂,否则位置会反复叠加。还有,牌桌背景要铺满全屏,不要进安全区,否则全面屏两侧会露出黑边。

4.4 联机重连后手牌“多出两张”

现象:弱网环境断线重连后,有时手牌数量比服务端实际牌数多,而且多出的牌是上一局已经打掉的。

原因:重连成功后,服务端先回发了增量帧,但本地缓存在上一次断开时的“待确认操作”没有被清除,重连逻辑把本地未确认操作又执行了一遍,导致重复。

解决:断线重连必须做“全量快照 + 增量帧”而非只拉增量。重连成功后先清空本地缓存,收到服务端快照后整体覆盖手牌状态,再用后续增量帧追赶。客户端不允许在重连过程中预演本地未确认的操作。

这一条在联机章节会展开协议细节,但排查思路是通用的:凡是重连出现“多于原本状态”的,先怀疑本地缓存没有清空,再怀疑服务端补帧逻辑。

5. 联机对战:用帧同步把一桌麻将稳住

5.1 为什么选帧同步而不是状态同步

麻将棋牌联机的常见做法有两种:状态同步和帧同步。状态同步是服务端算好结果,把牌型、分数、剩余牌数直接发给客户端;帧同步是客户端各自以相同输入跑逻辑,服务端只转发玩家操作。

麻将这个品类我推荐帧同步,理由有三点:

其一,麻将操作频率低,一局最多几十次有效操作,帧同步的消息量非常小,WebSocket 完全够用。其二,麻将规则全局一致,四个客户端在相同输入下能算出完全相同的牌墙和手牌,天然适合做回放。其三,状态同步需要为每个操作定义一套完整状态包,后端开发量大,而且状态包一旦更新不及时,牌桌观战和断线重连都会出问题。

帧同步的风险在于逻辑层必须严格确定性:所有客户端使用相同的洗牌种子、相同的判牌算法、相同的浮点运算,任何一方有差异都会导致牌局“分叉”。所以麻将项目的帧同步把随机数全部放服务端生成牌墙,客户端不自己造牌。

5.2 输入帧和操作协议

帧同步协议的常见格式是一条指令包含:帧号、座位号、操作类型、跟牌编码。

// 操作帧 export type GameOp = | { frame: number; seat: number; type: "draw" } | { frame: number; seat: number; type: "discard"; card: number } | { frame: number; seat: number; type: "peng" } | { frame: number; seat: number; type: "gang"; card?: number } | { frame: number; seat: number; type: "hu" } | { frame: number; seat: number; type: "pass" };

逻辑说明:每帧多条指令按序执行。操作类型用联合类型,编译器能帮我们约束每种操作必须携带哪些字段;比如 discard 必须有 card,peng 不需要 card。联机协议只要按这个结构序列化成 JSON 或二进制包即可。

服务端回包时把同一帧内的操作合并:

{ "frame": 1200, "actions": [ { "seat": 0, "type": "draw" }, { "seat": 0, "type": "discard", "card": 42 } ] }

一个玩家先摸后打,是同一帧内的两条 action。客户端按顺序执行,不能跳帧。

5.3 本地时钟与逻辑帧推进

Cocos Creator 的 update 回调是引擎渲染帧驱动的,帧率会随视图切换或掉帧而变化,不能直接用来驱动牌桌逻辑。常见做法是设一个固定逻辑帧率,比如 20 TPS 或 30 TPS,每帧累积一个时间步。

// GameClock.ts 固定步长逻辑时钟 const TICK_MS = 50; // 20 次/秒 let accumulator = 0; let prevTime = 0; export function tickLogic(dt: number): void { accumulator += dt; while (accumulator >= TICK_MS) { advanceFrame(); // 依次处理服务器下发的本帧指令,由牌桌逻辑实现 accumulator -= TICK_MS; } }

逻辑说明:accumulator 累加真实时间,每积累到 50ms 就推进一个逻辑帧。这样即使渲染掉到 30FPS,逻辑帧依然能按固定节奏前进;配合服务端帧号,到达逻辑帧时才处理该帧消息,不会因为掉帧产生输入积压。

参数说明:麻将这类低操作频率的牌局,20 TPS 完全足够,过高反而会放大网络延迟。TICK_MS 必须放配置表,不同地区网络环境不同,延迟高的地区可以降到 10 TPS,但超过 20 毫秒的间隔会影响出牌体验。

5.4 断线重连:快照补帧与状态恢复

前面避坑提到重连容易翻车,现在从帧同步视角看完整流程。常见做法是:

  • 客户端每收到一帧,记住本地lastFrame。
  • 断线后重连,把lastFrame发给服务端。
  • 服务端判断断线时长:若在记录窗口内,回传从lastFrame+1开始的增量帧;若太旧,直接回传全量快照。
  • 客户端拿到快照,清空本地缓存后一次性覆盖状态。
// 重连请求 function requestResume(ws: WebSocket, lastFrame: number) { ws.send(JSON.stringify({ cmd: "resume", userId: myUserId, lastFrame })); } // 收到服务端回复 function handleResumeReply(data: any) { if (data.snapshot) { restoreFromSnapshot(data.snapshot); // 全量恢复 } else { frames = data.frames; // 增量追赶 } }

逻辑说明:这里关键点是restoreFromSnapshot不只是清空牌面,还要重置当前出牌人、牌墙剩余数、碰杠状态和每个座位的手牌计数。漏掉任何一个状态字段,重连回来就会出现“牌能摸,但碰不了”的诡异局面。

参数说明:全量快照比增量帧体积大很多,服务端通常只给每个房间保留最近 60 帧增量窗口;超过窗口才发快照。这个窗口大小取决于逻辑帧率和观战数量,60 帧是常见起步值。

6. 上线前让项目跑得更稳:调试技巧与性能验证清单

6.1 用 Profiler 抓 draw call,别让牌桌资源拖垮低端机

棋牌项目的性能瓶颈九成在 draw call。牌桌上一百多张牌如果每张都单独一个 Sprite,draw call 直接破百;加上牌墙、按钮、倒计时,低端机一进桌就掉帧。

常见做法是把所有牌面图合到一张大图集,同一批纹理的牌节点会进入同一个批次。Cocos Creator 的自动图集工具能完成这件事,但要注意:不要让牌背、花牌、特效动画散落在多张图集上,否则合批会断开。验证手段是打开 Profiler,进牌桌打一局,观察 draw call 是否在 100 以内。超过这个值就排查出牌区、牌墙的纹理是否都来自同一图集。

6.2 发布前验证清单

检查项方法通过标准
首包大小打 release 包后看资源体积大厅包不含牌桌资源
洗牌均匀性自动化跑 1000 局统计牌序无重复可预测区块
断线重连弱网工具模拟断网 5~30 秒状态与对手机一致
低端机帧率低端安卓真机打满一局平均帧率不低于 30FPS
内存驻留连续打 10 局看内存曲线无阶梯式上涨
字牌胡牌构造 14 张字牌牌型自动化测试判定结果正确

这张表主要给内部测试使用,不依赖具体平台。最终上线前还要按发行平台要求做实名与内容前置审核,但那些属于运营流程,不是技术文能覆盖的。

6.3 我留下的一个验证习惯

每次发体验包前,我都会做一次“自动打牌冒烟”:把牌桌逻辑接一个自动出牌 AI,让它连续跑 30 局,把胡牌、碰杠、断线重连全路径都触发一遍。这样能避免“自己手点二十局没事,发到玩家手里第一局就崩”的尴尬。

做麻将棋牌比做其他品类更需要确定性:洗牌结果要可复验,判定规则要自动化测试,联机协议要能离线模拟。我的血泪经验是——不要相信任何“手动点两把没问题”的结论,棋牌项目一个随机数种子出错,玩家很快就会发现。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询