☰
Cocos Creator 3.x 开发斗地主微信小游戏Demo全流程与避坑指南
2026/10/6 8:12:54 网站建设 项目流程

简介:基于 Cocos Creator 开发的斗地主微信小游戏 Demo,面向希望快速上手微信小游戏开发、或研究棋牌类玩法的初中级游戏开发者。资源包共 470 个文件,压缩包约 18.43 MB,以 TypeScript 脚本、场景、预制体、PNG 图片和 MP3 音频为主,组成一套完整工程目录;其中 ts 脚本覆盖洗牌、发牌、出牌规则、牌型判断等核心玩法,prefab 和 scene 负责界面组织,png 与 mp3 提供牌面素材和音效,json 则用于配置数据。已有 245 人学习。学习者可从该 Demo 看清一个小游戏从界面搭建到逻辑实现的全过程,包括触摸选牌、出牌动画、交互反馈、网络通信与数据存储;同时还能参考为适配微信小游戏所做的资源配置、压缩优化和场景管理策略,以及排行榜、邀请好友等社交 API 的接入方式。对想在微信生态内发布跨平台棋牌游戏的开发者而言,这是一份便于对照实践的参考工程。

1. 从零搭斗地主微信小游戏Demo:为什么选Cocos Creator这条路

很多人以为斗地主微信小游戏 Demo 就是把牌洗一洗、点一下出牌,真正用 Cocos Creator 做一遍才知道,发牌只是起点,场景搭建、触摸交互、规则校验、微信小游戏平台适配,每一环都会吃掉你预期两倍的时间。这个 Demo 的定位不是商业级产品,而是把一条完整链路跑通:从 Cocos Creator 新建工程,到发牌、叫地主、出牌判定,再到微信开发者工具里预览和真机调试。它适合两类人:想入门微信小游戏开发的前端工程师,以及正在评估 Cocos Creator 是否值得投入的小团队,用最小成本看清这条路真实的工作量和技术选型。

2. Cocos Creator 工程初始化与微信小游戏构建:跑通预览的三步配置

2.1 版本选择:2.4.x 还是 3.8.x

在 Cocos Creator 上做微信小游戏,第一个决定就是版本。我见过不少老项目至今锁在 2.4.x,因为那一代对微信小游戏的支持成熟,社区教程和第三方插件基本按 2.x 的接口写。但如果你现在新起一个 Demo,我更推荐直接上 3.8.x LTS。3.x 重构了场景、资源、代码的加载机制,构建出来的包体比 2.x 时代小一截,而且对微信开放域、分包加载、远程资源这些能力支持得更好。一个直观感受是:我在 2.4 上做手牌排序时,每次进入对局都要手动管理常驻节点和资源释放,到 3.8 交给场景自动管理后,少写了一堆胶水代码。

顺手提一句,如果你是从 Unity 微信小游戏打包那套工作流转过来的,Cocos Creator 的构建面板你会觉得眼熟,但坑的位置完全不同。Unity 里你操心的是 IL2CPP 裁剪和 WebGL 内存,而 Cocos 里主要是图集散资源、Bundle 划分和启动场景这三个点。认知要切换过来,否则容易用 Unity 的习惯操作 Cocos,最后在包体优化上绕远路。

新建项目时,目录结构值得一开始就定规矩。Cocos 默认只有 assets 下零散的 scene 和 script 文件夹,但斗地主这种 UI 密度高的项目,我一般会手动拆成四块:

# 斗地主 Demo 推荐目录结构(assets 下) assets/ ├── scenes/ # 场景文件:game.fire / loading.fire ├── scripts/ # TypeScript 脚本 │ ├── core/ # GameState.ts / CardRule.ts / CardUtils.ts │ ├── ui/ # PlayerHand.ts / GameView.ts / BidPanel.ts │ └── ai/ # AIPlayer.ts ├── prefabs/ # 预制体:Card.prefab / PlayerPanel.prefab / Toast.prefab └── res/ # 静态资源:sprites / audio / fonts

