☰
Cocos Creator麻将工程:状态机、长连接与胡牌算法
2026/10/31 2:41:37 网站建设 项目流程

简介:这是一份基于 Cocos Creator 的达达麻将棋牌游戏项目源码,面向希望学习网络棋牌完整开发流程的初中级开发者与独立团队,覆盖前端界面与交互、JavaScript 游戏规则与状态管理、Node.js 登录匹配与数据同步、MySQL 玩家与积分存储,以及上线运营中的安全、性能与兼容性考虑。压缩包共 2000 个文件,约 51.62MB,以 json 配置、js 脚本、meta 资源索引与 png 图片为主,辅以 prefab 预制体、anim 动画、mp3 音频和 md 文档,便于按模块梳理工程结构。已有 3264 人学习,说明其作为棋牌项目参考具有一定关注度。读者可从中对照前后端协作方式、麻将规则落地、资源组织与构建发布流程,理解热更新、匹配同步和数据库设计的实现思路,也可将其作为二次开发、功能扩展及上线前技术评估的工程底本。

1. 一桌麻将背后是一整套状态机:这套 Cocos Creator 棋牌工程适合谁

接手一套别人写的麻将棋牌工程,比从零写更考验人:你不知道作者把「谁能出牌」这个状态放在客户端还是服务端,也不知道那副牌墙是本地洗的还是下发的。这套用 Cocos Creator 实现的「达达麻将」工程,价值就在于它把一局牌从登录、开房、发牌、出牌、吃碰杠胡到结算的整条链路都摊开在目录里,而且按能上线运营的完整度给的——热更新、断线重连、牌局回放、错误码这些运营期才想得起来的东西都在。它适合两类人:一类是想快速跑通一个棋牌 Demo、验证玩法可行性的开发者;另一类是想照着现成骨架做二次开发,把麻将换成别的牌类或者加地方玩法的人。不适合纯新手当天花板项目读,因为它默认你懂一点 Cocos 的场景和预制体概念。

2. 技术栈与工程骨架:Cocos Creator 的「大厅 + 子游戏」怎么搭

2.1 版本判定:先读工程根目录,再决定动不动手

拿到一套 Cocos Creator 工程,第一件事不是双击场景,而是翻根目录确认它是 2.x 还是 3.x。这两代引擎在脚本层几乎是两个东西:2.x 写cc.Class({ extends: cc.Component }),3.x 写@ccclass装饰器加import { _decorator, Component } from 'cc';资源加载一个走cc.loader(2.4 之后是cc.assetManager),另一个走assetManager加resources.load;节点变换 2.x 直接node.x,3.x 得node.setPosition()或者node.position = new Vec3(...)。判断标志很简单:根目录有project.json、library/、local/、temp/这几个,基本是 2.x;有settings/目录、脚本里出现from 'cc',那就是 3.x。

我一般的处理原则是:这套工程如果是 2.4.x,就先别动版本。麻将棋牌属于 2D UI 密集型项目,一局牌桌上同时有手牌、牌河、头像、按钮、飘字、倒计时,预制体嵌套很深,升级到 3.x 意味着预制体、动画剪辑、图集引用路径全要重新适配,改完还得逐屏对照 UI,工作量比加一个新玩法大得多。真要吃 3.x 的新特性(比如新的渲染管线或者原生性能),更划算的做法是保留 2.x 主体、只把新做的子玩法用 3.x 单独起工程,中间靠服务端协议对齐。这条是血泪经验,不是理论。

提示:动工程之前先整目录复制一份,Cocos 升级一旦中途失败,回滚成本很高。

2.2 目录结构:assets 下每个文件夹各管一段

这套工程的assets是按职责切分的,不是按场景堆的,这点值得保留。大致是这样:

