news 2026/10/31 7:56:46

Cocos Creator麻将棋牌项目:牌局逻辑、状态机与断线重连

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Cocos Creator麻将棋牌项目:牌局逻辑、状态机与断线重连

简介:面向棋牌游戏开发学习者与独立开发者的《cocos creator达达麻将棋牌游戏》项目包,以Cocos Creator为前端引擎,串联JavaScript游戏逻辑、Node.js后端服务与MySQL数据存储,可用于理解麻将规则实现、玩家匹配、登录与数据同步等核心流程,也适合作为上线运营前的技术参考。压缩包共约2000个文件、51.62MB,以json配置、js脚本、meta资源描述、png贴图、md说明文档为主,并含音频、预制体、动画及构建相关文件,较完整地呈现客户端与服务器工程结构。已有3264人学习下载,说明其在实际项目研读中具有一定参考热度。读者可从中梳理前后端协作方式、数据库表设计与热更新思路,并结合安全性、性能优化、社区建设、后期更新和兼容性测试等运营要点,形成对网络棋牌项目从开发到上线的整体认识。

1. 从一局打完多两张牌说起:cocos creator 麻将棋牌项目到底难在哪

前阵子帮朋友看他接手的一个 cocos creator 麻将棋牌项目,双击预览,牌墙摆得整整齐齐,界面也挺像样。可一局打下来,手上莫名多了两张牌,胡牌按钮死活不亮。查了半天发现跟美术、跟引擎都没关系,是牌墙取牌用shift()从头部拿,杠后补牌的位置算错了,再叠加一个递归判定没还原计数数组。这类活儿在棋牌游戏里特别典型:玩法规则看着简单,真正决定能不能上线运营的,是牌局逻辑的严谨程度和状态同步的容错能力。下面这套解析基于一个完整的麻将棋牌客户端工程(后面统一叫 M 项目),讲清楚它的分层结构、牌局核心算法、网络同步方式,以及要换皮加玩法时该动哪几个文件。

这套东西适合两类人:一类是手里已经拿到源码、想先跑通再改的开发者;另一类是自己想用 Cocos Creator 从零搭一套牌桌框架的人。M 项目是 Cocos Creator 3.x + TypeScript 的纯 2D 实现,一套代码出 H5、小游戏和原生包,玩法按房间号建房、好友对局走,不涉及任何充值付费链路,纯休闲向。技术栈不花哨,但分层做得比较干净,逻辑层不 import 任何cc模块,这点对后期改规则特别关键。

2. 技术栈与工程骨架:cocos creator 麻将客户端的目录该怎么分层

2.1 引擎版本与语言选型,为什么不用原生

M 项目锁的是 Cocos Creator 3.8 一线,TypeScript 全量,没有留 JavaScript 脚本。选 3.x 而不是 2.x,主要原因是 3.x 的 Asset Bundle 分包和资源释放机制成熟,牌桌这种"图集多、单局生命周期短"的场景很吃这个。用 Creator 而不是原生引擎,理由更直接:麻将牌桌是 100% 的 2D UI 布局,四家头像、手牌、弃牌河、操作按钮,全是九宫格拉伸和锚点适配的活,引擎自带的 Widget 和 Layout 能省掉大量手写适配代码;再就是一套工程能同时构建 web-mobile、微信小游戏和 Android,运营侧换渠道不用重写渲染层。

真正值得说的是语言和分层。牌局逻辑用 TypeScript 写,但有一个硬约束:assets/scripts/core/下面的文件一律不允许import { ... } from 'cc'。这条约束看着像洁癖,实际是保命的——逻辑层脱离引擎之后,可以在 Node 环境里直接跑单元测试,一万局随机牌局几分钟跑完。血泪经验是,早些年把判定逻辑写在 Component 里,改一条地方规则就得开模拟器点半小时,改一次崩一次。

2.2 工程目录逐层拆解

assets/ scripts/ core/ # 纯逻辑,零引擎依赖 mahjong/ tile.ts # 牌的编码、花色、序数工具 wall.ts # 牌墙构建、洗牌、摸牌游标 rule.ts # 胡牌判定、听牌、番型 state.ts # 对局状态机 fsm/ stateMachine.ts # 通用状态机 game/ # 表现层,与引擎强耦合 table/ TableView.ts # 牌桌主视图 HandView.ts # 手牌布局与重排 DiscardView.ts # 弃牌河 player/ PlayerSeat.ts # 座位、头像、状态图标 anim/ DealAnim.ts # 发牌动画 net/ protocol.ts # 消息号与包结构 socket.ts # 连接、心跳、重连 dispatcher.ts # 消息分发 ui/ UIManager.ts # 弹窗栈管理 config/ gameConfig.ts # 房间配置、局数、超时时间 resources/ fonts/ bundles/ lobby/ # 大厅包 table/ # 牌桌包(牌面图集在这里) audio/