这个结构背后的逻辑很简单:scripts 里按 core、ui、ai 分,是因为斗地主的规则逻辑、界面逻辑和机器人策略本来就是三个独立模块,后面改任何一块都不会波及另外两块。prefabs 单独放是为了复用——比如 Card.prefab 在玩家手牌、出牌区、底牌区三处都会实例化,不抽成预制体,改牌面样式时就得改三处节点。实际项目里我见过不建 res 目录的,图片随便丢在 assets 根目录,最后查找资源全靠编辑器搜索框,斗地主这种几十张牌面加背景音效的小项目还好,再往上一百多个 prefab 就彻底失控了。

2.2 构建发布面板:微信小游戏平台的关键配置项

版本选好、目录建好,接下来打开菜单栏的「项目 -> 构建发布」,选择 wechatgame 平台。很多人第一次看到这个面板会懵,觉得选项太多。实际上跟微信小游戏发布强相关的,是下面这六个配置项,其余保持默认就能跑:

配置项推荐值作用说明
AppID微信小游戏 AppID在微信公众平台注册小游戏账号后获取,测试时可先用测试号
初始场景scenes/game.fire微信小游戏启动后第一个加载的场景,必须放在主包
调试模式开发阶段勾选勾选后微信开发者工具能看到更详细的报错,上线前必须关闭
MD5 缓存勾选资源文件名带哈希后缀,避免微信缓存旧资源导致更新不生效
屏幕方向landscape斗地主建议横屏,竖屏布局要重做一轮
资源服务器地址留空(本地构建)远程资源才需要填,详情见第五章域名白名单

如果你之前更熟悉 Cocos Creator 打包 APK 那条路,这里最大的区别是多了一个「微信 AppID」输入框。打包 APK 时你关心的是 Android 签名和 so 库,而微信小游戏平台根本没有原生代码,所有构建产物是一个 JS 入口加一堆资源文件,运行在微信提供的容器里。这个认知转换很重要:不要在微信小游戏项目里试图用原生插件,或者依赖本地文件读写,这些在微信容器里都是受限的。

另外还有一个很多人忽略的选项叫「主包压缩类型」。Cocos 默认把脚本和首场景相关资源打进主包,其他场景扔进内置 Bundle。斗地主 Demo 一般只有一个游戏场景,不用折腾这个选项。但如果后续加了登录场景、结算场景,就要考虑把非首场景划到子包去,这在第六章展开。

2.3 微信开发者工具联调:第一次预览的完整流程

构建面板配置完成后,点「构建」按钮,等待 Cocos 生成一个 wechatgame 目录。这个目录就是微信小游戏的产物,里面会有一个 game.js 作为入口,还有 project.config.json 描述项目配置。接下来按这个流程走:

第一步,打开微信开发者工具,选择「导入项目」,把 Cocos 生成的 wechatgame 目录直接拖进去。工具会要求填 AppID,开发阶段可以选「测试号」,或者用你自己在微信公众平台注册的小游戏 AppID。第二步,确认详情面板里的 ES6 转 ES5 和增强编译两个开关,老版本基础库需要打开,新版基础库可以关掉,保持默认即可。第三步,点击工具栏的「预览」按钮,生成二维码,用手机微信扫码,就能在真机上跑起这个 Demo 了。

这个流程第一次顺利走通,值得庆祝。因为后续你做任何资源增删、脚本调整,都需要回到 Cocos 重新构建,再回微信开发者工具看效果。两个工具来回切换是常态,建议把构建快捷键和微信开发者工具的自动编译功能都打开,省得每次手动点。

3. 斗地主核心逻辑实现:54张牌编码、洗牌与出牌判定

3.1 牌的数据结构:数字编码优于字符串数组的三个理由

新手写斗地主,最容易用字符串数组表示牌,比如['♠A', '♥K']。这在展示层很直观,但一旦涉及排序和规则比较,就麻烦了。字符串比较大小需要额外写映射表,花色和点数混在一起,洗牌和发牌的算法得先解析字符串,代码量翻倍。