目录职责依赖方向
assets/script/framework资源管理、网络层、音频、事件总线、工具函数不依赖任何业务目录
assets/script/hall登录、大厅、房间列表、创建/加入房间依赖 framework
assets/script/game/mj麻将牌桌逻辑、手牌渲染、操作按钮、结算面板依赖 framework,不依赖 hall
assets/script/protocol协议号常量、消息结构定义被 hall 和 game 共同引用
assets/bundles/common公共图集、位图字体、通用音效无
assets/bundles/mj麻将子包的场景、牌面图集、牌桌背景、音效依赖 common

依赖方向是这套结构最值钱的地方。game/mj不能反向引用hall里的任何东西,两个模块之间传数据只走事件总线和协议层。这么做的直接收益是:换皮的时候只动bundles,加玩法的时候只动game下新建的子目录,大厅代码一行不改。很多棋牌项目后期越改越乱,根子就是大厅和牌桌互相 import,最后谁也拆不出来。

framework里通常会有这么几个单例:ResManager(包加载和引用计数)、NetManager(WebSocket 收发和重连)、AudioManager(BGM 和音效通道)、EventBus(全局事件)。这几个是棋牌项目的通用件,读代码建议从它们入手,因为它们决定了后面所有业务代码的写法。

2.3 Bundle 分包与首屏加载:大厅独立成包的理由

棋牌项目上线最常撞的墙是包体。小游戏渠道首包普遍卡在几 MB 量级,一张牌桌背景加一套牌面图集就能吃掉大半。所以这套工程把资源切成了「主包(大厅骨架)+ 公共包 + 玩法包」三层,玩法包按需远程加载。

// framework/ResManager.ts (2.4 风格,3.x 把 cc.assetManager 换成 import 的 assetManager 即可) export class ResManager { private bundles: Map<string, cc.AssetManager.Bundle> = new Map(); /** * 加载子包 * @param name 包名,对应 assets/bundles 下的目录名,例如 'mj' * @param onProgress 进度回调,参数区间 0~1,用来驱动加载条 */ loadBundle(name: string, onProgress?: (p: number) => void): Promise<cc.AssetManager.Bundle> { const cached = this.bundles.get(name); if (cached) return Promise.resolve(cached); // 二次进入直接复用,避免重复下载 return new Promise((resolve, reject) => { cc.assetManager.loadBundle(name, { onFileProgress: (loaded, total) => { onProgress && onProgress(loaded / total); }}, (err, bundle) => { if (err) { // 远程包失败常见于弱网,退一次本地内置包兜底 console.warn(`[Res] 加载子包失败: ${name}`, err); reject(err); return; } this.bundles.set(name, bundle); resolve(bundle); }); }); } /** 从已加载的包里取一张图,取完记得在合适时机释放 */ loadSprite(bundleName: string, path: string): Promise<cc.SpriteFrame> { const bundle = this.bundles.get(bundleName); if (!bundle) return Promise.reject(new Error(`子包未加载: ${bundleName}`)); return new Promise((resolve, reject) => { bundle.load(path, cc.SpriteFrame, (err, asset) => err ? reject(err) : resolve(asset as cc.SpriteFrame)); }); } }

逻辑说明:loadBundle做了两层保护,一是内存缓存命中直接返回,二是失败时打日志并 reject,让上层决定是重试还是降级。onFileProgress回调里的loaded / total是字节数,不是文件数,拿它驱动进度条比较准。参数name必须和assets/bundles下的目录名完全一致,大小写敏感,在部分渠道的构建产物里会被压成小写,这一点在打包后要核对一遍。

进了牌桌场景之后,卸载时机也要管住:退出牌桌时调bundle.releaseUnusedAssets(),否则来回进出几次,低端机的内存就上去了。真机上 OOM 的案例,一半以上是子包只加载不释放。

3. 麻将核心链路:从洗牌、发牌到胡牌判定的完整实现

3.1 牌数据表示:34 个下标胜过 136 个对象

