简介:一套基于cocos Creator开发的完整麻将游戏源码,面向希望快速上手麻将类项目或深入理解游戏逻辑的中高级开发者,可直接用于学习、二次开发或商用参考。压缩包为rar格式,共2000个文件,约46.44MB,主要包含js逻辑脚本、json数据配置、png图片素材、prefab预制体及mp3音效,覆盖游戏运行所需的核心资源与工程目录结构。目前已有4547人学习下载,是热度较高的cocos Creator麻将实现案例。源码对麻将核心规则、碰杠胡判定、UI交互、随机洗牌摸牌、单机AI、网络通信、数据存储及性能优化均有实现。具体包括摸牌出牌流程、多种胡牌牌型判断、触摸事件与界面刷新、基于随机数的发牌策略、AI托管逻辑、网络同步方案、本地存档与异常处理;工程目录按功能拆分,模块化程度较好,便于拆解、替换和二次复用,也能帮助开发者理解cocos Creator跨平台发布与调试技巧。
1. 从麻将源码下载到能跑起来的完整框架:这份资源解决什么问题
下载解压后,“麻将源码”这个关键词往往意味着一个黑匣子:文件多、场景多、脚本乱,拖进编辑器就报错,报错指向的脚本你还没找到。某开发者当初拿到这套资源时,第一反应是换一个引擎版本,折腾两天才发现版本只是第一道坎。这里要拆的是一套基于 Cocos Creator 的完整麻将 Demo:牌桌 UI、手牌拖拽、出牌碰杠、胡牌结算、简易 AI 和本地联调信息都齐。它解决的不是“怎么画一张麻将桌”,而是“一局牌从发牌到结算,状态如何流转、数据如何组织”。适合两类人:接棋牌外包想快速起底台的开发者,以及想搞懂麻将状态机和事件驱动的新手。下面按拆包顺序,把目录、运行、改造和踩坑讲透。
2. 麻将源码的目录与数据流:先看清这三层再动手
2.1 工程目录:资源、脚本、预制体分别放哪
拿到任何一份麻将源码,我第一件事不是双击场景,而是先把 assets 下的目录结构扫一遍。麻将项目虽然玩法固定,但工程组织方式的差别很大:有的把所有行为写在一个 GameMgr 里,有的把逻辑层和 UI 层拆得很干净。这份源码按分层方式组织,重点目录见下表。
| 目录示例 | 主要存放内容 | 二次开发时怎么用 |
|---|---|---|
| assets/resources/Game | 牌桌场景、麻将预制体、纹理、音效 | 换皮、改牌桌布局主要动这里 |
| assets/scripts/logic | 洗牌、发牌、胡牌判定、AI 选牌等纯逻辑 | 改玩法规则只改这里,能独立做单测 |
| assets/scripts/ui | 手牌控件、按钮、弹窗、倒计时 | 改交互反馈、点击事件在这里 |
| assets/scripts/network | 协议封装、本地模拟器消息转发 | 要接真实服务端时替换这一层 |
| settings/ | 项目配置、构建参数、引擎版本记录 | 版本不匹配时先查这里 |
这样分层的最大好处是:验证逻辑不用开编辑器。胡牌算法、AI 打分这类纯计算函数,可以丢到 Node 里直接跑;一旦它们混在 Cocos 组件里,每次调试都要加载整个场景,效率很低。我一般会把 logic 目录单独拎出来跑单元测试,跑通了再映射回组件调用。换个说法,如果你下载的源码没有这种分层,第一件事不是改玩法,而是先把纯计算函数从组件里抽出来,否则后面每动一个规则都要在游戏场景里反复点鼠标,非常容易改崩。
2.2 一局牌的数据流:从发牌、出牌到胡牌
麻将 Demo 的骨架其实就是一局牌的状态流转。以最常见的四人玩法为例,完整流程是:进入牌桌 → 生成牌山并洗牌 → 发牌,庄家拿 14 张、闲家 13 张 → 每个玩家轮流操作(出牌、碰、杠、胡、过) → 检查听牌与胡牌 → 结算并回到准备状态。这个流程中,界面更新只是表面现象,真正需要维护的是当前阶段编号、当前操作者、各家手牌和牌山剩余张数。
大多数完整麻将源码会用一个状态机来管理这套流程,而不是在每个按钮回调里各写各的判断。状态机的核心代码如下:
// 核心牌桌状态机:把每一局牌切成四个阶段 const Phase = { Idle: 0, // 空闲,等待开始 Dealing: 1, // 发牌中 Playing: 2, // 对局中,等待玩家操作 Settling: 3 // 已有人胡牌,结算中 }; let currentPhase = Phase.Idle; const phaseChangeCallbacks = []; export function setPhase(next) { if (next === currentPhase) return; const prev = currentPhase; currentPhase = next; // 状态切换时通知所有观察者(UI、日志、网络层) for (const fn of phaseChangeCallbacks) { fn(next, prev); } } export function onPhaseChange(fn) { phaseChangeCallbacks.push(fn); }这段代码的逻辑是:所有能改变对局阶段的地方,统一走setPhase,而不是直接改全局变量。next是目标阶段值,prev是切换前的阶段值,回调函数会在每次切换时被调用。这样有两点好处:一是你可以在回调里统一刷新 UI、保存操作日志,避免漏改;二是非法状态切换可以被集中挡住,比如在Idle阶段收到出牌动作,直接忽略而不是报错。实际使用时,牌桌的每个按钮都只关心“当前是不是Playing”,而不用管之前的逻辑是怎么跑过来的。
2.3 消息驱动 vs 状态驱动:这套源码选择状态机的原因
麻将源码常见有两种驱动方式:一种是上面这种状态机加回调,另一种是消息驱动,所有玩家动作都封装成消息包发给服务端,再由服务端广播结果。本地 Demo 用状态机完全够用,因为只有一桌,逻辑集中,不需要跨进程同步。真要做网络版,必须改成消息驱动,否则两台手机上的手牌状态根本对不上。
我一般会保留状态机,把出牌、碰、杠、胡这些动作再包一层轻量事件总线,后续接网络时只需要把发往本地的emit换成发往服务端的send。事件总线代码不复杂:
// 轻量事件总线:所有牌桌交互都走它,避免组件间互相拿引用 const bus = { events: {}, on(event, handler) { (this.events[event] = this.events[event] || []).push(handler); return () => this.off(event, handler); }, off(event, handler) { this.events[event] = (this.events[event] || []).filter(h => h !== handler); }, emit(event, payload) { for (const handler of this.events[event] || []) { handler(payload); } } }; // 使用示例:某玩家出牌,UI 层监听后刷新手牌区 bus.emit('play-card', { player: 0, cardIndex: 17 });on返回一个取消监听函数,适合在组件销毁时解绑;off用来移除指定处理函数;emit的payload是消息体,这里放player和cardIndex。这套结构的好处是,UI 组件之间完全解耦,修改出牌逻辑时不需要知道界面是谁在监听。你要从单桌 Demo 演进到多桌时,事件总线基本不用动,只要把同步对象从本地变成服务端就行。
3. 把源码跑起来:版本匹配、导入步骤与本地联调
3.1 版本匹配:为什么这是第一个坑
我拆过不少别人下载的棋牌源码,翻车最集中的地方不是代码,而是引擎版本。Cocos Creator 从 2.x 到 3.x 的 API 差异非常大,哪怕都是 3.x,小版本之间也会改一些渲染和资源接口。麻将源码是完整工程,里面大量引用编辑器生成的坐标、预制体和脚本绑定,版本不对,编辑器会弹出一堆“脚本丢失”。
拿到源码后第一件事不是点运行,而是查它当时是用哪个版本创建的。工程根目录的project.json一般会记录引擎版本,用一条命令就能看到:
# 在工程根目录执行,输出里找 creator、version 字段 grep -E '"creator"|"version"' project.json如果project.json里没有明确标注,就打开settings/目录看里面的项目配置文本。要注意,编辑器提示“是否用当前版本打开并升级”时,不要盲目点确认,因为升级过程会重写场景文件,一旦写入失败,整个预制体可能打不开。我一般会先复制一份原始工程,再拿副本去升级,这样至少保留后悔药。
3.2 从导入到首屏:完整的操作路径
确认版本能对上之后,导入流程并不复杂,但步骤顺序有讲究。先用对应版本的编辑器打开 Dashboard,选择“导入”指向源码根目录;然后等资源导入完成,确认资源管理器里所有预制体都有缩略图,再打开主场景。主场景在复杂项目里往往叫main.scene或game.scene,如果你面对的是一个多目录工程,可以用一段小脚本快速定位:
// find-scene.js 在工程根目录运行,列出所有场景文件 const fs = require('fs'); const path = require('path'); const scenes = []; function walk(dir) { for (const f of fs.readdirSync(dir)) { if (f.startsWith('.') || f === 'node_modules') continue; const p = path.join(dir, f); const stat = fs.statSync(p); if (stat.isDirectory()) walk(p); else if (f.endsWith('.scene')) scenes.push(p); } } walk('assets'); console.log(scenes.join('\n'));这个脚本的逻辑很简单:从assets递归遍历所有子目录,把.scene后缀的文件路径全部打印出来。参数只有一个,就是根目录路径,如果你把脚本放在工程外面,就改成walk(process.argv[2]),运行时传绝对路径。找到主场景后,双击打开,再点编辑器上方的预览按钮,浏览器会自动启动。首屏出现牌桌画面后,先确认左下角 Console 没有红色报错,再点“开始”进一局本地模拟。
3.3 本地联调:用 Mock 服务端模拟四家动作
很多麻将源码会自带一个本地模拟器,负责生成随机牌和推进对局;如果解压后没找到,我会自己补一个最小可跑的 Mock 服务端。它不需要实现完整逻辑,只要让客户端连得上、能收到发牌数据,UI 就能跑起来看效果。下面是一个简化版本:
// mock-server.js,用 node mock-server.js 启动 // 只实现了发牌和动作确认两个接口,不做完整牌局逻辑 const http = require('http'); function handBuilder(count) { // 生成 count 张牌,牌号范围 0-33,对应万/条/饼/字牌 const hand = []; for (let i = 0; i < count; i++) { hand.push(Math.floor(Math.random() * 34)); } return hand; } const server = http.createServer((req, res) => { res.setHeader('Content-Type', 'application/json'); if (req.url === '/deal') { // 发牌接口:庄家手牌 14 张 res.end(JSON.stringify({ hand: handBuilder(14) })); return; } if (req.url === '/action') { // 动作接口:出牌、碰、杠、胡都走这里 res.end('{"ok":true}'); return; } res.end('{"ok":false}'); }); server.listen(9527, () => console.log('mock server run at 9527'));这里的handBuilder只是模拟了随机手牌,没有按真实麻将 136 张牌来洗牌,实际源码里会做完整牌山。之所以先给这段代码,是因为它能帮你验证网络模块的连通性:只要客户端能通过http://localhost:9527/deal拿到 JSON 数据,就说明协议解析和 UI 绑定没问题,后续把 Mock 换成真实服务端时,客户端代码不用大改。端口9527可以改成你本地不冲突的端口,真机调试时再用电脑的局域网 IP 替换。
4. 改造出自己的玩法:胡牌判定、听牌提示与 AI 出牌
4.1 胡牌判定的数学模型:回溯而不是穷举
单机麻将源码里最重要的纯逻辑,就是胡牌判定。常见做法是把牌映射成 0-33 的数字:0-8 是万,9-17 是条,18-26 是饼,27-33 是字牌。然后用一个长度 34 的数组记录每张牌的张数,比如[3, 1, ...]表示一万有三张、二万有一张。胡牌条件在普通牌型下是“一对将牌加四组面子”,面子包括顺子和刻子。这个判断用回溯法最直接,代码也容易维护:
// 普通胡牌判定:tiles 是长度 34 的计数数组,值代表某张牌的张数 export function canWin(tiles) { const total = tiles.reduce((a, b) => a + b, 0); // 手牌数量必须是 3n+2,14、11、8、5 都合法 if (total % 3 !== 2) return false; const t = tiles.slice(); const tryWin = (usedJiang) => { // 找到第一张大于 0 的牌 const i = t.findIndex(c => c > 0); if (i === -1) return usedJiang; // 牌用完了,必须已经有将 // 尝试拿两张当将牌 if (!usedJiang && t[i] >= 2) { t[i] -= 2; if (tryWin(true)) return true; t[i] += 2; // 回溯还原 } // 尝试三张当刻子 if (t[i] >= 3) { t[i] -= 3; if (tryWin(usedJiang)) return true; t[i] += 3; } // 尝试顺子:只有万/条/饼能组顺子,字牌不能 if (i < 27 && i % 9 <= 6 && t[i + 1] > 0 && t[i + 2] > 0) { t[i] -= 1; t[i + 1] -= 1; t[i + 2] -= 1; if (tryWin(usedJiang)) return true; t[i] += 1; t[i + 1] += 1; t[i + 2] += 1; } return false; }; return tryWin(false); }这段代码核心是tryWin递归:每次都从牌数最小的那张牌开始处理,避免随机选牌漏掉分支。usedJiang表示是否已经使用过将牌;t.slice()是为了不修改调用方的手牌对象。参数tiles必须保证长度是 34,且数组元素都大于等于 0。递归层数最深不超过手牌张数,所以性能不需要担心,一局内调用几千次也流畅。
但要注意,这个函数只处理普通牌型。七对和十三幺不是“将牌加面子”结构,需要在进入回溯前单独判断。七对的写法很简洁,就是检查每个计数是不是 0 或 2:
// 七对判断:14 张牌正好组成 7 个对子 export function isSevenPairs(tiles) { const total = tiles.reduce((a, b) => a + b, 0); if (total !== 14) return false; for (let i = 0; i < 34; i++) { if (tiles[i] % 2 !== 0) return false; } return true; }在canWin入口处加一句:如果isSevenPairs(tiles)直接返回 true,就能支持七对。很多便宜的麻将源码在“七对”上翻车,就是因为只写了普通型回溯,没有单独分支。你拿到源码后,先跑一下七对用例,能省很多排查时间。
4.2 听牌提示:遍历 34 张牌,交给胡牌判定
听牌提示是麻将源码最容易让用户感知“智能”的地方。实现思路很简单:遍历 34 张牌,每次往手牌里加一张,然后调用胡牌判定,能胡就说明这张是听牌。代码如下:
// 计算听牌:hand 是手牌计数数组,有效长度 13 或 16 等 3n+1 的情况 export function getTingTiles(hand) { const results = []; const total = hand.reduce((a, b) => a + b, 0); // 听牌时手牌数必须是 3n+1,13、10、7 都合法 if (total % 3 !== 1) return results; for (let i = 0; i < 34; i++) { const next = hand.slice(); next[i]++; // 假设摸到第 i 张牌 if (canWin(next)) results.push(i); } return results; }hand.slice()保证每轮尝试不影响原始手牌;results返回的是牌索引,UI 层再把它映射成语牌或贴图。这个函数不需要额外参数,只要保证传入的hand是计数数组。实际工程中,我会把它包一层 debounce,因为玩家每打一张牌 UI 都会重算一次,连续操作时容易浪费计算量。
4.3 AI 出牌策略:从孤张评估到进阶打分
完整麻将源码里的 AI 一般不会太复杂,但至少不能乱打。初学者最容易写出的 AI 是“随机出牌”,那会让单机体验很差。我通常先实现一个基于孤张评估的贪心版本:每张牌的价值由它和左右相邻牌的关系决定,越孤立的牌越先打。代码很短,容易改:
// 简易 AI:找到手牌中孤张程度最高的牌打出 export function findDiscard(hand) { let best = -1; let bestScore = Infinity; for (let i = 0; i < 34; i++) { if (hand[i] === 0) continue; const score = calcLonelyScore(hand, i); if (score < bestScore) { bestScore = score; best = i; } } return best; } function calcLonelyScore(hand, idx) { let s = 0; // 往左右各看两张,同花色相邻牌越多,这张牌越有价值 for (let offset = -2; offset <= 2; offset++) { const nIdx = idx + offset; if (nIdx < 0 || nIdx >= 34) continue; // 万/条/饼只在同花色内算价值;字牌只算自身数量 const sameSuit = Math.floor(nIdx / 9) === Math.floor(idx / 9); if (idx >= 27) { if (nIdx === idx) s += hand[nIdx]; } else if (sameSuit) { s += hand[nIdx]; } } return s; }findDiscard返回要打出的牌索引;分数越低越孤立。这个评分没有考虑进张数和听牌点数,属于可用但不算聪明的级别。想增强,最常见做法是加一个“进张数”维度:模拟打出这张牌后,把剩余牌组成听牌集合,统计听牌数量,听牌越多越不该打。这套源码的 AI 层是独立模块,你替换findDiscard的返回值就能改变整局 AI 风格,不用动 UI。
5. 避坑:麻将源码二次开发的高频问题与排查路径
5.1 场景全黑,控制台报 Failed to load scene
现象:把源码导入编辑器后,主场景打开是黑的,Console 报Failed to load scene,资源管理器里一堆预制体没有缩略图。
原因:工程根目录残留了其他机器生成的library和temp缓存目录,这些目录记录了本机绝对路径、uuid 和资源哈希。换机器后,缓存里的路径对不上,编辑器加载场景时找不到对应资源。
解决:先关闭编辑器,删除工程根目录下的library、temp、build三个目录,然后重新打开工程,让编辑器重新导入资源。删除前确认这三类目录只是缓存,不是你的源码。很多刚下载的源码打不开,都是这个原因。
5.2 点击手牌没反应,鼠标事件被桌面组件吃掉
现象:运行后牌桌显示正常,但点击手牌不出选牌高亮,或者点一张牌会触发桌面背景的交互。
原因:手牌节点的触摸区域设置不对,或者点击事件冒泡到了上层节点。Cocos Creator 中,Button 和监听器都会参与触摸事件派发,如果手牌节点没有正确的 UITransform 尺寸,点击判定会落到父节点上。
解决:给每张手牌预制体的根节点添加 UITransform,并把 Content Size 调成和美术资源一致的尺寸。在事件回调里主动停止冒泡:
// 在手牌点击回调里阻止事件继续向上传递 this.node.on(Node.EventType.TOUCH_END, (event) => { event.propagationStopped = true; // 停止向父节点派发 this.selectCard(); }, this);这段代码主要是提醒:事件回调的逻辑必须先检查propagationStopped的时机。如果父节点还有别的监听,必须优先处理子节点事件。
5.3 七对不算胡,胡牌判定永远返回 false
现象:单机测试时,手里凑齐七个对子,点“胡”按钮没有反应,控制台也没报错。
原因:胡牌判定只实现了普通牌型,也就是“一对将牌加四组面子”。七对是“七个对子”结构,不走回溯逻辑,所以普通判定直接返回 false。
解决:在canWin入口处加七对判断。前面 4.1 节拿到的isSevenPairs可以直接用:if (isSevenPairs(tiles)) return true;。改完再补一组测试用例:七个对子、普通顺子胡、刻子胡、非胡牌型各跑一遍,确保没改坏原有逻辑。
5.4 升级引擎后组件全部失踪,脚本显示 Missing Script
现象:用新版引擎打开旧版项目,预制体上的脚本状态变成 Missing Script,运行时大量功能失效。
原因:.meta文件里记录的 uuid 和脚本类名不匹配,或者资源导入时没有把脚本绑定关系一起迁移。升级过程中如果对场景做了自动保存,丢失的绑定可能无法还原。
解决:先不要保存场景。关掉编辑器,把工程恢复到升级前的备份,确认脚本类的@ccclass名称没有改动,再重新升级。如果已经保存了,只能手动把 Missing Script 组件删除,重新挂载脚本并刷一遍属性。从那以后我升级前都强制复制一份工程,不再裸奔升级。
5.5 手机连不上本地服务器,WebSocket 地址写死
现象:电脑上预览正常,但手机打开构建包后,一直显示“连接服务器失败”,电脑端却能正常对局。
原因:源码网络模块把服务器地址写死成了localhost或127.0.0.1。手机访问的是自己本机的 localhost,根本不是电脑的地址。
解决:把地址抽到配置表。常见方案是在settings/下加一个server.json,启动时读取:
{ "wsUrl": "192.168.1.100:9527" }客户端启动时读取wsUrl并建立连接。注意手机和电脑必须在同一局域网,且电脑防火墙要放行对应端口。很多下载源码出现“局域网连不上”,问题不在协议,而在地址写死和防火墙。
6. 进阶技巧:用配置表驱动玩法,让一份源码长出多种麻将
6.1 把规则抽成 JSON 配置文件
这套源码默认玩法是“带碰杠、带七对、普通胡”。如果每次改玩法都要进脚本改判断条件,等于把规则硬编码死。我遇到做地方麻将需求时,会先把可变规则抽成一个 JSON 配置,比如是否启用七对、是否启用十三幺、是否带花牌、底分多少。配置文件只描述规则,不写逻辑:
{ "deck": { "tileCount": 136, "hasFlower": false }, "rules": { "normalWin": true, "sevenPairs": true, "thirteenOrphans": false, "gangScore": 1 }, "score": { "selfDraw": 1, "dianPao": 2 } }加载配置后,用配置值控制 4.1 节里的分支:rules.sevenPairs为 true 时才调用isSevenPairs。这样同一套源码,换一个 JSON 文件就能切换玩法,不需要因为规则差异维护多份分支代码。
6.2 规则改动后先跑一致性脚本验证
改动胡牌或 AI 逻辑后,直接进游戏试玩很难覆盖边界。我一般会在logic目录下跑一个简单的验证脚本,随机生成大量手牌,检查两个不变量:每回合手牌总数不变,胡牌结果能回溯到完整的面子组合。脚本可以很粗糙,但非常能暴露问题:
// check-rules.js 验证随机手牌的胡牌判定一致性 const { canWin } = require('./logic'); const rules = require('./rules.json'); let passed = 0; for (let i = 0; i < 1000; i++) { const hand = randomHand(14); const win = canWin(hand, rules); if (win) { // 真胡牌至少能找出一组将牌 if (!hasJiang(hand)) { console.log('invalid win at', JSON.stringify(hand)); } } passed++; } console.log(`passed ${passed}/1000`);这段脚本只做冒烟验证,重点不是完整测试,而是确保逻辑层在改完配置后不会出现低级错误。参数hand是 34 位计数数组,随机生成时必须保证总数是 14。跑通后再回编辑器联调,能省不少时间。
6.3 单桌到多桌:状态实例化与房间 ID
如果把这份源码直接改成多桌匹配,需要做的最小改动是:把第 2 章里那个全局currentPhase变成“每桌一个 Room 实例”的成员变量。当前源码的 Phase 是整个进程唯一的,两个房间同时进行时,A 桌的出牌会污染 B 桌的状态。常见做法是给Room增加roomId,事件总线里所有消息都带roomId,服务端转发时按房间分发。事件总线代码不用重写,只要在emit的 payload 里多加一个字段即可。改动量不大,但这是从“本地单机 Demo”走向“可上线棋牌”的第一道分水岭。从那以后我每次拿到下载源码,都会先跑一遍一致性脚本再动规则,这个习惯帮我少踩了至少十次改坏玩法的坑,希望帮到你。
本文还有配套的精品资源,点击获取