这个结构里最关键的一条分界线是core和game。core只管"这手牌能不能胡、听什么、下一状态是谁",game只管"这张牌该飞到哪个坐标、动画播多久"。两边靠事件和纯数据通信,core往外抛的是{ type: 'TILE_DRAWN', seat: 2, tile: 17 }这种结构,game收到之后自己去查坐标。

2.3 资源分包:牌桌包单独切出来

M 项目把牌桌相关的图集、音效、预制体全塞进bundles/table,用 Asset Bundle 加载。原因很实在:大厅进去之前根本不需要那套 108 张牌面图,主包塞进去首包体积直接顶到小游戏上限。配置在资源管理器里勾选"配置为 Bundle",压缩类型选"小游戏分包"或"远程包",代码里这样加载:

import { assetManager, AssetBundle, director } from 'cc'; // 牌桌包名与 bundles/table 目录一致 const TABLE_BUNDLE = 'table'; export async function loadTableBundle(): Promise<AssetBundle> { // 已加载过就直接复用,避免重复 IO const cached = assetManager.getBundle(TABLE_BUNDLE); if (cached) return cached; return new Promise((resolve, reject) => { assetManager.loadBundle(TABLE_BUNDLE, (err, bundle) => { if (err) { reject(err); return; } resolve(bundle); }); }); } // 进入牌桌场景时调用 export async function enterTableScene() { await loadTableBundle(); director.loadScene('table'); }

assetManager.getBundle先查缓存这一句别省,来回切大厅和牌桌的时候重复loadBundle会打出重复的 IO 请求,低端机上表现为切场景卡两秒。loadBundle的回调里只做 resolve,真正的资源load放到场景onLoad之后,让首帧先出来再慢慢填牌面,体感快很多。

2.4 大厅到牌桌:常驻节点与场景切换

M 项目用两个场景:lobby和table。但网络连接、玩家信息、音频管理这些东西不能跟着场景销毁,所以要有一个常驻节点。Creator 3.x 的写法是director.addPersistRootNode:

