☰
Cocos Creator达达麻将棋牌游戏开发:规则引擎与状态同步实战
2026/9/26 4:39:48 网站建设 项目流程

简介:这是一套基于Cocos Creator引擎开发的达达麻将棋牌游戏完整项目源码,面向具备一定JavaScript基础、希望学习网络棋牌游戏前后端架构的开发者与独立游戏团队。项目以Cocos Creator完成图形界面与场景设计,前端逻辑用JavaScript实现麻将规则、用户交互与状态管理,后端采用Node.js处理登录、匹配与数据同步,并以MySQL存储玩家信息、游戏记录与积分,同时涉及热更新、安全性防护、性能优化与多端兼容等上线运营要点。资源包共约2000个文件,以json配置、js脚本、meta资源描述、png图片、prefab预制体、anim动画、mp3音效及sql脚本等为主,压缩包约51.62MB,目录结构完整便于按模块查阅。目前已有3232人学习下载,适合作为棋牌类项目从开发到上线的参考实例,帮助读者理解前后端协作流程与核心模块组织方式。

1. 达达麻将棋牌游戏:从 Cocos Creator 工程到能上桌的完整牌局

很多人第一次搜「cocos creator达达麻将棋牌游戏」,心里想的其实是同一件事:我能不能用 Cocos Creator 把一套能跑起来的麻将牌局做出来,而不是只停留在画几张牌、摆几个按钮。达达麻将这类地方麻将玩法,核心不是美术,而是「规则引擎 + 状态同步 + 出牌交互」这三件事能不能咬合。Cocos Creator 在这里的价值,是把牌桌 UI、触摸交互、动画表现和逻辑层用同一套 TypeScript 工程管起来,省掉前后端两套语言来回翻译的成本。这篇笔记面向的是已经会一点 Cocos Creator、想真正落地一个麻将棋牌项目的开发者,从工程结构讲到牌型判定、出牌流程、打包 APK 的实操,把能抄的代码和会翻车的地方都摊开讲。

2. 达达麻将的工程结构与牌局数据模型怎么定

2.1 为什么逻辑层必须和渲染层彻底分开

麻将棋牌游戏和普通休闲游戏最大的区别,是它的「正确性」要求极高。一张牌算错番、一个吃碰顺序判错,玩家立刻就能感觉到,而且这类 bug 极难复现。所以我在做达达麻将这类项目时,第一条铁律就是:牌局逻辑层不引用任何 cc 节点、不引用任何 UI 组件,纯 TypeScript 类,能脱离 Cocos 运行时单独跑单元测试。

常见做法是建三个目录:core(纯逻辑)、view(Cocos 组件与预制体)、net(通信与房间)。core里放牌墙、手牌、牌型判定、番型计算;view只负责把core抛出的状态渲染成牌面。这样做的直接好处是,你可以在 Node 环境里用脚本模拟一万局,验证胡牌判定和番数计算,而不用每次都在编辑器里点半天。

// core/MahjongTile.ts // 牌的唯一标识:花色 + 点数,0-8 万,9-17 条,18-26 筒,27-33 字牌 export enum TileSuit { WAN = 0, TIAO = 1, TONG = 2, ZI = 3, } export class MahjongTile { readonly id: number; // 0 ~ 33 constructor(id: number) { if (id < 0 || id > 33) throw new Error(`非法牌 id: ${id}`); this.id = id; } get suit(): TileSuit { if (this.id < 9) return TileSuit.WAN; if (this.id < 18) return TileSuit.TIAO; if (this.id < 27) return TileSuit.TONG; return TileSuit.ZI; } get rank(): number { // 字牌 rank 从 1 开始,方便做顺子判断时排除 return this.suit === TileSuit.ZI ? this.id - 26 : (this.id % 9) + 1; } // 是否可以作为顺子的一部分(字牌不行) get isSequenceable(): boolean { return this.suit !== TileSuit.ZI; } }

这段代码的关键点是id的编码方式。把 34 种牌压成一个 0~33 的整数,后面所有手牌、牌墙、弃牌堆都可以用number[]表示,排序、去重、哈希都快。rank对字牌单独处理,是因为字牌只能做刻子不能做顺子,判定逻辑里要频繁区分。参数上唯一要守的是id边界,越界直接抛错,别让它悄悄流进判定函数里变成玄学 bug。

2.2 手牌用数组还是用计数表

新手最容易踩的坑,是手牌用MahjongTile[]存,然后每次判胡都去filter、sort。牌局一多,GC 压力上来,低端机上就能看到明显卡顿。我一般会把手牌在逻辑层表示成一张「计数表」:长度 34 的number[],counts[id]表示这张牌有几张。