先统一牌的数据表示,这是后面所有算法的地基。麻将一共 34 种牌:万 0-8、条 9-17、筒 18-26、字牌 27-33(东南西北中发白),每种 4 张,合计 136 张。这套工程用的是「34 长度计数数组」而不是「136 长度的对象数组」,因为判胡、判听、判碰杠全是在做「某种牌有几张」的判断,用计数数组可以直接下标访问,不用遍历。

// game/mj/core/tile.ts export const TILE_KINDS = 34; /** 0-8 万 / 9-17 条 / 18-26 筒 / 27-33 字 */ export function isNumberTile(t: number): boolean { return t >= 0 && t < 27; } /** 花色:0 万 1 条 2 筒 3 字,用于判断顺子是否跨花色 */ export function suitOf(t: number): number { if (t < 9) return 0; if (t < 18) return 1; if (t < 27) return 2; return 3; } /** 手牌(136 张的 id 数组)转成计数数组,渲染层和算法层的转换口 */ export function toCounts(tiles: number[]): number[] { const counts = new Array(TILE_KINDS).fill(0); for (const t of tiles) counts[t]++; return counts; }

逻辑说明:toCounts是渲染层和算法层之间唯一的转换口。渲染层拿到的是带唯一 id 的牌对象(要区分「哪一张」,做排序和动画),算法层只关心「哪种牌几张」,所以进算法前统一转计数。参数tiles里放的是 0~33 的牌种下标,不是 0~135 的物理 id,物理 id 只在渲染和断线重连比对时用。

这样表示还有个隐性好处:网络传输省字节。一局牌的手牌同步,传 34 个0~4的数字,比传 14 个对象小一个数量级,弱网下差别明显。

3.2 洗牌与牌墙:种子由服务端下发,客户端只负责渲染

洗牌这件事最容易出问题。很多 Demo 直接在客户端Math.random()洗一副牌然后自己发,单机玩没问题,一旦联网就是灾难:四家客户端各洗各的,牌面对不上。这套工程的做法是服务端用种子洗牌,把整副牌墙和种子一起下发,客户端只负责按顺序摸。

// game/mj/core/wall.ts /** xorshift32,轻量可复现,仅用于牌局生成,不要拿去做安全相关的事 */ export class SeededRandom { private state: number; constructor(seed: number) { this.state = (seed || 1) >>> 0; } next(): number { let x = this.state; x ^= x << 13; x >>>= 0; x ^= x >> 17; x ^= x << 5; x >>>= 0; this.state = x; return x / 0x100000000; // 归一化到 [0,1) } } /** * 生成牌墙 * @param seed 服务端下发的随机种子,同一 seed 必然生成同一副牌,用于回放和校验 */ export function buildWall(seed: number): number[] { const wall: number[] = []; for (let t = 0; t < TILE_KINDS; t++) { for (let n = 0; n < 4; n++) wall.push(t); } const rng = new SeededRandom(seed); // Fisher-Yates 洗牌,从后往前换,保证每种排列等概率 for (let i = wall.length - 1; i > 0; i--) { const j = Math.floor(rng.next() * (i + 1)); const tmp = wall[i]; wall[i] = wall[j]; wall[j] = tmp; } return wall; }

逻辑说明:buildWall生成的是 136 张完整牌墙,发牌顺序是「庄家 14 张、闲家各 13 张」从前往后取,剩下的进牌墙尾。seed由服务端生成并随开局消息一起下发,客户端拿同一个 seed 能算出完全一样的牌墙——这就是牌局回放能工作的原因,回放不需要存整局录像,只存 seed 加操作序列,重放时重新算一遍即可。

参数上要注意一点:seed必须走无符号 32 位,>>> 0这一步不能省,否则 JS 的位运算会把超过 31 位的部分截断成负数,xorshift 直接退化。我见过有人图省事用Math.random()生成种子但忘了服务端也要用同一个,结果回放出来的牌序全乱,排查了大半天。

注意:客户端本地洗牌只用于单机练习模式和断线时的临时兜底展示,正式对局一律以服务端下发的牌墙为准,本地算出来的结果不能回写。