import { _decorator, Component, director, Node } from 'cc'; const { ccclass, property } = _decorator; @ccclass('GameRoot') export class GameRoot extends Component { private static _inst: GameRoot | null = null; static get inst(): GameRoot { if (!this._inst) throw new Error('GameRoot 尚未初始化'); return this._inst; } // 挂在 lobby 场景根节点上,切场景时自动带过去 onLoad() { if (GameRoot._inst) { // 二次进入场景时重复挂载,直接销毁多余节点 this.node.destroy(); return; } GameRoot._inst = this; director.addPersistRootNode(this.node); } onDestroy() { if (GameRoot._inst === this) GameRoot._inst = null; } }

这里有个坑值得单说:addPersistRootNode要求节点必须是场景的根节点,挂在 Canvas 下面调用是不生效的,而且不会报错,只是切场景时节点跟着没了,表现为"重连逻辑莫名其妙丢失"。onLoad里做单例判重也是必要的,从牌桌返回大厅再进牌桌,如果不判重会有两份GameRoot同时监听网络消息,一个消息触发两次 UI 刷新,弹窗叠弹窗。

3. 牌局核心拆解:洗牌、发牌、胡牌判定到回合状态机

3.1 牌的编码约定,先把这件事定死

麻将牌在代码里就是整数。M 项目用0..33共 34 种牌,每种 4 张,合计 136 张。编码顺序是先数牌后字牌:

  • 0..8一万到九万
  • 9..17一条到九条
  • 18..26一筒到九筒
  • 27..33东南西北中发白

这个约定必须一次定死,因为它贯穿牌墙、判定、协议、美术资源名。改编码等于全项目重来。配套的工具函数:

// core/mahjong/tile.ts export const TILE_KINDS = 34; // 牌的种类数 export const TILE_TOTAL = 136; // 一副牌张数 export const WORD_START = 27; // 字牌起始编码 export const enum Suit { Wan = 0, Tiao = 1, Tong = 2, Word = 3, } export function suitOf(tile: number): Suit { if (tile < 9) return Suit.Wan; if (tile < 18) return Suit.Tiao; if (tile < 27) return Suit.Tong; return Suit.Word; } /** 数牌在花色内部的序数,1~9;字牌返回 0 */ export function rankOf(tile: number): number { return tile < WORD_START ? (tile % 9) + 1 : 0; } /** 判断两张牌是否构成"吃"的相邻关系,字牌不能吃 */ export function canFormSequence(a: number, b: number, c: number): boolean { if (a >= WORD_START || b >= WORD_START || c >= WORD_START) return false; if (suitOf(a) !== suitOf(b) || suitOf(b) !== suitOf(c)) return false; const sorted = [a, b, c].sort((x, y) => x - y); return sorted[0] + 1 === sorted[1] && sorted[1] + 1 === sorted[2]; }

rankOf里tile % 9 + 1这个是数牌的序数,用来算顺子边界。注意判断顺子起始牌时不能只看tile % 9 <= 6,还得加tile < WORD_START,否则字牌会被错误地当成"七万"参与顺子判定——这个 bug 我在两个项目里都见过。

3.2 洗牌与发牌,随机源要能复现

牌墙构建很简单,34 种各 4 张推成一个数组,然后洗。但洗牌这里有个不该省的设计:随机源要可注入。

// core/mahjong/wall.ts import { TILE_KINDS } from './tile'; export class Wall { private tiles: number[] = []; private cursor = 0; // 正常摸牌游标,从牌墙头往后走 private tailCursor: number; // 杠后补牌游标,从牌墙尾往前收 /** rnd 可注入,便于用固定种子回放同一局 */ constructor(rnd: () => number = Math.random) { for (let id = 0; id < TILE_KINDS; id++) { for (let k = 0; k < 4; k++) this.tiles.push(id); } this.shuffle(rnd); this.cursor = 0; this.tailCursor = this.tiles.length - 1; } // Fisher-Yates,原地洗,复杂度 O(n) private shuffle(rnd: () => number) { for (let i = this.tiles.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [this.tiles[i], this.tiles[j]] = [this.tiles[j], this.tiles[i]]; } } /** 正常摸牌 */ draw(): number | null { if (this.cursor > this.tailCursor) return null; // 牌墙摸完,流局 return this.tiles[this.cursor++]; } /** 杠后从牌尾补牌 */ drawFromTail(): number | null { if (this.cursor > this.tailCursor) return null; return this.tiles[this.tailCursor--]; } get remainCount(): number { return this.tailCursor - this.cursor + 1; } }

两个游标分别从头从尾走,是麻将的标准做法,draw和drawFromTail各自维护自己的位置,remainCount用两个游标算剩余。如果用数组shift()和pop(),每次都是 O(n) 的元素搬移,136 张无所谓,但你想想如果有人在里面做牌局的期望值模拟,跑十万局就废了。rnd 注入这一手,是为了做回放和复现 bug:线上反馈"某局判定不对",把种子捞出来在本地重放,问题直接定格,比看日志高效太多。

发牌阶段,庄家 14 张、其余三家各 13 张,实际产品里要按骰子决定起手位置,M 项目简化为庄家固定从牌墙头取,四家轮流取 13 张后再给庄家补一张。

3.3 胡牌判定与听牌计算,递归要成对还原

这是整个项目最核心的一段。判定分两路:七对子型和标准型(四组面子 + 一对将)。

// core/mahjong/rule.ts import { TILE_KINDS, WORD_START } from './tile'; /** 一手牌转成 34 长度的计数数组,索引即牌编码 */ export function toCounts(hand: number[]): number[] { const counts = new Array(TILE_KINDS).fill(0); for (const t of hand) counts[t]++; return counts; } /** 七对子:每种牌都是偶数张,且总对数为 7(四张算两对) */ function checkSevenPairs(counts: number[]): boolean { let pairs = 0; for (let i = 0; i < TILE_KINDS; i++) { if (counts[i] % 2 !== 0) return false; pairs += counts[i] / 2; } return pairs === 7; } /** * 从 counts 里递归拆出 need 组面子 * 注意:进入时改 counts,返回前必须还原 */ function removeMelds(counts: number[], need: number): boolean { // 找到第一张还有剩余的牌 let i = 0; while (i < TILE_KINDS && counts[i] === 0) i++; if (i === TILE_KINDS) return need === 0; // 牌用完了且面子数刚好够 // 分支一:刻子 if (counts[i] >= 3) { counts[i] -= 3; const ok = removeMelds(counts, need - 1); counts[i] += 3; // 关键:无论成功失败都还原 if (ok) return true; } // 分支二:顺子,只有数牌且序数 <= 7 才可能起头 if (i < WORD_START && i % 9 <= 6 && counts[i + 1] > 0 && counts[i + 2] > 0) { counts[i]--; counts[i + 1]--; counts[i + 2]--; const ok = removeMelds(counts, need - 1); counts[i]++; counts[i + 1]++; counts[i + 2]++; if (ok) return true; } return false; } /** 标准型:枚举将牌,剩下的拆四组面子 */ export function checkStandardWin(counts: number[]): boolean { for (let i = 0; i < TILE_KINDS; i++) { if (counts[i] < 2) continue; counts[i] -= 2; // 取出将牌 const ok = removeMelds(counts, 4); counts[i] += 2; // 一定要还回去 if (ok) return true; } return false; } export function canWin(counts: number[]): boolean { if (checkSevenPairs(counts)) return true; return checkStandardWin(counts); }

逐段说。toCounts把 14 张牌压成 34 长度的计数数组,这是所有判定的基础结构,比在原数组上做indexOf/splice快一个数量级。removeMelds是典型回溯:先找最小的还有余量的牌,它要么当刻子用掉三张,要么当顺子的头用掉三张,两条路都试,试完立刻还原。这里还原必须成对——counts[i] -= 3之后不管是成功还是失败都要+= 3,先还原再if (ok) return true。早期版本图省事写成"成功就直接 return,失败再还原",结果遇到"这一路失败但上层要继续试别的分支"时数组状态就脏了,表现为同样的牌型,放在手牌第二位能胡、放第一位不能胡,玄学得很。

checkStandardWin枚举将牌,34 次循环里每次减 2 再递归。这里有个可以优化的点:可以直接传入counts的副本,避免还原逻辑写错,代价是每次递归多一次数组分配。M 项目为了性能选了原地改,但把还原写成固定模式,代码 review 时专门盯这一条。

听牌计算就是拿着 34 种牌挨个试:

/** 返回当前手牌(13 张,不含将摸的那张)的所有听牌 */ export function getTingList(counts: number[]): number[] { const result: number[] = []; for (let t = 0; t < TILE_KINDS; t++) { if (counts[t] >= 4) continue; // 这张牌已经用尽四张,不可能再摸到 counts[t]++; if (canWin(counts)) result.push(t); counts[t]--; } return result; }

counts[t] >= 4这一句跳过判断很重要,不然会算出"听五万"但实际四张五万全在自己手里和弃牌河里,玩家点了半天没有反应。听牌结果一般还要拿去做 UI 提示,M 项目在玩家手牌上方显示一个小圆点标记,这里返回的数组直接喂给表现层。

3.4 回合状态机与操作仲裁

对局流程必须由状态机驱动,不能靠一堆 if 判断"现在轮到谁"。M 项目用五态:

状态含义允许的输入出口
Idle未开局无收到开局消息进Dealing
Dealing发牌动画中无动画完成进WaitAction
WaitAction等待当前玩家操作出牌/吃/碰/杠/胡出牌后进Draw,或胡牌进Settle
Draw从牌墙摸一张无摸完回WaitAction
Settle结算展示无点击继续回Idle或开下一局
// core/mahjong/state.ts export const enum TableState { Idle = 'Idle', Dealing = 'Dealing', WaitAction = 'WaitAction', Draw = 'Draw', Settle = 'Settle', } export interface ActionCtx { seat: number; // 0..3 type: 'discard' | 'chi' | 'peng' | 'gang' | 'hu' | 'pass'; tile?: number; } export class TableFsm { state: TableState = TableState.Idle; currentSeat = 0; /** 统一入口,非法操作直接拒绝,不给"试试看"的机会 */ canAccept(ctx: ActionCtx): boolean { if (this.state !== TableState.WaitAction) return false; if (ctx.seat !== this.currentSeat && ctx.type !== 'pass') return false; return true; } apply(ctx: ActionCtx): boolean { if (!this.canAccept(ctx)) return false; if (ctx.type === 'discard') { // 出牌后轮到下家摸牌 this.currentSeat = (this.currentSeat + 1) % 4; this.state = TableState.Draw; } else if (ctx.type === 'hu') { this.state = TableState.Settle; } // 吃碰杠的处理略,本质是阻断轮转、由操作者接管出牌权 return true; } }

状态机的价值在"仲裁"两个字。四家可能同时能操作——上家打出五万,你和对家都能胡,这时候谁先?M 项目按"胡 > 杠 > 碰 > 吃 > 过"的优先级排队,服务端拍板,客户端只做表现。客户端的状态机只负责"本地现在允许不允许点按钮",不做最终裁决。这个分工必须在项目早期就定下来,否则后面会出现客户端算出来能胡、服务端判了别人胡,两边界面打架。

4. 网络与同步:协议帧、服务端推消息与断线重连

4.1 协议结构与消息号分段

麻将这类回合制对局,协议不复杂,但有两条硬要求:消息要能对号入座、乱序或重发要能识别。M 项目的包结构就三个字段:

// net/protocol.ts export const enum Cmd { // 1000 段:账号与大厅 Login = 1001, EnterLobby = 1002, // 2000 段:房间 CreateRoom = 2001, JoinRoom = 2002, LeaveRoom = 2003, // 2100 段:对局 Ready = 2101, Action = 2102, // 客户端上行操作 SyncSnapshot = 2103, // 服务端下发全量快照 BroadcastAction = 2104, // 服务端广播他人操作 DealTiles = 2105, SettleResult = 2106, } export interface Packet<T = unknown> { cmd: number; seq: number; // 客户端自增,服务端原样回带,用于匹配请求 body: T; } // 上行出牌 export interface ActionBody { type: 'discard' | 'chi' | 'peng' | 'gang' | 'hu' | 'pass'; tile?: number; }

消息号按百位分段,1000 是账号大厅,2000 是房间,2100 是对局。分段是个小习惯但很省事:看到日志里一串 21xx 就知道在牌桌里,不用翻常量表。seq用于请求响应匹配,客户端发出后把 Promise 的 resolve 存进 map,回包按seq取出来。body用unknown泛型,每个cmd配一个具体的 body 类型,收发时做一次类型断言。

4.2 收发封装:请求响应 + 服务端推帧

网络层要同时处理两种模式:客户端主动请求等回包,以及服务端主动推消息。M 项目的socket.ts大致是这样:

// net/socket.ts import { Packet } from './protocol'; type Handler = (body: unknown, seq: number) => void; export class SocketClient { private ws: WebSocket | null = null; private seq = 1; private pending = new Map<number, { resolve: (v: unknown) => void; timer: number }>(); private listeners = new Map<number, Set<Handler>>(); private heartbeat = 0; connect(url: string) { this.ws = new WebSocket(url); this.ws.onopen = () => { // 5 秒一次心跳,服务端 15 秒没收到就踢 this.heartbeat = setInterval(() => this.send(9999, {}), 5000) as unknown as number; }; this.ws.onmessage = (ev) => this.onRaw(ev.data); this.ws.onclose = () => { clearInterval(this.heartbeat); this.reconnect(); }; } /** 请求-响应模式,带 8 秒超时,超时 reject 掉避免 pending 泄漏 */ request<T>(cmd: number, body: unknown, timeout = 8000): Promise<T> { return new Promise<T>((resolve, reject) => { const seq = this.seq++; const timer = setTimeout(() => { this.pending.delete(seq); reject(new Error(`cmd ${cmd} timeout`)); }, timeout) as unknown as number; this.pending.set(seq, { resolve: resolve as (v: unknown) => void, timer }); this.send(cmd, body, seq); }); } /** 订阅服务端推消息 */ on(cmd: number, handler: Handler) { if (!this.listeners.has(cmd)) this.listeners.set(cmd, new Set()); this.listeners.get(cmd)!.add(handler); } private send(cmd: number, body: unknown, seq = 0) { const pkt: Packet = { cmd, seq, body }; this.ws?.send(JSON.stringify(pkt)); } private onRaw(data: string) { const pkt = JSON.parse(data) as Packet; // 先看是不是某个请求的回包 const p = this.pending.get(pkt.seq); if (p) { clearTimeout(p.timer); this.pending.delete(pkt.seq); p.resolve(pkt.body); return; } // 否则当推消息分发 this.listeners.get(pkt.cmd)?.forEach((fn) => fn(pkt.body, pkt.seq)); } private reconnect() { // 退避重连,1s / 2s / 4s,最多 5 次 // 重连成功后触发 SyncSnapshot 拉全量,细节见 4.3 } }

几个必须注意的点。第一,request一定要有超时和pending.delete,否则请求丢包后 Promise 永远挂着,玩家点"准备"按钮的 loading 圈一直转,只能杀进程——这个后悔药加得越早越好。第二,回包匹配优先于推消息分发,同一个seq只可能对应一次请求。第三,心跳间隔要小于服务端判定超时的时长,客户端 5 秒、服务端 15 秒是个安全比例,网络抖动一两次不会掉线。

4.3 断线重连与快照恢复

重连是对局类项目最容易做成黑匣子的一块。M 项目的策略是"全量快照 + 序号对账":重连成功后客户端立刻发SyncSnapshot,服务端返回当前座位、手牌、弃牌河、当前状态、当前轮到谁、最后一次已广播的动作序号lastSeq。客户端拿本地已处理的最大seq跟它比,小就补,大就说明本地有脏数据,直接以快照为准重建。

// game/table/TableController.ts import { SocketClient } from '../../net/socket'; import { Cmd } from '../../net/protocol'; interface Snapshot { seat: number; hand: number[]; discards: number[][]; state: string; currentSeat: number; lastSeq: number; } export class TableController { private localSeq = 0; constructor(private sock: SocketClient) {} async onReconnected() { const snap = await this.sock.request<Snapshot>(Cmd.SyncSnapshot, {}); if (snap.lastSeq < this.localSeq) { // 本地比服务端还新,理论上不该发生,直接以服务端为准重建 console.warn('local seq ahead of server, force rebuild'); } this.rebuildFromSnapshot(snap); this.localSeq = snap.lastSeq; } private rebuildFromSnapshot(snap: Snapshot) { // 一步到位重建视图:手牌重排、弃牌河清空重铺、状态机置位 // 注意状态机也要恢复,不能只恢复数据 this.applyState(snap.state, snap.currentSeat); } private applyState(state: string, currentSeat: number) { // 状态机置为服务端状态,本地按钮可用性随之刷新 } }

这段里最容易被忽略的是applyState——只恢复数据不恢复状态机,就会出现"手牌显示正确、但操作按钮全灰、点不动"的情况。因为按钮的可用性是由状态机算出来的,状态机还停在Idle,自然全灰。我在一个类似项目里排查过这个现象,日志里数据全对,就是按钮死灰,最后发现是快照恢复只走了数据分支。

4.4 操作预表现与服务端拒绝时的回滚

为了让手感跟手,出牌点击之后本地立刻把手牌里的牌移走、动画飞到弃牌河,不等服务端回包。这就有个问题:服务端可能因为"不是你的回合"或"这张牌你没有"而拒绝。做法是给这张牌打一个 pending 标记:

private pendingTile: number | null = null; onDiscardClick(tile: number) { // 1. 本地先移走,播放飞牌动画 this.pendingTile = tile; this.handView.removeTile(tile, { pending: true }); // 2. 发请求 this.sock.request(Cmd.Action, { type: 'discard', tile }) .then(() => { this.pendingTile = null; }) .catch(() => { // 3. 被拒或超时,把牌塞回手牌并重排,给一个轻提示 if (this.pendingTile !== null) { this.handView.addTile(this.pendingTile); this.handView.relayout(); this.pendingTile = null; } }); }

pendingTile只存一张,因为回合制下玩家不可能连续出两张牌;存成数组反而说明状态机有问题。回滚动作要快,别等超时 8 秒才把牌塞回去,玩家会以为游戏卡了,所以产品上一般会把这类请求超时调到 3 秒。

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

5.1 牌局逻辑类的三个坑

现象:一局打完手牌数量不对,有时 13 张有时 15 张。原因:牌墙混用了shift()和正常游标两套取牌方式,杠后补牌从尾部取,但某处补牌写成了draw(),导致原本该从牌尾拿的牌从牌头拿了,两套游标错位,剩余张数计算也跟着偏。 解决:牌墙内部只保留cursor和tailCursor两个整数,所有取牌走draw和drawFromTail两个方法,禁掉外部直接操作数组。再加一条自检:每局结束时手牌 + 弃牌河 + 各家副露 + 牌墙剩余 === 136,不等就在控制台打红字。这条断言上线前跑一千局,能捞出一堆隐蔽问题。

现象:同样的牌型,换一下手牌顺序就胡不了。原因:胡牌判定的递归里counts数组被修改后没有成对还原,某条搜索路径失败后残留了脏数据,后续分支基于脏数据继续算。 解决:把"减三/加三"、"减二/加二"写成固定的成对模板,约束是先还原再判断返回值。更保险的做法是递归入口深拷贝一份counts,牺牲一点性能换正确性,14 张牌的判定量级完全扛得住。

现象:听牌提示不显示,或者显示了但实际摸不到。原因:算听牌时没有排除"这种牌已经用掉四张"的情况,以及没有把弃牌河、副露里的牌算进已用张数。 解决:维护一份全局的已用牌计数,算听牌前把counts[t]加上已用数,达到 4 就跳过。这个计数要在每次出牌、碰杠之后同步更新,别到算的时候临时去遍历弃牌河,容易漏。

5.2 表现层的两个坑

现象:中低端机上牌桌界面掉帧,手机发烫。原因:手牌重排逻辑挂在每帧执行的update里,每帧遍历十几个节点调setPosition,加上节点没有做合批,Draw Call 飙到一百多。 解决:改成脏标记 + 事件驱动,只有手牌集合发生变化时才重排一次,update里只判断标记位。牌面图集合并成一张,同一批渲染的节点顺序在层级里排好,减少材质切换。这两条做完,Draw Call 能从一百多压到个位数。

现象:出牌动画播到一半,别人的操作消息到了,牌桌上出现两张一样的牌在飞。原因:动画没有做互斥,服务端推来的消息直接触发了新动画,旧的没播完。 解决:表现层做一个简单的动画队列,关键动画(发牌、出牌、碰杠)互斥,非关键动画(表情、提示气泡)允许并行。队列里超过一定长度的旧动画直接跳过,宁可少播一帧也别堆积。

5.3 构建发布的坑

现象:构建到小游戏平台提示首包超限。原因:牌面图集、音效全在主包里,没有做 Bundle 拆分,或者拆了但没勾"小游戏分包"。 解决:bundles/table配置成 Bundle,压缩类型按目标平台选分包或远程包;牌面用压缩纹理,108 张牌面合到一张 2048 图集里;首场景只留 loading 和必要的 UI 图。构建后用引擎的分包分析面板看一遍,哪个包超了心里有数。

现象:本地调试一切正常,真机上牌桌进去黑屏。原因:远程 Bundle 的加载地址在真机环境跟编辑器预览不一致,或者跨域配置没放行。 解决:Bundle 的 baseUrl 统一走配置表,不同环境读不同值,别硬编码。真机必测一遍完整的"大厅 → 建房 → 打一局 → 退出"闭环,别只测编辑器预览。

6. 二次开发:换皮、加玩法与自测脚本怎么落地

换皮是最常见的需求,也是最容易做崩的。M 项目的牌面是按编码命名的,tile_0.png到tile_33.png一一对应,换皮只需要替换图集里的切图,尺寸保持一致,逻辑层一行不用动。唯一要动的是手牌布局的间距参数——有些皮牌面更宽,13 张牌在 720 宽的屏幕上会挤,得在HandView里把spacing从固定值改成按容器宽度和牌数动态算。这个参数化改造建议动手前就做掉,不然每换一套皮调一次。

加地方玩法是重头戏。核心思路是把规则抽成接口,别往rule.ts里堆 if。常见做法是定义一个规则集接口,主流程只认接口:

// core/mahjong/ruleSet.ts export interface WinContext { hand: number[]; // 手牌编码 melds: number[][]; // 副露 winTile: number; // 胡的那张 isSelfDraw: boolean; // 是否自摸 seatWind: number; // 门风 } export interface RuleSet { readonly name: string; /** 是否构成胡牌 */ canWin(ctx: WinContext): boolean; /** 算番/算分 */ score(ctx: WinContext): { fan: number; detail: string[] }; /** 是否允许吃(有的地方规则不吃) */ allowChi(): boolean; } // 默认规则:标准型 + 七对 export class DefaultRule implements RuleSet { readonly name = 'default'; canWin(ctx: WinContext): boolean { return checkStandardWin(toCounts(ctx.hand)) || checkSevenPairs(toCounts(ctx.hand)); } score(ctx: WinContext) { // 简化的番型计算,按需扩展 return { fan: ctx.isSelfDraw ? 2 : 1, detail: [ctx.isSelfDraw ? '自摸' : '点炮'] }; } allowChi() { return true; } }

改造点与验证方式的对应关系,可以按这张表推进:

改造目标主要影响文件验证方式
换牌面皮肤bundles/table图集、HandView.spacing三档分辨率截图对比,13 张手牌不重叠
加地方番型ruleSet.ts新增实现、score分支构造 20 组已知牌型跑单测,比对番数
改回合规则(如不吃)TableFsm.canAccept、allowChi回放 10 局录像,确认没有吃牌按钮出现
改房间人数config/gameConfig.ts、座位布局开一局跑完整个闭环,检查弃牌河布局

自测这块,我强烈建议把core目录单独跑起来。因为它不依赖cc,可以直接用 Node + 一个测试框架执行,写一个万局随机对局脚本:

// tools/simulate.ts,用 ts-node 直接跑,不经过引擎 import { Wall } from '../assets/scripts/core/mahjong/wall'; import { canWin, toCounts } from '../assets/scripts/core/mahjong/rule'; function simulateOneRound(seed: number) { // 固定种子的伪随机,保证同一 seed 结果一致 let s = seed; const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff); const wall = new Wall(rnd); const hands: number[][] = [[], [], [], []]; for (let r = 0; r < 13; r++) { for (let seat = 0; seat < 4; seat++) hands[seat].push(wall.draw()!); } hands[0].push(wall.draw()!); // 庄家多一张 // 断言:四家手牌 + 剩余牌墙 = 136 const total = hands.reduce((a, h) => a + h.length, 0) + wall.remainCount; if (total !== 136) throw new Error(`牌数不守恒: ${total} seed=${seed}`); return { hands, wall }; } for (let i = 0; i < 10000; i++) simulateOneRound(i); console.log('10000 rounds ok');

每局结束校验牌数守恒,是这套自测里性价比最高的一条——它能自动兜住绝大多数取牌、补牌、弃牌环节的错位。跑通之后再加判定正确性:构造已知能胡、不能胡、听牌的手牌各若干组,断言canWin和getTingList的返回。这套脚本我一般会挂到提交前的钩子里,跑一次十几秒,比开模拟器手点靠谱得多。

最后说个教训。早些年我做另一个牌类项目,图省事把所有判定逻辑写在 Component 里,改一条地方规则要开模拟器点半小时,改一次崩一次,最后重构花了比原开发更长的时间。从那以后我每次接牌类项目,第一件事就是把core目录隔离出来、把单测脚本跑通,再谈表现层的事。这套顺序看着慢,实际是省下后面所有的排查时间。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 7:56:11

MySQL事务原理与实践:从ACID特性到高并发优化

1. MySQL事务基础概念 事务(Transaction)是数据库管理系统执行过程中的一个逻辑单位&#xff0c;由一个有限的数据库操作序列构成。在MySQL中&#xff0c;事务是最小的不可再分的工作单元&#xff0c;它要么全部执行成功&#xff0c;要么全部不执行。 1.1 事务的四大特性(ACI…

作者头像 李华
网站建设 2026/10/31 7:56:10

MySQL高可用集群实战:Group Replication与InnoDB Cluster

1. MySQL高可用集群的核心价值与挑战 在互联网业务爆发式增长的今天&#xff0c;数据库作为业务系统的核心组件&#xff0c;其稳定性直接决定了服务的SLA水平。我经历过多次因数据库单点故障导致的线上事故&#xff0c;深刻体会到高可用架构不是"锦上添花"而是"…

作者头像 李华
网站建设 2026/10/31 7:55:51

防火墙安全区域部署实战:从区域划分到策略落点的避坑指南

防火墙这玩意儿&#xff0c;配过的人都知道&#xff0c;真正让人头疼的从来不是“要不要开”&#xff0c;而是“这条策略到底该挂在哪个区域、哪个方向”。我见过太多机房里的设备&#xff0c;策略写了上百条&#xff0c;真出问题时抓包一看&#xff0c;流量压根没走到你以为的…

作者头像 李华
网站建设 2026/10/31 7:55:35

图神经网络、大模型优化与表格分析:十条论文的可复用工程零件

前阵子整理自己的阅读笔记&#xff0c;发现过去大半年反复翻的那十来篇工作&#xff0c;居然刚好落在图神经网络、大模型优化、表格分析这三条线上。一开始我以为是巧合&#xff0c;毕竟这三块平时在社区里各聊各的&#xff0c;连会议都不太重叠。直到把笔记摊开按“它到底解决…

作者头像 李华
网站建设 2026/10/31 7:55:10

FOC从Clark/Park变换到SVPWM工程实现与避坑

简介&#xff1a;这份PDF笔记面向正在学习或调试FOC的电机控制初学者与工程实践者&#xff0c;围绕磁场定向控制的核心链路整理理论推导与算法实现&#xff0c;帮助读者把抽象概念落到可计算、可仿真的步骤上。内容从FOC本质与超前角控制切入&#xff0c;依次梳理Clark变换、Pa…

作者头像 李华
网站建设 2026/10/31 7:55:09

iPhone打开ICS文件全指南:导入、订阅与排错

1. 先搞清楚ICS到底是什么文件&#xff0c;再谈用什么打开 每次在群里丢一个.ics附件&#xff0c;十有八九会有人问“这玩意怎么打开”。问题通常不是文件坏了&#xff0c;而是大家把它当成了一种需要“专用软件”的文档。我的经验是&#xff1a; ICS格式 本质就是一份纯文本…

作者头像 李华