// core/HandTiles.ts export class HandTiles { // counts[i] 表示 id 为 i 的牌有几张,取值 0~4 private counts: number[] = new Array(34).fill(0); add(id: number): void { if (this.counts[id] >= 4) throw new Error(`牌 ${id} 超过 4 张`); this.counts[id]++; } remove(id: number): void { if (this.counts[id] <= 0) throw new Error(`手里没有牌 ${id}`); this.counts[id]--; } // 导出成排序后的 id 数组,只给 UI 层用 toSortedIds(): number[] { const result: number[] = []; for (let i = 0; i < 34; i++) { for (let c = 0; c < this.counts[i]; c++) result.push(i); } return result; } getCount(id: number): number { return this.counts[id]; } }

计数表的好处是判胡、判碰、判杠全是 O(34) 的常数级操作,而且天然防止「同一张牌出现 5 次」这种脏数据。toSortedIds只在刷新 UI 时调用,逻辑层内部永远不碰数组排序。参数上要注意的是add里的 4 张上限校验,这是麻将规则硬约束,早校验早暴露问题,别等到判胡时才发现数据已经烂了。

2.3 牌墙与发牌:随机种子要能复现

达达麻将这类游戏,如果要做回放或者断线重连,牌墙必须是可复现的。我一般用一个带种子的伪随机数生成器来洗牌,而不是直接Math.random()。

// core/SeededRandom.ts export class SeededRandom { private seed: number; constructor(seed: number) { this.seed = seed >>> 0; } // xorshift32,够用且快 next(): number { let x = this.seed; x ^= x << 13; x ^= x >>> 17; x ^= x << 5; this.seed = x >>> 0; return this.seed / 0xffffffff; } nextInt(maxExclusive: number): number { return Math.floor(this.next() * maxExclusive); } } // core/Wall.ts export class Wall { tiles: number[] = []; constructor(rng: SeededRandom) { // 每种牌 4 张,共 136 张 for (let id = 0; id < 34; id++) { for (let k = 0; k < 4; k++) this.tiles.push(id); } // Fisher-Yates 洗牌 for (let i = this.tiles.length - 1; i > 0; i--) { const j = rng.nextInt(i + 1); [this.tiles[i], this.tiles[j]] = [this.tiles[j], this.tiles[i]]; } } draw(): number { const t = this.tiles.pop(); if (t === undefined) throw new Error('牌墙已空'); return t; } }

种子从房间创建时下发,服务端和客户端用同一个种子,牌墙就完全一致。这样断线重连只需要重放操作序列,不用同步整副牌。参数上seed用 32 位无符号整数,xorshift32的周期足够覆盖一局牌,别用Math.random混进来,否则回放必崩。

3. 胡牌判定与番型计算:达达麻将规则引擎怎么写

3.1 标准胡牌判定的递归骨架

胡牌判定的本质是:14 张牌能否拆成 4 个面子(顺子或刻子)加 1 对将。最稳的写法是递归回溯,配合计数表。

// core/HuChecker.ts export function canHu(counts: number[]): boolean { // 先找将牌 for (let i = 0; i < 34; i++) { if (counts[i] >= 2) { counts[i] -= 2; if (canFormMelds(counts, 0)) { counts[i] += 2; return true; } counts[i] += 2; } } return false; } // 判断剩下的牌能否全部组成面子 function canFormMelds(counts: number[], start: number): boolean { // 找到第一张还有的牌 let i = start; while (i < 34 && counts[i] === 0) i++; if (i === 34) return true; // 全部拆完 // 尝试刻子 if (counts[i] >= 3) { counts[i] -= 3; if (canFormMelds(counts, i)) { counts[i] += 3; return true; } counts[i] += 3; } // 尝试顺子:i, i+1, i+2 必须同花色且不是字牌 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; }

这段代码有两个容易写错的地方。第一,顺子判断里i < 27排除了字牌,i % 9 <= 6保证不会跨花色(比如 8 万和 1 条不能连)。第二,递归时传start = i而不是i + 1,因为拆完一组后当前牌可能还有剩余。参数上counts是原地修改再还原,调用方传进来之前最好 clone 一份,避免污染手牌状态。

3.2 七对、碰碰胡这些特殊牌型怎么加

标准胡判定跑通后,特殊牌型就是并列判断。七对最简单:计数表里恰好有 7 个 2。