我用自己的方案:给 54 张牌编一个数字 id,0 到 53。普通牌 0 到 51,花色等于 id 除以 13 取整,点数等于 id 取模 13 加 3。特殊牌单独留 52 和 53,分别对应小王、大王。这样做的三个理由很明确:

第一,排序成本低。斗地主手牌排序核心是比点数,数字直接比较大小,不需要解析字符串。第二,规则判断方便。判断是否为对子、顺子,只需要对点数分组,而点数是通过简单取模算出来的。第三,资源复用。Cocos 里每张牌对应一个节点,节点上记录 cardId 数字,渲染时根据 id 找图片资源,逻辑层和数据层完全分离。

具体编码工具函数如下:

// CardUtils.ts —— 牌的编码与基础工具 // 普通牌 id 0~51:花色 = Math.floor(id / 13),点数 = id % 13 + 3 // 点数对照:3=>3, 4=>4, ..., K=>13, A=>14, 2=>15 // 特殊牌:id 52 = 小王(16),id 53 = 大王(17) export const getCardValue = (id: number): number => { if (id === 52) return 16; // 小王 if (id === 53) return 17; // 大王 return (id % 13) + 3; }; export const getCardColor = (id: number): number => { if (id >= 52) return -1; // 王没有花色概念 return Math.floor(id / 13); }; export const createDeck = (): number[] => { const deck: number[] = []; for (let i = 0; i < 54; i++) deck.push(i); return deck; };

这里getCardValue是整个规则体系的基石。0 号牌是点数 3 的黑桃,12 号牌是点数 15 的 2,52 是 16,53 是 17。A 的值是 14,2 是 15,符合斗地主里 2 比 A 大的规则。createDeck生成一副完整牌组,后续洗牌、发牌都基于这个数组操作。

这里有一个容易踩的设计点:为什么不是直接用 1 到 54 标号?因为用 0 到 53 之后,花色和点数的计算都变成了纯取模运算,没有边界判断。比如你拿到 id 30,Math.floor(30/13)=2是梅花,30%13=4,点数 7,一句话就能同时得出花色和点数,不需要像字符串方案那样建两张映射表。

3.2 洗牌与手牌排序:Fisher-Yates 和按点数降序

洗牌算法用 Fisher-Yates,这是唯一值得写在生产代码里的洗牌方式。很多人图省事直接sort(() => Math.random() - 0.5),这套随机性差,而且在大数据量下概率分布不均匀。斗地主虽然不是赌博应用,但洗牌不均匀会影响体验,极端情况下所有小牌都发给一个玩家,对局没法打。

Fisher-Yates 的思路是从后往前遍历,每次随机挑一个没处理过的位置交换,遍历一遍就得到完全随机的排列:

export const shuffleDeck = (deck: number[]): number[] => { const arr = [...deck]; for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; };

Math.random()足够 Demo 使用。如果需要为录像回放和观战系统做确定性随机,就要改成种子随机数,把生成随机数的函数替换为带 seed 的实现,这是 Demo 阶段不需要关心的复杂度。

发牌之后,玩家手里的 17 张牌(地主 20 张)要做一次排序。斗地主的习惯是按点数从大到小排列,AB 牌型一眼看清。排序规则先比点数,点数相同比花色,黑桃优先:

export const sortHand = (hand: number[]): number[] => { const arr = [...hand]; arr.sort((a, b) => { const va = getCardValue(a); const vb = getCardValue(b); if (va !== vb) return vb - va; // 点数大的排前面 return getCardColor(a) - getCardColor(b); // 同点数按花色排 }); return arr; };

这个方法返回一个新数组,不会改动传入的 hand。这一点在 UI 交互中很重要:玩家的手牌数组是逻辑层的数据,排序只是展示层的操作,如果直接 sort 原始数组,出牌后移除牌的操作就会因为数组下标变化而出错。

3.3 出牌类型判定与大小比较:一个单文件搞定的规则模块

斗地主的出牌规则是整个项目最核心、最容易写出 bug 的部分。我在 Demo 里实现了单牌、对子、三带一、三带二、顺子、连对、四带二、炸弹、王炸这九种牌型,足够支撑一局完整的游戏体验。飞机和特殊组合不在 Demo 范围内,后续可以扩展。

判定牌型的思路是:先把传入的牌按点数分组,得到点数字典和对应的张数数组,再根据张数分布判断牌型:

// CardRule.ts —— 出牌规则判定与大小比较 import { getCardValue } from './CardUtils'; export type PlayType = 'SINGLE' | 'PAIR' | 'TRIPLE_ONE' | 'TRIPLE_TWO' | 'STRAIGHT' | 'PAIR_STRAIGHT' | 'BOMB' | 'ROCKET' | 'FOUR_TWO'; export interface PlayInfo { type: PlayType; mainValue: number; // 用于比较大小的主点数 length: number; // 顺子/连对的长度 } export const analyzeHand = (cards: number[]): PlayInfo | null => { const n = cards.length; if (n === 0) return null; const groups = new Map<number, number>(); for (const id of cards) { const v = getCardValue(id); groups.set(v, (groups.get(v) || 0) + 1); } const values = [...groups.keys()]; const sortedValues = values.sort((a, b) => a - b); const counts = [...groups.values()]; // 王炸:大小王各一张 if (n === 2 && groups.has(16) && groups.has(17)) { return { type: 'ROCKET', mainValue: 17, length: 2 }; } // 单牌 if (n === 1) { return { type: 'SINGLE', mainValue: values[0], length: 1 }; } // 对子 if (n === 2 && counts.length === 1 && counts[0] === 2) { return { type: 'PAIR', mainValue: values[0], length: 2 }; } // 三带一 if (n === 4 && counts.includes(3) && counts.includes(1)) { const triple = values.find(v => groups.get(v) === 3)!; return { type: 'TRIPLE_ONE', mainValue: triple, length: 4 }; } // 三带二 if (n === 5 && counts.includes(3) && counts.includes(2)) { const triple = values.find(v => groups.get(v) === 3)!; return { type: 'TRIPLE_TWO', mainValue: triple, length: 5 }; } // 炸弹 if (n === 4 && counts.length === 1 && counts[0] === 4) { return { type: 'BOMB', mainValue: values[0], length: 4 }; } // 四带二 if (n === 6 && counts.includes(4)) { const four = values.find(v => groups.get(v) === 4)!; return { type: 'FOUR_TWO', mainValue: four, length: 6 }; } // 顺子:至少 5 张连续牌,2 和王不能进顺子 if (n >= 5 && counts.every(c => c === 1)) { const max = sortedValues[sortedValues.length - 1]; const min = sortedValues[0]; if (max - min === n - 1 && max <= 14) { return { type: 'STRAIGHT', mainValue: max, length: n }; } } // 连对:至少 3 对连续对子,2 和王不能进连对 if (n >= 6 && n % 2 === 0 && counts.every(c => c === 2)) { const max = sortedValues[sortedValues.length - 1]; const min = sortedValues[0]; if (max - min === sortedValues.length - 1 && max <= 14) { return { type: 'PAIR_STRAIGHT', mainValue: max, length: sortedValues.length }; } } return null; };

这个函数的核心逻辑是分组后判断。比如counts.every(c => c === 1)表示所有点数都只出现一次,同时max - min === n - 1保证这些点数是连续的。max <= 14把 2 和王排除出顺子,因为 2 和王不能参与顺子,这是斗地主规则里容易忽略的一条。mainValue存的是最大牌的点数,后面比大小直接用这个字段比较。

比较大小逻辑单独封装一个函数:

export const canBeat = (current: PlayInfo, lastPlay: PlayInfo): boolean => { if (current.type === 'ROCKET') return true; if (lastPlay.type === 'ROCKET') return false; if (current.type === 'BOMB') { if (lastPlay.type === 'BOMB') return current.mainValue > lastPlay.mainValue; return true; } if (lastPlay.type === 'BOMB') return false; if (current.type !== lastPlay.type) return false; if (current.length !== lastPlay.length) return false; return current.mainValue > lastPlay.mainValue; };

炸弹能压任何非炸弹牌型,王炸能压一切牌,这一点在 Demo 的 AI 出牌和用户出牌校验里会反复用到。我把canBeat和analyzeHand放在同一个文件里,是因为它们永远是一起被调用的,分开反而要维护两个文件的 import。

4. 对局流转、AI 托管与玩家交互:Demo 完整度的最后一公里

4.1 对局状态机:叫地主与抢地主的一轮简易流程

有了出牌规则,还需要一套对局流程把发牌、叫地主、出牌、结算串起来。我的做法是一个简单的状态机,核心是四个阶段:等待、叫地主、出牌、结算。斗地主 Demo 不需要复杂的帧同步或者状态同步,本地状态机完全够用。

// GameState.ts —— 对局状态机 export enum GamePhase { Waiting = 'WAITING', // 等待准备 Bidding = 'BIDDING', // 叫地主/抢地主 Playing = 'PLAYING', // 出牌阶段 End = 'END' // 结算 } export enum BidOption { NoBid = 0, // 不叫 Bid1x = 1, // 1 倍 Bid2x = 2, // 2 倍 Bid3x = 3 // 3 倍(直接当地主) } export class GameState { phase: GamePhase = GamePhase.Waiting; playerHands: number[][] = [[], [], []]; // 0=自己, 1=下家, 2=上家 landlordId: number = -1; currentPlayer: number = 0; lastPlay: PlayInfo | null = null; passCount: number = 0; bidMultiple: number = 1; reset() { this.phase = GamePhase.Waiting; this.playerHands = [[], [], []]; this.landlordId = -1; this.currentPlayer = 0; this.lastPlay = null; this.passCount = 0; this.bidMultiple = 1; } }

叫地主流程我做了简化:三个玩家依次选择不叫或者叫 1/2/3 倍,叫 3 倍直接成为地主结束叫牌。没有实现抢地主阶段的多轮交互,因为 Demo 的核心是验证出牌规则和交互流畅度,不是还原完整规则。

passCount字段记录连续过牌的次数。当两个玩家连续过牌,第三个玩家可以自由出任意牌,不需要压过上家的牌。这个逻辑在出牌阶段频繁用到。每轮出牌前判断passCount >= 2即可自由出牌,否则必须调用canBeat校验。

4.2 AI 出牌策略:先保证不犯错,再追求打得聪明

AI 出牌是这个 Demo 里最让人纠结的部分。写复杂了,代码量翻倍;写简单了,玩家会觉得对手是傻子。我的策略是分层:第一版只做「最小能压过的牌」,保证 AI 不会乱出,不会犯错,就已经能提供可玩性了。

一个常见做法是,AI 出牌时先按点数分组,然后针对上家的牌型逐类寻找最小可压牌:

// AIPlayer.ts —— 简单 AI:找最小的能压过的牌 import { getCardValue, sortHand } from './CardUtils'; import { PlayInfo } from './CardRule'; const groupByValue = (hand: number[]): Map<number, number[]> => { const map = new Map<number, number[]>(); for (const id of hand) { const v = getCardValue(id); if (!map.has(v)) map.set(v, []); map.get(v)!.push(id); } return map; }; const findMinPass = (hand: number[], last: PlayInfo): number[] | null => { const grouped = groupByValue(hand); const vals = [...grouped.keys()].sort((a, b) => a - b); if (last.type === 'SINGLE') { // 找比上家大的最小单牌 for (const v of vals) { if (v > last.mainValue) return [grouped.get(v)![0]]; } return null; } if (last.type === 'PAIR') { // 找比上家大的最小对子 for (const v of vals) { if (v > last.mainValue && grouped.get(v)!.length >= 2) { return grouped.get(v)!.slice(0, 2); } } return null; } if (last.type === 'BOMB') { // 只能炸弹压炸弹 for (const v of vals) { if (v > last.mainValue && grouped.get(v)!.length === 4) { return grouped.get(v)!.slice(0, 4); } } return null; } // 顺子:从比上家大的最小牌开始凑长度 if (last.type === 'STRAIGHT') { for (let start = last.mainValue - last.length + 1; start <= 14 - last.length + 1; start++) { const combo: number[] = []; for (let v = start; v < start + last.length; v++) { if (!grouped.has(v)) break; combo.push(grouped.get(v)![0]); if (combo.length === last.length) return combo; } } return null; } return null; // 其他牌型暂不处理 }; export const aiTurn = (hand: number[], last: PlayInfo | null): number[] | null => { if (!last) { // 主动出牌时先出最小的单牌 const sorted = sortHand(hand); return [sorted[sorted.length - 1]]; } return findMinPass(hand, last); };

这个 AI 的策略有两个明显的弱点,正好可以作为后续迭代方向。第一,它不懂得拆牌,比如手里有对子,但上家出单牌时,它可能为了压单牌把对子拆掉。第二,它不会保留炸弹,只要炸弹能压住上家它就会出,而不是留在关键局面。Demo 阶段这两个弱点可以接受,因为玩家能赢就觉得游戏好玩,AI 太弱不是坏事。

aiTurn的入参last是上家最后一手牌的PlayInfo,如果为 null 表示 AI 是自由出牌,此时出最小的单牌。这个简化策略避免了「AI 主动出三带一却被玩家拆解」的尴尬情况。

4.3 玩家交互细节:选中高亮、提示按钮与倒计时

玩家手牌交互是 Demo 体验的门面,很多项目逻辑写得不错,但点牌没反馈、按钮没区域、倒计时不显示,整体就很廉价。交互部分的核心是点击选牌、提示按钮和倒计时三个点。

点击选牌时,需要维护一个已选中的卡片集合。点击牌节点时切换选中状态,并通过向上平移牌节点做视觉反馈:

// PlayerHand.ts —— 手牌点击选中逻辑(节选) import { Node, EventTouch, UITransform } from 'cc'; export class PlayerHand { selectedCards: Node[] = []; onCardClick(event: EventTouch, cardNode: Node): void { const index = this.selectedCards.indexOf(cardNode); if (index >= 0) { this.selectedCards.splice(index, 1); cardNode.setPosition(0, 0, 0); // 取消选中,回到原位 } else { this.selectedCards.push(cardNode); cardNode.setPosition(0, 30, 0); // 向上抬 30 像素表示选中 } } getSelectedIds(): number[] { return this.selectedCards.map(node => { const script = node.getComponent('CardView') as any; return script.cardId; }); } clearSelection(): void { for (const node of this.selectedCards) { node.setPosition(0, 0, 0); } this.selectedCards = []; } }

setPosition(0, 30, 0)的 30 像素是经验值,需要根据手牌节点的尺寸和你屏幕适配方案调整。我用的是横屏设计分辨率 1280x720,牌宽约 80 像素,上移 30 像素视觉上刚好分开。如果用的是竖屏或者更大分辨率,这个值要做微调。

提示按钮的实现,我直接复用了 AI 的aiTurn函数。玩家点提示按钮时,传入当前手牌和上家牌型,AI 返回一个可行出牌组合:

// GameView.ts —— 提示按钮逻辑 onHintClick(): void { const hintCards = aiTurn(this.handCards, this.lastPlayInfo); if (hintCards && hintCards.length > 0) { // 把提示的牌设为选中状态 this.clearSelection(); for (const id of hintCards) { const node = this.cardNodeMap.get(id)!; this.selectCard(node); } } else { // 没有能压过的牌,提示玩家点「不出」 this.toast.show('没有能压过的牌'); } }

这里复用了aiTurn而不是单独写提示逻辑,是一个很实用的技巧。提示本质就是 AI 帮你从手牌里挑一个可行解,和 AI 出牌的唯一区别是返回结果后不执行出牌,只设置选中状态。

倒计时我用了一个简单 CountDown 组件,60 秒内未操作则自动过牌。微信小游戏切后台时,cc.director的帧回调会暂停,所以倒计时不能只依赖帧累计,要结合Date.now()做时间校正:

// CountDown.ts —— 倒计时组件 import { Component, Label } from 'cc'; export class CountDown extends Component { duration: number = 60; private elapsed: number = 0; private lastTime: number = 0; private label: Label | null = null; onEnable() { this.lastTime = Date.now(); this.elapsed = 0; } update() { const now = Date.now(); this.elapsed += (now - this.lastTime); this.lastTime = now; const remain = Math.max(0, this.duration - this.elapsed / 1000); if (this.label) this.label.string = `${Math.ceil(remain)}s`; if (remain <= 0) { this.onTimeout(); } } onTimeout() { // 由 GameView 注入,默认触发过牌 } }

用Date.now()而不是累加dt,是为了避免游戏切后台再回前台时,倒计时瞬间跳完的尴尬局面。这个细节在微信小游戏上尤其重要,因为玩家切到微信聊天再切回来是高频操作。

5. 微信小游戏打包与真机调试避坑:五个高发问题排查记录

微信小游戏和浏览器环境最大的区别是运行环境封闭,很多在模拟器里正常的代码一上真机就翻车。下边这五个问题是我在斗地主 Demo 里依次踩过的,每一条按现象、原因、解决三段写。

5.1 构建后黑屏:主包入口与 startScene 配置冲突

现象:Cocos 构建成功后,用微信开发者工具打开,模拟器一片黑屏,控制台报Failed to load resource或找不到场景文件。

原因:Cocos 的「初始场景」配置指向了一个不在主包里的场景。微信小游戏启动时只能加载主包里的资源,如果初始场景被归到了某个分包里,容器加载不到入口场景,自然黑屏。这个问题在多人协作时常见:同事把游戏场景挪到子包,忘了同步修改初始场景配置。

解决:回到 Cocos 构建发布面板,确认「初始场景」指向的场景包含在主包中。如果你有加载场景和游戏场景两个场景,分别检查是否前者在主包、后者在子包。另外确认初始场景中至少有一个带 Canvas 组件的节点,没有 Canvas 的场景在微信小游戏里同样无法渲染。

5.2 音频不播放:格式、基础库与系统静音三重因素

现象:微信开发者工具模拟器里背景音乐正常,一上真机就听不到声音,或者只有 Android 没有 iOS,反之亦然。

原因:微信小游戏的 WebAudio 兼容性比浏览器差。Cocos Creator 默认会用 AudioClip 加载音频,但支持格式有限。mp3 在某些 iOS 基础库版本上解码失败,wav 则体积太大不适合加载。还有一个隐藏问题是手机系统静音开关——iOS 的静音拨片会压掉小游戏所有带声音的音频,除非代码里明确设置obeyMuteSwitch: false。

解决:把音频文件转成微信推荐的 m4a 格式,采样率 44100,码率不超过 96kbps,保证体积和解码性能平衡。加载时用wx.setInnerAudioOption({ obeyMuteSwitch: false })绕过系统静音限制。顺便建议不要同时播放超过 4 个音频实例,微信小游戏对音频通道数有限制,出牌音效和背景音乐同时响就会丢声音。

5.3 首包超过 4MB:子包配置没生效的三个自查点

现象:上传到微信公众平台时报错「主包大小超过 4MB」,点了若干优化选项仍然超限。

原因:主包超限通常不是单个资源太大,而是资源未被合理分包。微信小游戏限制主包体积,但允许通过子包加载扩展资源。如果你的代码和资源全塞在主包里,就算只有几张贴图加脚本也很容易逼近 4MB。

解决:按三个自查点依次排查——第一,确认资源是否全部打进了主包,打开构建日志看主包资源清单,检查是否有大型图片被自动并入了主包;第二,确认子包配置里是否正确填写了资源目录,Cocos 的子包配置需要手动勾选 Bundle 并设置为「远程包」或「本地包」;第三,确认代码里加载子包场景时用的是bundle.loadScene,如果仍然用director.loadScene,子包永远不会被触发加载。

5.4 远程图片真机不显示:域名白名单与 HTTPS 证书

现象:在微信开发者工具里,远程头像、背景图加载正常,扫码到真机上图片全部空白。

原因:微信小游戏规定所有网络资源请求必须在合法域名白名单内。开发者工具默认勾选了「不校验合法域名」,所以模拟器请求能过;真机上每次请求都会校验域名是否在微信公众平台配置的白名单中,不在就拦截。另一个坑是域名必须是 HTTPS,且证书链完整,自签证书会被微信直接拒绝。

解决:到微信公众平台小游戏后台,在「开发管理 -> 服务器域名」里配置 downloadFile 合法域名。开发阶段可以用「不校验合法域名」开关应急,但上线前必须配好。如果你没有自己的服务器,直接把远程图片打包进本地资源是最省事的选择,斗地主这类 Demo 远程资源一般只有头像和公告图,本地化完全可以接受。

5.5 真机触摸坐标偏移:适配方案与安全区域的矛盾

现象:模拟器里点牌出牌正常,真机上点某张牌却选中旁边的一张,或者点击区域整体向下偏移。

原因:Cocos 的设计分辨率适配策略和小游戏安全区没有对齐。Cocos 默认的SHOW_ALL适配会让整个 UI 等比缩放,但微信小游戏在刘海屏上会有不规则安全区,容易出现适配中心和屏幕中心偏差。如果代码里用了全局触摸坐标来绑定点击节点,而没有做节点坐标转换,就会产生偏移。

解决:所有牌节点的触摸回调中,用event.getUILocation()配合uiTransform.convertToNodeSpaceAR()做坐标转换,不要直接用event.getLocation()。同时把适配策略改为FIXED_WIDTH或FIXED_HEIGHT,让 UI 整体边缘在刘海屏上可控。这里有个玄学经验:模拟器正常、真机偏移的问题,八成是适配策略选了SHOW_ALL,改成FIXED_WIDTH后立竿见影。

6. Demo 跑通后的下一步:分包、骨架屏和联机方案选型

Demo 跑通只是第一步,离一个能在微信里传播的版本还有三件事要做。

第一件是主包瘦身。斗地主 Demo 的主要资源是牌面图片和背景音效,把这些资源全部放进去,主包很容易突破 4MB。常见做法是把游戏场景和音效资源放进子包,主包只保留加载场景和启动脚本:

// 加载子包中的游戏场景 import { resources, director } from 'cc'; resources.loadBundle('game', (err: Error | null, bundle: Bundle) => { if (err) { console.error('子包加载失败', err); return; } bundle.loadScene('GameScene', (err: Error | null, scene: Scene) => { if (!err) director.runScene(scene); }); });

第二件是加载体验。微信小游戏冷启动会有一段时间的白屏或黑屏,不做任何提示会给玩家「游戏坏了」的错觉。我建议在主包放一个 pure 的加载场景,背景色和 Logo 都是代码绘制的简单 Sprite,不依赖任何远程资源,保证 1 秒内渲染出来。这也是验证首包是否有冗余资源的好手段。

第三件是联机对战选型。如果准备接入真人对战,不必自己搭服务器。微信云开发的实时数据推送和云函数支持房间同步,适合 2~8 人的小规模游戏,斗地主三个人的房间信息量不大,云开发足够。如果需要更复杂的房间匹配和断线重连,再考虑自建 WebSocket 服务。但 Demo 阶段单机 AI 就够用了,联机方案可以放到验证玩法后再投入。

我做 Demo 的习惯是不追求一步到位,但每个模块都给后续升级留好接口。牌型判定可以扩展飞机,AI 可以换成评估函数,状态机可以接入网络消息。方向确认了,再把重复的工作量一次性补齐。希望这些经验和踩过的坑能帮你把这个 Demo 做扎实。

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

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

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

立即咨询