首页 / 资讯中心 / 文章详情

用Cocos Creator开发斗地主微信小游戏Demo:从状态管理到真机适配全流程

用Cocos Creator开发斗地主微信小游戏Demo:从状态管理到真机适配全流程 ★ FEATURED ARTICLE
简介基于 Cocos Creator 开发的斗地主微信小游戏 Demo面向希望学习微信小游戏开发的 Cocos 开发者尤其适合想从零搭建完整工程、理解项目结构的入门与中级使用者。压缩包共 470 个文件包含 TS 逻辑脚本、Prefab 预制体、场景与动画资源、PNG/JPG 图片、MP3 音效及相关配置文件整体约 18.43MB目录覆盖代码、美术、音频与场景配置可直接导入 Cocos Creator 工程进行对照分析。项目实现了洗牌发牌、出牌规则、牌型判断等斗地主核心机制并处理了触摸选牌、出牌交互、与后端数据交换等流程同时针对微信小游戏平台给出资源压缩、动画优化、场景管理等适配思路并接入排行榜、邀请好友等社交能力。已有 245 人学习下载。对于想快速掌握 Cocos Creator 微信小游戏开发流程、梳理脚本组织与平台适配方法的开发者这份 Demo 是值得参考的完整样例。1. 用 Cocos Creator 开发斗地主微信小游戏 Demo先想清楚这四件事再动手做斗地主微信小游戏 Demo看起来是“发牌、出牌、比大小”三件事真正动手才发现它把 Cocos Creator 的 UI 系统、事件机制、状态管理、资源加载和微信小游戏适配全串在了一起。很多新手卡住不是卡在牌型算法——那个网上有大把现成代码——而是卡在“牌桌状态怎么管理”“网络同步什么时候介入”“微信小游戏的内存和分包限制怎么绕”。这篇文章就以一个可运行的 Demo 为线索讲清楚从建项目到真机运行的全过程适合刚学完 Cocos Creator 基础、想用一个小而完整的项目验证自己能力的开发者也适合想评估“斗地主类小游戏开发成本”的技术负责人。先给一个反直觉的结论斗地主 Demo 的代码量一半以上花在“出牌合法性校验”和“状态流转”上真正画牌桌、搓动画的部分反而简单。如果你的目标是先跑通那就把 AI 托管、联网对战全部砍掉先做单机三人两个 AI版本这能把工作量缩小一大半。下文的所有方案都是按“本地三人桌 后续可扩展网络对战”的思路来搭的好处是 Demo 阶段不需要买服务器后续接协议层也不至于推翻重来。2. 技术选型与项目结构为什么用 Cocos Creator 3.x场景怎么划分2.1 版本选型3.8 LTS 还是 2.4.x这是个先手问题微信小游戏 Cocos Creator 的组合目前社区里存的案例大多是 2.x 时代的包括很多所谓的“斗地主源码”。但如果你现在才起步我建议直接用 3.8 LTS 版本理由有三个。第一3.x 的构建发布对微信小游戏的适配已经相当成熟原生渲染器在 iOS 低端机上的表现比 2.x 的 WebView 方案稳定得多。第二3.x 的资源管线和微信小游戏的分包机制配合更顺畅2.4 时代常见的“首包超过 4MB 就得手动拆”的问题在 3.8 里有更明确的配置入口。第三你现在学 2.x半年后还是得切过来不如一步到位。用 3.8 的代价也有最典型的就是 2.x 的老代码、老教程大量失效——尤其是cc.Node、cc.loader这种 API 全换了写法。网上能找到的斗地主思路可以参考代码基本要自己重写。如果你只求最快看到效果、不想折腾新 API那 2.4.10 也不拦你但要清楚这是“短期效率换长期维护成本”的决定。我的选择是 3.8下面的所有代码都基于 3.x 的模块化写法。2.2 场景划分把 Lobby、Table 和 Game 拆开别塞进一个场景斗地主 Demo 最容易做坏的一件事就是把大厅、牌桌、结算界面全部堆在一个场景里用node.active来回切换。Demo 阶段可能感觉还行一旦要加动画、加音效、加网络状态回调一个场景里的节点树会膨胀到没法维护。我的做法是拆成三个场景Lobby、Table、GameResult。如果后续要加网络再拆一个 RoomList 出来。场景之间用director.loadScene切换参数用全局单例传——注意不是挂在场景节点上的而是挂在persistRootNode上否则场景切换时数据就丢了。// 全局数据管理器挂到 persistent 节点上 export class GameData extends Component { private static _inst: GameData null; public static get inst(): GameData { if (!this._inst) { const node new Node(GameData); director.getScene()?.addChild(node); director.addPersistRootNode(node); this._inst node.addComponent(GameData); } return this._inst; } public roomType: number 0; // 低倍场/高倍场 public playerName: string ; public playerAvatar: number 0; }这段代码的核心在addPersistRootNode——它把节点标记为“常驻”场景切换不销毁。参数说明roomType走 Lobby 时写入进 Table 场景后读取playerName和playerAvatar是给牌桌顶部信息条用的。注意director.getScene()在场景未加载完时可能拿到 null所以这个管理器最好由 Lobby 场景里的某个节点在onLoad时调用一次并addPersistRootNode而不是等 Table 场景再去创建。2.3 目录组织资源按“功能模块”放别按“资源类型”放一个常见的目录组织方式是assets/textures、assets/audio、assets/scripts这样按类型分。斗地主这种项目里牌桌相关资源至少有几十张牌面、十几种按钮、弹窗、动画序列帧全混在一个 textures 目录里后期找图能把人找疯掉。我推荐的按功能模块组织assets/modules/lobby、assets/modules/table、assets/modules/common每个模块内部再分 prefab、texture、audio、script。这样做最直接的好处是——后续做微信小游戏分包的时候可以直接把assets/modules/table单独打成 Table 分包Lobby 作为主包首包体积立刻降下来。如果你一开始就按类型分到分包的阶段就得重新整理资源依赖那才是真正的“返工式”工作。3. 核心玩法落地发牌、叫地主、出牌校验的完整实现3.1 牌的数据结构用一维数组表示 54 张牌别用字符串斗地主的牌数据最稳的结构是用数字 0-53 表示 54 张牌而不是用字符串比如3D表示方块3或者两个字段花色点数。原因很简单排序、比较大小、判断牌型时数字的大小直接对应牌面大小和花色优先级一个sort()就完事。具体编码规则0-3对应方块3、梅花3、红桃3、黑桃34-7对应方块4、梅花4、红桃4、黑桃4……以此类推。52对应小王53对应大王。这样设计的技巧是Math.floor(cardId / 4)就是牌面点数0-12对应3到2cardId % 4就是花色0 方块1 梅花2 红桃3 黑桃。大小王单独处理不参与花色计算。// 洗牌生成54张牌并打乱 function createDeck(): number[] { const deck []; for (let i 0; i 54; i) deck.push(i); // Fisher-Yates 洗牌保证均匀 for (let i deck.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [deck[i], deck[j]] [deck[j], deck[i]]; } return deck; }为什么不用deck.sort(() Math.random() - 0.5)因为 V8 引擎的sort不保证对“不稳定比较器”的随机性实际洗牌结果会有明显偏差某些牌出现的概率偏高玩家玩几局就能感觉到“牌太假”。Fisher-Yates 是教科书的做法写起来也就三行这点复杂度值得付。注意这里的随机数源是Math.random()Demo 足够但如果做真钱或金币场洗牌必须由服务器生成并且在网络同步前不能下发到客户端。3.2 手牌排序从大到小排列让玩家一眼看清发牌后玩家手里的 17 张牌要按牌面从大到小排列王炸在左上角3 在右下角。这里有个细节牌面大小和编码大小不完全一致——编码里 0-3 是 34-7 是 4所以牌面越大编码越大直接降序排列就是对的。// 手牌排序降序同点数按花色排序黑桃红桃梅花方块 function sortHandCards(hand: number[]): number[] { return hand.slice().sort((a, b) { const aRank getRank(a); // 0-12 对应 3-K-A-2 const bRank getRank(b); if (aRank ! bRank) return bRank - aRank; return b % 4 - a % 4; // 花色优先级方块0 梅花1 红桃2 黑桃3 }); } // 取牌面点数3~2 映射到 0~12小王13 大王14 function getRank(cardId: number): number { if (cardId 52) return 13; if (cardId 53) return 14; return Math.floor(cardId / 4); }这里的要点是大小王单独返回 13 和 14而不是Math.floor(52/4) 13否则你还要额外判断 52/53 谁是王。排序结果直接决定 UI 层牌的摆放顺序所以这个函数是后续所有手牌展示的基础。如果你希望“相同的牌”在界面上紧凑排列而不是全展开排序后还需要按组计数——那就是后面“手牌分组”函数的活这里先不展开。3.3 出牌合法性校验最核心的算法模块先把牌型定义清楚这是整个斗地主 Demo 里最容易写乱的部分。不要一上来就写“判断数组长度是1就是单牌”而是先定一个牌型枚举然后写一个统一的解析函数把“一组手牌”解析成“牌型 关键参数”再写判定函数去检验。export enum CardType { None, Single, Pair, Three, ThreeWithOne, ThreeWithPair, Straight, PairStraight, Plane, PlaneWithOne, PlaneWithPair, FourWithTwo, FourWithFour, Bomb, Rocket } // 解析一手牌返回 { type, rank, length }type为None表示非法牌型 export function parseCards(cards: number[]): { type: CardType, rank: number, length: number } { if (!cards || cards.length 0) return { type: CardType.None, rank: 0, length: 0 }; cards cards.slice().sort((a, b) getRank(a) - getRank(b)); // 按点数分组计数 const counts: [number, number][] []; // [rank, count] for (const c of cards) { const r getRank(c); const last counts[counts.length - 1]; if (last last[0] r) last[1]; else counts.push([r, 1]); } const ranks counts.map(x x[0]); const nums counts.map(x x[1]).sort((a, b) a - b); const len cards.length; // 单牌 if (len 1) return { type: CardType.Single, rank: ranks[0], length: 1 }; // 对子 if (len 2 nums[0] 2) return { type: CardType.Pair, rank: ranks[0], length: 2 }; // 王炸 if (len 2 ranks[0] 13 ranks[1] 14) return { type: CardType.Rocket, rank: 99, length: 2 }; // 三张 / 三带一 / 三带二 ... if (len 3 nums[0] 3) return { type: CardType.Three, rank: ranks[0], length: 3 }; if (len 4 nums[0] 3 nums[1] 1) return { type: CardType.ThreeWithOne, rank: ranks[0], length: 4 }; // ... 省略顺子、连对、飞机等判断 return { type: CardType.None, rank: 0, length: 0 }; }这个解析函数的关键设计是先按点数分组再基于“有几组、每组几张”来判断牌型。这样做比“按长度 switch”健壮得多——比如长度 6 可能是顺子、连对、三带二、三对你得先分组统计才能区分。牌型判断完还要判断牌型大小同类型比rank不同类型中只有炸弹和王炸能压其他类型王炸压炸弹。这个比较逻辑一般在出牌管理器里写不要在 UI 层里耦合。3.4 出牌状态机轮转、合法回击、过牌用状态而不是散乱布尔值斗地主牌桌的核心状态机只有四个Idle等待开始、Bidding叫地主、Playing出牌中、GameOver。出牌中又分WaitPlayer、WaitAI1、WaitAI2三个子状态注意“等待谁出牌”不要用布尔值isMyTurn来维护——一旦同步延迟或者 AI 动画播放出错这个值就不可信了。export enum TableState { Idle, Bidding, Playing, GameEnd } export enum TurnPhase { None, Player, AI1, AI2 } // 出牌管理器负责轮转、验证、切换 export class TurnManager { private state: TableState TableState.Idle; private phase: TurnPhase TurnPhase.None; private lastPlay: { cards: number[], player: number, type: CardType, rank: number } | null null; private passCount 0; // 连续过牌计数谁出的牌没人管 }轮转规则中最容易写错的是“过牌后谁先出”如果上家出牌后你选择过牌那么由下家继续如果三家中两家都过牌则由刚才出牌那家重新出牌。这个逻辑用passCount计数lastPlay的持有者出牌后passCount 0每过一次 1passCount 2时说明其他两家都不要lastPlay持有者可以重新出任意合法牌型不再是“压过上家”。重新出牌时记得把lastPlay清空否则你第一手就要求“必须大于上家”直接导致死循环。4. 微信小游戏适配与构建从浏览器能跑到真机能玩4.1 构建发布Cocos Creator 3.8 的微信小游戏输出配置在 Cocos Creator 里构建微信小游戏路径是“项目 - 构建发布 - 微信小游戏平台”。你需要重点确认的参数有四个appid测试阶段可以用测试号、生成分包勾选后需要配置分包目录、初始场景必须是 Lobby、资源服务器地址Demo 阶段留空走本地包。有一个非常容易踩的坑是“首包体积超限”。微信小游戏主包限制 4MBCocos 默认会把所有资源和代码打进 br 包如果你的牌面图片一张 300KB、音效加了一堆首包瞬间超了。构建面板的“构建进度”日志里会有体积统计注意看 output 目录下的cocos-js和assets文件夹大小。首包超限的两个快速解法一是把图片压缩到PNG8 / RGB565格式二是做分包——把 Table 场景的资源独立成一个分包主包只留 Lobby 和公共资源。4.2 屏幕适配斗地主界面最怕刘海屏和长宽比差异微信小游戏是在手机上跑的手机屏幕的宽高比从 iPhone SE 的 16:9 到全面屏的 19.5:9 再到折叠屏差距很大。斗地主的牌桌是横向布局顶部是对手信息中间是出牌区底部是你的手牌和操作按钮。如果 Canvas 的Fit Width和Fit Height设置不对会出现“手牌跑出屏幕”或者“两侧大片空白”的问题。我一般用“固定设计分辨率 安全区适配”的方案。设计分辨率设成1280 x 720横向游戏Canvas 的适配策略选择Fit HeightSHOW_ALL这样在不同宽高比下场景都会完整显示但两侧会出现黑边。黑边的处理方式是在 Lobby 的背景图上留出足够的可裁剪区域而不是拉伸背景图。在代码层面UI 节点的定位不要全部依赖Widget对齐底部的手牌区域要动态算安全区。// 微信小游戏安全区适配 import { view, screen, Canvas } from cc; // 获取安全区单位设计分辨率下的坐标 const safeArea screen.safeArea; const visibleSize view.getVisibleSize(); const designSize view.getDesignResolutionSize(); // 计算底部偏移量像素再转成 UI 坐标 const bottomOffset safeArea.y; // 拿到这个值后就可以把它应用到牌桌底部容器节点的 y 坐标上这段代码的关键变量是safeArea.y——在 iPhone X 及以后的机型上这个值不为 0表示底部有手势条区域。你把牌桌底部容器节点的y设为-visibleSize.height/2 bottomOffset手牌和按钮就永远不会被手势条挡住。注意safeArea的单位是像素在view.getVisibleSize()对应的 UI 坐标系里用的话可能需要除以view.getScaleX()做一次换算否则不同分辨率下偏移量会不对。4.3 资源加载与内存图片别在场景里直接引用用远程加载或分包微信小游戏的运行环境是浏览器内核本地包体积和内存都有严格的限制。斗地主全套 54 张牌面 背景 特效 音效如果全在主包里预加载内存很可能在低端 Android 机上崩溃。我的做法是牌面图片不直接挂在 prefab 上而是做一张“牌面图集”并在用到的时候再加载。常见的内存优化方案是“图集拆分 按需加载”牌面图集拆成cards-13到10和cards-2J到2、大小王两个文件。游戏刚开始只加载cards-1玩家手中的牌大概率是小牌为主底牌和炸弹到后面才用上大牌当出现第一张 J 及以上的牌时再异步加载cards-2。这样做的好处是进入牌桌的首屏时间变短低端机的初始内存压力也小。代价是代码里要处理一次“图片未加载完时牌的显示占位”逻辑用一个空牌盒节点兜底即可。5. 避坑斗地主 Demo 从构建到真机的 6 个常见问题避坑这一章写我在这类项目上踩过的真实问题每条都按“现象、原因、解决”的结构来方便你对照排查。坑一构建到微信小游戏后发现音效播放不了控制台报webaudio相关错误。现象是模拟器里一切正常真机没有声音。原因很直接微信小游戏的 Audio 在用户触摸屏幕之前是被禁用的而 Cocos 引擎默认在场景加载时就尝试播放背景音乐。解决方法是把背景音乐播放的调用放到玩家点击“开始游戏”按钮的回调里并且在wx.onShow事件里做一次恢复播放的处理。坑二手牌区域的牌重叠严重尤其是牌一多17张的时候。现象是手牌数量超过 10 张后半部分几乎看不出牌面。原因是 UI 布局用的固定间距没有根据手牌数量动态计算。解决方法是写一个“手牌重排”函数牌的显示宽度是固定的容器可用宽度也是固定的间距 (容器宽度 - 单牌宽度) / (牌数 - 1)牌多时间距变小牌少时间距变大保证每张牌露出约 20% 的宽度。注意重排要加过渡动画否则点一张牌后整体闪跳。坑三出牌校验在“三带一”时漏判。现象是玩家打出“三张J带一张4”服务器端校验不通过——当然 Demo 没有服务器这是 UI 提示非法出牌时发现的。原因是 parseCards 里对“三带一”的判断条件写成了nums[0] 3 nums[1] 1但nums是按数量排序的数组nums是[1, 3]时会误判。解决方法是判断分组数量而不是排序位置先确认总共只有两组然后找哪组是 3 张哪组是 1 张。类似的逻辑还影响“三带两”“四带两张单牌”的判断核心是不要用排序后数组的下标去猜牌型要考虑具体是哪一组。坑四AI 托管时表现得很“傻”不管牌多大都直接出。现象是演示 Demo 时把游戏交给 AI 托管AI 手上有王炸却出单张。原因是 AI 最简单的策略是“从最小牌开始出”但没做两个基本优化有炸弹时要不要拆、对手出大牌时要不要“忍一手”。Demo 阶段的 AI 可以不做完整决策树但至少加一个“最大牌优先级”的规则手中牌大于当前桌上牌时选择最小可压过的牌型如果手中有炸但对手只剩一手牌可以考虑直接炸。这块不追求强但“AI 完全不过大脑”会让你的 Demo 看起来很廉价。坑五微信小游戏分包后Lobby 点击“进入牌桌”时白屏。现象是构建时勾选了分包Lobby 正常切 Table 场景时加载卡住。原因是分包里的场景脚本没有被主包引用导致代码没有被打进主包。解决方法是在game.ts或其他入口脚本里显式引用分包里所有需要用的脚本类哪怕只是import不调用或者在分包配置里把Table场景和它的脚本、资源全部归进同一个分包并确保所有改用resources.load加载的资源路径正确。这个问题排查起来比较隐蔽建议一开始就把分包配置好再往上加资源而不是后补分包。坑六真机上牌桌背景图变形拉伸。现象是模拟器正常真机变糊和变形。原因是背景图用的 JPEG 尺寸不够被强行拉伸到 1280x720 以上另外部分 Android 机的 GPU 对非2的幂尺寸的纹理处理效率低。解决方法背景图做成 2048x1024 或 2560x1440 的JPEG质量 80UI 元素用 PNG 带透明通道所有纹理导入设置里的Filter设为BilinearPremultiply Alpha设为不勾选避免黑色描边。如果你用的是大图拼小图的方式还要注意 SpriteFrame 的Trim设置否则会出现裁切偏移。6. 进阶技巧让 Demo 具备“演示价值”的两个关键——AI 手感和断线恢复AI 手感是斗地主 Demo 观感的分水岭断线恢复则是微信小游戏微信环境的刚需这两点做好项目就具备“拿出来演示”的完成度了。AI 的牌风控制在 Demo 阶段不需要上模型用“规则 随机”就能达到能玩的水平。策略分三层。第一层是“要不要出牌”如果是地主上家、且地主只剩一两手牌优先出最大牌尝试走完如果是地主下家跟随队友出牌时尽量不出大牌拦队友。第二层是“出什么牌型”从最小单牌开始试探但如果手上有对子或顺子优先选择“能走完”或“能接近走完”的牌型。第三层是“炸弹时机”对手只剩一手牌且大于你所有单牌时才炸否则留着。AI 决策函数的参数里加一个riskLevel0-100值越高 AI 越激进这个值每次开局随机化就能避免 AI 打法千篇一律。这样不会让 Demo 呈现“AI 水平太高不可信”或者“太傻没法看”的两极状态。断线恢复在 Demo 阶段要做的不是真正的重连服务器而是做本地状态恢复——玩家滑动切走微信再回来牌桌不能重置。做法是在游戏进入后台时监听wx.onHide把TableState、手牌数组、当前轮到谁、上一次出牌的牌型全部序列化到wx.setStorageSync回到前台wx.onShow时反序列化恢复。注意只保存必要数据不要把整个场景树存进去否则存储和恢复的开销都会变大。这一步的目的有两个一是让队友在演示时敢于切出去回微信消息再回来不至于重新开局二是为后续接真正的网络同步做一个本地快照的对照基准——网络重连时以服务器推送的牌面为准用本地快照做比对不一致时强制刷新。最后一个习惯问题真机调试时不要只盯着功能每改一个 UI 参数、每加一张图片都构建一次真机预览看内存占用。微信开发者工具的“性能面板”里能看到 CPU、内存、帧率三条曲线帧率掉到 40 以下基本就是有资源反复加载或节点泄漏。这个习惯帮我提前发现了很多看不到的坑希望也能帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站