3.3 胡牌判定:递归拆解 + 七对分支

胡牌判定的本质是「14 张牌能不能拆成 4 组面子 + 1 对将」。这套工程用的是最直白的递归拆解,比查表法慢一点但可读性好,牌型扩展到地方玩法时改起来方便。

// game/mj/core/win.ts /** 判断剩余牌能否全部拆成刻子或顺子(不含将) */ function canFormMelds(counts: number[], start: number): boolean { let i = start; while (i < TILE_KINDS && counts[i] === 0) i++; if (i === TILE_KINDS) return true; // 全部拆完,合法 // 分支一:拆刻子 if (counts[i] >= 3) { counts[i] -= 3; if (canFormMelds(counts, i)) { counts[i] += 3; return true; } counts[i] += 3; } // 分支二:拆顺子。i < 27 排除字牌;i % 9 <= 6 防止 8 筒 9 筒这种跨花色组合 if (i < 27 && i % 9 <= 6 && counts[i + 1] > 0 && counts[i + 2] > 0) { counts[i]--; counts[i + 1]--; counts[i + 2]--; if (canFormMelds(counts, i)) { counts[i]++; counts[i + 1]++; counts[i + 2]++; return true; } counts[i]++; counts[i + 1]++; counts[i + 2]++; } return false; } /** 七对:14 张刚好组成 7 个对子,4 张相同算 2 对(龙七对) */ export function isSevenPairs(counts: number[]): boolean { let total = 0, pairs = 0; for (let i = 0; i < TILE_KINDS; i++) { total += counts[i]; if (counts[i] === 0) continue; if (counts[i] % 2 !== 0) return false; // 出现单张直接否掉,省掉后续递归 pairs += counts[i] / 2; } return total === 14 && pairs === 7; } /** 主入口:手牌计数数组(14 张)是否构成胡牌 */ export function isWinningHand(counts: number[]): boolean { if (isSevenPairs(counts)) return true; // 七对优先判,避免后面重复拆解 const c = counts.slice(); for (let i = 0; i < TILE_KINDS; i++) { if (c[i] >= 2) { c[i] -= 2; // 枚举将牌 const ok = canFormMelds(c, 0); c[i] += 2; if (ok) return true; } } return false; }

逻辑说明:canFormMelds每次都从最小的非零下标开始拆,先试刻子再试顺子,失败就回溯。因为拆解顺序固定(永远从最小牌开始),所以不会重复枚举同一种拆法,性能对 14 张牌来说完全够用,单次判定在微秒级。isWinningHand在外层枚举将牌,两种牌型互斥所以七对提前判掉。

参数上最关键的边界是i % 9 <= 6。写成<= 7就会出现「8 筒 + 9 筒 + 10 筒」(下标 25、26、27)被当成顺子的情况,而 27 是东风。这个错我见过至少两次,表现为「偶尔多算一种胡牌牌型」,牌桌上很隐蔽,因为大部分时候不会凑巧摸到那几张。另外顺子判定必须先确认counts[i] > 0,这里靠「从最小非零位开始」隐式保证了,改成别的遍历顺序就得补上这个条件。

3.4 听牌与出牌提示:一次遍历算出全部听口

听牌提示和「打哪张能听」是棋牌项目里用户感知最强的功能,做慢了会被吐槽卡。实现上不用什么高深算法,就是枚举。

// game/mj/core/ting.ts /** * 算出手牌的听口 * @param counts 13 张手牌(或 10/7 张,杠后手牌会变少) * @returns 所有能让手牌成胡的牌种下标 */ export function getTingTiles(counts: number[]): number[] { const result: number[] = []; for (let t = 0; t < TILE_KINDS; t++) { if (counts[t] >= 4) continue; // 四张已经全在手上,不可能再摸到 counts[t]++; if (isWinningHand(counts)) result.push(t); counts[t]--; // 恢复现场,千万别漏 } return result; } /** * 出牌推荐:枚举打掉每一张后是否听牌,返回听口最多的那张 * @returns { discard, ting } 打哪张、听几张 */ export function suggestDiscard(counts: number[]): { discard: number; ting: number[] } { let best = { discard: -1, ting: [] as number[] }; for (let t = 0; t < TILE_KINDS; t++) { if (counts[t] === 0) continue; counts[t]--; const ting = getTingTiles(counts); counts[t]++; if (ting.length > best.ting.length) best = { discard: t, ting }; } return best; }