export function isSevenPairs(counts: number[]): boolean { let pairs = 0; for (let i = 0; i < 34; i++) { if (counts[i] === 2) pairs++; else if (counts[i] !== 0) return false; // 有单张或刻子就不算七对 } return pairs === 7; } export function isAllTriplets(counts: number[]): boolean { // 碰碰胡:4 个刻子 + 1 对将,不能有顺子 let pairCount = 0; for (let i = 0; i < 34; i++) { if (counts[i] === 2) pairCount++; else if (counts[i] !== 0 && counts[i] !== 3) return false; } return pairCount === 1; }

番型计算我一般做成「规则表 + 逐条匹配」的结构,每条规则是一个函数,输入是手牌、副露、胡牌方式,输出是否成立和番数。这样加地方规则时只加函数,不动主流程。达达麻将如果有多地玩法,这套结构能让你把「血战到底」「推倒胡」拆成不同规则集,而不是写一堆 if-else。

3.3 听牌提示与出牌建议

听牌提示是玩家体验的关键。做法是:对当前 13 张手牌,枚举 34 种可能的进张,逐个加进去跑canHu,能胡的就是听牌。

export function getTingTiles(counts: number[]): number[] { const result: number[] = []; for (let id = 0; id < 34; id++) { if (counts[id] >= 4) continue; // 已经 4 张,不可能再进 counts[id]++; if (canHu(counts)) result.push(id); counts[id]--; } return result; }

这个函数在每次出牌后调用一次,34 次canHu,单次判定是常数级,整体开销可以忽略。参数上注意counts[id] >= 4的跳过,否则会算出「第五张牌」这种不存在的听牌。出牌建议可以在此基础上再算「打掉某张后听牌张数」,给新手一个高亮提示,这是留存率很敏感的功能。

4. 出牌交互与状态同步:从点击牌到牌桌刷新

4.1 触摸选牌与出牌的最小闭环

Cocos Creator 里牌面通常是预制体,每张牌挂一个脚本处理点击。核心是「选中态」和「出牌态」两个状态机。

// view/TileView.ts import { _decorator, Component, Node, EventTouch, Vec3 } from 'cc'; const { ccclass, property } = _decorator; @ccclass('TileView') export class TileView extends Component { @property raiseY = 20; // 选中时抬高的像素 private selected = false; private basePos = new Vec3(); onTileClick: ((node: Node) => void) | null = null; start() { this.basePos = this.node.position.clone(); this.node.on(Node.EventType.TOUCH_END, this.onTouch, this); } private onTouch(_e: EventTouch) { this.setSelected(!this.selected); this.onTileClick?.(this.node); } setSelected(v: boolean) { this.selected = v; const p = this.basePos.clone(); if (v) p.y += this.raiseY; this.node.setPosition(p); } }

raiseY是选中时牌抬高的距离,一般 15~25 像素,太小看不出选中,太大牌会盖住上家。onTileClick回调交给牌桌控制器统一处理,TileView 本身不碰逻辑。这里有个血泪经验:TOUCH_END和TOUCH_START别混用,麻将出牌要的是「按下再抬起」的确认感,用TOUCH_START会导致误触出牌。

4.2 牌桌状态机:谁该出牌、能不能碰

牌局状态我一般用一个显式状态机表示,而不是散落的布尔变量。

状态含义允许的操作
Waiting等待发牌无
Dealing发牌中无
MyTurn轮到我出牌出牌、暗杠、补杠
WaitAction等待我响应吃、碰、杠、胡、过
OtherTurn别人回合无
Settle结算查看结果

状态切换由服务端事件驱动,客户端只做表现。比如收到draw事件进入MyTurn,收到discard事件后如果自己有可操作牌就进WaitAction,超时未响应自动「过」。参数上超时时间一般设 8~15 秒,太短玩家来不及反应,太长牌局拖沓。

4.3 操作优先级:胡 > 杠 > 碰 > 吃

多人同时能操作时,优先级必须明确,否则会出现「我碰了但别人胡了」的争议。常见做法是服务端收集所有玩家的可操作项,按优先级排序,最高优先级玩家先响应,其他人等待。

// core/ActionPriority.ts export enum ActionType { HU = 4, GANG = 3, PENG = 2, CHI = 1, PASS = 0, } export function pickHighest(actions: { type: ActionType; seat: number }[]) { return actions.sort((a, b) => { if (b.type !== a.type) return b.type - a.type; // 同类型按座位顺序,离出牌者近的优先 return a.seat - b.seat; })[0]; }

这段逻辑必须放在服务端,客户端只做展示。我见过把优先级放客户端算的项目,结果不同客户端算出的结果不一致,直接翻车。参数上seat的顺序要按实际座位方向定义清楚,别一会儿顺时针一会儿逆时针。

5. 打包 APK 与真机适配:Cocos Creator 出包的避坑清单

5.1 构建参数怎么设

Cocos Creator 打包 Android,构建面板里几个参数直接决定能不能跑起来。渲染后端选 GLES2 兼容性最好,GLES3 在部分老机型上有黑屏报告。MD5 Cache建议开启,热更新时能避免缓存问题。屏幕方向麻将一般锁横屏。包名用反域名格式,别用默认的com.cocos.demo,否则上架会被打回。

构建完成后用 Android Studio 打开build/android工程,检查minSdkVersion,麻将游戏建议不低于 21,覆盖绝大多数设备。targetSdkVersion跟着 Google Play 要求走,国内渠道各自有要求,提前查清楚。

5.2 真机上的三个高频问题

第一个是牌面模糊。原因是设计分辨率设得太低,或者牌图没做多倍图。解决方法是设计分辨率至少 1280x720,牌面用 2 倍图,Cocos 的fitHeight/fitWidth按机型选。

第二个是点击不灵敏。麻将牌小,触摸区域要放大。可以在 TileView 上加一个透明的UITransform,尺寸比牌面大 20%,专门接触摸。

第三个是低端机掉帧。多半是牌面节点太多,每张牌一个 DrawCall。优化手段是用SpriteAtlas把牌面打成图集,同图集的节点能合批。136 张牌如果都在一个图集里,DrawCall 能从上百降到个位数。

5.3 热更新与资源版本

棋牌游戏上线后改规则、加玩法是常态,热更新能力要提前留。Cocos Creator 的 Asset Bundle 机制可以把牌面、音效、配置表拆成独立 bundle,规则配置用 JSON 放远程,改数值不用重新出包。参数上 bundle 的优先级和依赖关系要在assets里配清楚,别让两个 bundle 引用同一个资源导致重复加载。

6. 达达麻将常见问题排查:五个真实踩坑记录

现象:胡牌判定偶尔漏判,同一手牌有时能胡有时不能。原因:canHu里对counts原地修改后没完全还原,某条递归分支提前 return 时漏了还原。 解决:所有修改counts的地方用try/finally或先 clone 一份再操作,判定函数保持纯函数语义。

现象:断线重连后牌墙顺序和原来不一样。原因:洗牌用了Math.random,或者种子在重连时重新生成。 解决:种子在房间创建时生成一次,存服务端,重连时下发同一个种子,客户端用SeededRandom重建牌墙。

现象:打包 APK 后真机启动黑屏,编辑器里正常。原因:渲染后端选了 GLES3,或者某个原生插件没配好。 解决:先切回 GLES2 验证,再逐个排查插件。构建日志里native相关报错要重点看。

现象:多人同时点碰,结果两个人都碰了。原因:操作优先级判定放在了客户端,或者服务端没做互斥。 解决:所有操作请求先到服务端,服务端按优先级选一个赢家,其他人收到「操作无效」回滚 UI。

现象:牌面图集打了但 DrawCall 没降。原因:牌面节点层级里插了不同图集的装饰节点,打断了合批。 解决:把同一图集的节点放在连续层级,中间不插其他图集节点,用Sprite的atlas属性确认归属。

7. 用模拟对局验证规则引擎:一万局跑通再上桌

规则引擎写完,别急着接 UI,先在 Node 环境里跑模拟对局。我一般写一个simulate.ts,让四个「机器人」随机出牌,跑到有人胡为止,统计胡牌率、平均局时长、异常抛出次数。

// tools/simulate.ts import { SeededRandom } from '../core/SeededRandom'; import { Wall } from '../core/Wall'; import { HandTiles } from '../core/HandTiles'; import { canHu } from '../core/HuChecker'; function runOneGame(seed: number): { hu: boolean; turns: number } { const rng = new SeededRandom(seed); const wall = new Wall(rng); const hands = [new HandTiles(), new HandTiles(), new HandTiles(), new HandTiles()]; // 发牌:每人 13 张 for (let round = 0; round < 13; round++) { for (let seat = 0; seat < 4; seat++) hands[seat].add(wall.draw()); } let turns = 0; while (wall.tiles.length > 0) { turns++; const seat = turns % 4; hands[seat].add(wall.draw()); if (canHu(hands[seat].toSortedIds().reduce((acc, id) => { acc[id]++; return acc; }, new Array(34).fill(0)))) { return { hu: true, turns }; } // 简化:随机打一张 const ids = hands[seat].toSortedIds(); hands[seat].remove(ids[rng.nextInt(ids.length)]); } return { hu: false, turns }; } let huCount = 0; const N = 10000; for (let i = 0; i < N; i++) { const r = runOneGame(i); if (r.hu) huCount++; } console.log(`胡牌率: ${(huCount / N * 100).toFixed(2)}%`);

这段脚本的价值在于,它能在几秒内跑完一万局,把规则引擎里的边界问题全逼出来。参数上N设一万起步,太少统计不稳。跑通后你会对「平均几巡胡牌」「哪种牌型最容易出」有直觉,这些数据反过来能指导 AI 难度和番型平衡。

我自己的习惯是:任何规则改动,先跑模拟,再看 UI。有一次改碰碰胡判定,模拟里胡牌率从 18% 掉到 3%,一查是把顺子误判成了刻子。这种问题如果靠手点,可能一周都发现不了。希望这套从工程结构到模拟验证的路子,能帮你把达达麻将这类项目真正推到能上桌的程度。

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

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

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

立即咨询