逻辑说明:getTingTiles做的是「假设我再摸一张 t,能不能胡」,枚举 34 种牌各试一次,每次内部跑一遍isWinningHand。34 次乘上递归开销,单次调用在毫秒级以内,放在点击事件里完全无感。suggestDiscard在外层再套一层,枚举 14 张手牌里每一种可以打的牌,算出打完之后听几张,取最大值。

参数上有个必须注意的点:counts在函数内部是被修改然后恢复的,调用方传进来的数组不能同时被其他逻辑持有并异步读,否则会出现「本来听三张,提示只显示一张」的玄学问题。稳妥做法是进函数先slice()一份。另外杠完之后手牌只有 10 张,周期上仍成立,这套判定不依赖固定的 13 张,所以逻辑不用改。

4. 房间与网络:WebSocket 长连接下的状态一致性

4.1 协议分层:cmd + seq + body 三段式

棋牌对局是典型的长连接交互,HTTP 轮询撑不住牌桌上的高频操作。这套工程用 WebSocket,协议体沿用「命令号 + 序号 + 业务体」的三段式。

// protocol/message.ts export interface NetMessage { cmd: number; // 命令号,1000 段是通用,2000 段是大厅,3000 段是牌局 seq: number; // 客户端自增序号,服务端原样回带,用于对齐请求与响应 body: any; // 业务体,项目里可能用 JSON,也可能走 protobuf 序列化后转 base64 } // framework/NetManager.ts export class NetManager { private handlers = new Map<number, (m: NetMessage) => void>(); private seq = 0; private ws: WebSocket | null = null; on(cmd: number, h: (m: NetMessage) => void): void { this.handlers.set(cmd, h); } send(cmd: number, body: any): void { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { console.warn('[Net] 连接未就绪,丢弃消息', cmd); return; } this.ws.send(JSON.stringify({ cmd, seq: ++this.seq, body } as NetMessage)); } /** 收到消息后按 cmd 分发,未注册的命令只打日志不抛错,避免拖垮整条连接 */ dispatch(raw: string): void { const msg = JSON.parse(raw) as NetMessage; const h = this.handlers.get(msg.cmd); if (!h) { console.warn('[Net] 未注册的协议', msg.cmd); return; } h(msg); } }

逻辑说明:cmd按段划分是棋牌项目的惯例,通用、大厅、牌局分开,查日志时一眼能看出属于哪一层。seq由客户端自增,服务端回包时原样带回,请求响应靠它对上号——同一个cmd短时间内发两次(比如连点出牌),没有seq就分不清哪个回包对应哪次请求。dispatch里对未注册的cmd只告警不抛错,是因为灰度期间服务端可能先上了新协议,老客户端收到不认识的消息不该直接崩。

4.2 房间状态机:从入座到结算的六种状态

牌桌逻辑如果散落在各个按钮的回调里,加一个玩法就能改出 bug。这套工程把房间收敛成一个状态机:IDLE(空桌)→WAITING(等齐人)→DEALING(发牌动画)→PLAYING(轮流出牌)→SETTLE(算分结算)→ROUND_END(准备下一局)。每次状态迁移都由服务端消息驱动,客户端本地不允许自迁移。

这么定规矩的收益在联调期最明显:客户端只要盯着「当前状态和我收到的消息类型是否匹配」,对不上就打日志丢弃并请求一次快照。线上出问题时,日志里能直接看到「在 WAITING 收到了 3005 出牌广播」,一眼定位是服务端推错还是客户端状态没跟上。

4.3 断线重连:快照恢复比增量补发靠谱

移动端切后台、地铁进隧道、信号跳 4G,断线是常态。重连方案有两条路:增量补发漏掉的消息,或者直接推一份整桌快照。这套工程选的是后者,因为增量补发要维护消息队列和确认机制,一处漏发就永久错位,而快照是幂等的,重推几次结果一样。

// protocol/room.ts /** 重连快照:服务端一次性推给客户端,客户端整桌重建,不做增量合并 */ export interface RoomSnapshot { roomId: string; round: number; // 第几局,用来丢弃过期快照 seat: number; // 自己的座位号 0~3 hand: number[]; // 自己的手牌,只有自己能看到真实牌面 handCounts: number[]; // 其余三家的手牌张数,只给数量不给牌面 melds: number[][]; // 四家的副露(吃碰杠) discards: number[][]; // 四家的牌河 curSeat: number; // 当前该谁出牌 lastDiscard: number; // 最近一张打出的牌,用于处理吃碰杠优先级 remainWall: number; // 牌墙剩余张数 actionList: number[]; // 自己当前可执行的操作:1吃 2碰 4杠 8胡 16过 deadline: number; // 当前操作的服务端截止时间戳,用于本地倒计时 } // game/mj/RoomController.ts onSnapshot(snap: RoomSnapshot): void { if (snap.round < this.currentRound) return; // 过期快照直接丢 this.currentRound = snap.round; this.hand.rebuild(snap.hand); // 手牌重建并重排 this.discardPool.rebuild(snap.discards); // 牌河重建 this.playerBar.updateAll(snap.handCounts, snap.melds); this.actionBar.setActions(snap.actionList); this.turnIndicator.setSeat(snap.curSeat, snap.deadline); // 倒计时以服务端时间为准 this.fsm.switchTo('PLAYING'); }

逻辑说明:rebuild系列方法都是幂等的,重推同一份快照结果一样,不用担心重复执行。round用来防乱序,老快照直接丢。deadline存的是服务端时间戳而不是「剩余秒数」,客户端用本地时间差算倒计时,这样即使重连花了三秒,倒计时也不会重新从 15 秒开始跳——这是很多项目翻车的细节,用户在重连后发现倒计时莫名变长,怀疑被针对。

参数上handCounts和hand必须分开,服务端不能把别家的牌面发下来,这是最基本的防作弊底线。actionList用位掩码表示,比传字符串数组省,扩展新操作时只要加一位。

4.4 服务端校验:客户端算出来的胡牌只能当提示

第 3 章那套判定算法,在联网模式下的定位是「给用户提前亮按钮」,不是判定的最终依据。客户端算完发现能胡,亮按钮,点下去发一个「请求胡牌」的命令,服务端拿自己那份 authoritative 的牌面再算一遍,通过了才广播结算。

这么做的直接原因很现实:客户端是黑匣子,改个内存就能改判定结果。上线运营之后,任何能被客户端单方面决定胜负的逻辑都是风险。所以设计上要保证三点:一是客户端不持有别家的牌面;二是所有改变牌局状态的操作都要带seq回服务端确认;三是结算面板的数字全部来自服务端的结算消息,客户端本地算的只用于展示进程中的估算值,且标记为「预计」。

真机上还有一个细节:弱网时点胡可能几秒才回,用户会重复点。按钮点完立刻置灰,并且带上请求序号,服务端对同一seq做幂等处理,重复请求只认第一次。

5. 避坑与排查:上线前最容易翻车的五处

5.1 构建与资源

现象:部分安卓机进牌桌黑屏,日志里没有任何报错,重进有时又好。原因:子包资源放在远程,构建产物的配置文件和资源版本号对不上,缓存里拿的是老版本的资源索引,加载到一半找不到对应的图集。解决:把资源版本号写进远程路径(/mj/v12/...),每次发版换路径而不是覆盖;配置文件的缓存时间设短,加载失败时重试一次再降级到内置包。

现象:连续进出牌桌五六次之后闪退,低端机必现。原因:音效和牌面图集只加载不释放,JS 层看着没事,原生侧内存一路涨上去,最后被系统杀掉。解决:退出牌桌时统一走releaseUnusedAssets,音效改成按需加载并做 LRU 缓存,图集按场景分两份而不是一次性全加载。

5.2 算法与状态

现象:结算时某家手牌数量对不上,客户端显示 13 张但服务端说是 14 张。原因:摸牌广播和出牌广播在弱网下乱序到达,客户端又在本地做了「乐观更新」——点出牌先把牌删了再等服务端确认,两条广播一交叉就多删或少删一张。解决:所有牌局变更都带服务端递增序号,客户端维护一个小的乱序缓冲,按序应用;本地乐观更新只在 UI 层做,数据层等确认。

现象:胡牌按钮偶尔不亮,用户反馈「明明能胡」。原因:顺子判定边界写成了i % 9 <= 7,跨花色的三张牌被误判成顺子,同时也可能导致合法牌型被更早的错误拆解吃掉,导致回溯走岔路。解决:改成i % 9 <= 6,并补一组边界单测,覆盖 8/9 筒、8/9 条、8/9 万这三种跨界组合。

现象:断线重连成功,牌面也在,但操作按钮全灰,只能看不能打。原因:快照恢复时只重建了手牌和牌河,没有恢复curSeat和actionList,状态机还停在IDLE。解决:把RoomController.onSnapshot做成唯一的重建入口,任何恢复路径都走它,重建末尾统一刷一次 UI。

5.3 网络与运营

现象:热更新后,一部分老用户卡在登录页转圈,清缓存才好。原因:登录接口返回的版本号和本地包内记录的版本号不一致,客户端进了「等待更新」分支,但更新地址是空的。解决:版本比对逻辑加兜底,地址为空时直接放行到大厅并打点上报;灰度发布先放 5% 的量观察半小时。

现象:某个渠道包首日崩溃率明显高于其他渠道。原因:那个渠道的构建配置里没开图片压缩,牌面图集比别的包大了一倍,中低端机解码时内存吃紧。解决:把图集压缩参数写进构建脚本而不是靠手工勾选,出包后核对一遍包内图集尺寸。

6. 二次开发:换玩法、换皮和一套能复用的验证清单

这套工程的二次开发价值集中在两处:换皮和加玩法。换皮只动assets/bundles下的图集和音效,代码层面唯一要注意的是牌面图集必须保持 index 与牌种下标的映射一致,0-8对应万、9-17对应条,画师交图时按这个顺序命名,能省掉一大轮对图。加玩法则从game/mj/core里复制一份规则配置,把胡牌判定里的七对、十三幺这类分支做成可开关的策略,而不是直接改isWinningHand。

改造点涉及位置验证方式
换牌面皮肤bundles/mj/texture/*起一局看 34 种牌是否全部对上,重点核对字牌
加地方玩法core/win.ts的策略分支 + 服务端规则配置用固定种子的牌墙跑回放,对比结算分
改房间人数RoomSnapshot的seat范围 + 服务端三人桌跑满 100 局,看牌墙是否够用
调动画节奏牌桌预制体上的 Animation 组件低端机跑一局,看发牌是否掉帧

最后一类验证别省:拿种子固定住牌墙,跑一遍完整回放,比对客户端和服务端的结算结果。只要两边不一致,就一定是某处规则分支没对齐,而这类差异在正常对局里可能一百局才碰上一次,靠人工试是试不出来的。

从那以后我每次动规则代码,都强制走一遍「固定种子 + 回放比对」,哪怕是只加了一个牌型判断的小改动。棋牌项目最贵的不是写功能,是出错之后在线上定位一局已经结束的牌——那时候用户只会说「这牌明明能胡」,而你手里只有一个时间戳。希望帮到你。

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

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

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

立即咨询