简介:这是一份基于Cocos Creator开发的斗地主微信小游戏Demo,面向希望入门微信小游戏开发的工程师与独立开发者。项目完整覆盖了从游戏界面搭建、交互逻辑,到洗牌、发牌、牌型判断与出牌规则等核心玩法,并演示了如何结合微信小游戏API实现邀请好友、排行榜等社交功能,可作为从零构建休闲棋牌类小游戏的参照实例。压缩包共470个文件,大小18.43MB,主要包含54个TypeScript脚本、99个PNG图片、49个MP3音频、10个Prefab预制体、3个Scene场景及4个Anim动画,另附JSON配置、Plist图集、TTF字体、SQL脚本和Dockerfile等,结构上兼顾前端资源与后端部署参考。已有245人学习下载,适合具备一定Cocos Creator基础、希望快速了解微信小游戏适配优化与完整项目目录组织的开发者。
1. 斗地主微信小游戏 Demo 从哪开始:不是联机,是那副牌
很多人拿到"cocos creator 开发斗地主微信小游戏Demo"这个标题,第一反应是去接实时对战、找服务器、搞房间管理。但以我做过的 Demo 经验,真正的拦路虎是那 54 张牌本身:牌型怎么识别、出牌怎么校验、选牌手感怎么做、AI 怎么打。这些没跑通,联机接得再漂亮也是空中楼阁。这篇笔记按 Cocos Creator 3.x 的常见做法,把 UI 布局、牌型逻辑、联网选型、打包验证一条线拆开,适合刚入门微信小游戏、想用最短路径跑通一个可玩斗地主原型的开发者。Demo 定位是功能演示,不含真实货币结算,后面所有设计都按这个前提来。
2. 先把场景和 UI 立起来:斗地主桌面在 Cocos Creator 里的三层节点结构
2.1 场景节点树:把"桌面、对局信息、操作栏"分成三层再动手
斗地主的 UI 看起来简单,实际铺开来很容易乱。我一般会把场景节点拆成三层:背景层负责桌面纹理和玩家头像框,对局信息层放比分、倒计时、地主标记、上一手牌展示,操作层放手牌容器、叫地主按钮组、出牌按钮组。这样做的核心原因是:微信小游戏在窄屏上的布局是高度动态的,三层各管各的适配逻辑,后续调刘海屏安全区时不用整个场景推倒重来。
在 Cocos Creator 3.x 的层级管理器中,我通常搭成下面这棵结构:
Canvas ├── BGLayer // 背景层:桌面纹理、左右玩家区域底色 ├── InfoLayer // 对局信息层:比分、倒计时、地主标记、上家/下家手牌数 └── ActionLayer // 操作层: ├── HandContent // 手牌容器,UI 坐标系下挂脚本,控制牌的位置 ├── PassBtn // "不出"按钮 ├── PlayBtn // "出牌"按钮 └── CallBar // 叫地主/抢地主按钮组,默认隐藏这三层都用 Canvas 下的全屏节点承载,锚点居中,设计分辨率在 960x640 时先跑通,真机适配留到后面单独处理。BGLayer 不接收触摸事件,InfoLayer 用 Label 做数字和文字,ActionLayer 才是所有可交互节点的挂载点。分层的另一个好处是:微信开发者工具里调试时,可以直接把某一层隐藏,快速定位是布局问题还是交互问题,这个习惯能省掉不少排查时间。
2.2 手牌区的手感:用 layoutCards 撑开 17 张牌并响应点选
斗地主手牌最多 17 张,屏幕宽度有限,牌与牌必须重叠。常见做法是每张牌宽约 80 像素,间距 45 像素,17 张总宽约 800 像素,在 960 设计宽度下可以放下,选中的牌往上抬 20 像素。我习惯把"布局"和"选牌"分开写:布局只管计算每张牌的世界坐标,选牌用统一事件处理,这样 AI 出牌、手牌重排、托管出牌都能复用同一套布局函数。下面是一段手牌容器的核心代码,在 Cocos Creator 3.x 的 TypeScript 组件里直接用。
import { _decorator, Component, Node, Vec3, UITransform } from 'cc'; const { ccclass, property } = _decorator; @ccclass('HandContainer') export class HandContainer extends Component { @property(Node) content: Node = null!; // 手牌父节点,所有牌节点都挂在这里 private readonly CARD_SPACING = 45; // 牌与牌的横向间距(像素) private readonly CARD_RAISE_Y = 20; // 选中后牌抬升的高度(像素) /** 按当前手牌数量重新排列所有牌的位置 */ layoutCards(selected: Set<number>) { const cards = this.content.children; const n = cards.length; if (n === 0) return; // 从正中间开始向两边排,保证手牌始终居中 for (let i = 0; i < n; i++) { const x = (i - (n - 1) / 2) * this.CARD_SPACING; const y = selected.has(i) ? this.CARD_RAISE_Y : 0; cards[i].setPosition(new Vec3(x, y, 0)); } } }这段代码里有两个参数值得记住:CARD_SPACING决定牌的重叠程度,牌越宽间距越大;CARD_RAISE_Y决定选牌时的视觉反馈强度,抬太高会让牌超出点击区域,后续触摸判定会不准。setPosition用的是节点本地坐标,所以 content 节点必须放在屏幕中部偏下,并保证锚点在中心。牌节点的触摸事件不在这里处理,我的做法是每张牌挂一个Button组件,点击回调里把牌索引加入或移出selected集合,再调用layoutCards重新排位置。如果你用手势坐标自己判断,一旦 ScrollView 介入,事件会被滚动逻辑搅乱,这个坑在第 5 章单独讲。另一点要注意:手牌数量变化后必须重新计算居中起点,否则左边多一张右边少一张,玩家的眼睛立刻会察觉到不对称。
2.3 先用 Label 和纯色块跑通交互,再考虑美术资源
很多新手拿到 Demo 就急着找全套扑克素材,其实前期完全可以用Label显示数字"3、4、5...J、Q、K、A、2、小王、大王",用Sprite加纯色底片代替牌面。这样做的直接好处是:54 张牌的牌面识别、排序、选牌、出牌校验都能先跑起来,你验证的是逻辑和交互,不是美术。微信小游戏对首包大小有硬限制,一套高清扑克图集动辄几百 KB 到 1MB,早点接入图集管理,比最后快要上线时才压缩要省事得多。
等核心交互通了再替换美术,只要保持牌节点尺寸不变,替换 SpriteFrame 就能无缝升级。这一步在 Cocos Creator 里就是给每个牌节点动态设置spriteFrame,或用预制体替换内部子节点,不需要改任何布局代码。开发时我把扑克牌的枚举和SpriteFrame的映射写在一个静态配置表里,后续换图只改配置,不碰业务代码。除牌面外,按钮和背景也建议先用带文字的纯色节点代替,把精力集中在出牌流程和状态切换上。这套做法在团队协作时尤其管用:程序先并行,美术后补,交付节奏快很多。
3. 牌型识别是 Demo 的心脏:在 TypeScript 里写出可测试的发牌、排序与出牌校验
3.1 用 3-17 的整数表示牌面,别为每张牌建对象
斗地主牌型判断的核心难点是"如何去重、如何比大小"。如果为每个牌面建一个对象,判断顺子、连对时要反复取属性,既繁琐又容易出错。我采用的常见做法是:用整数表示牌面值,3 到 15 分别对应斗地主中的 3 到 2,16 是小王,17 是大王。花色在牌型判断里完全用不到,只在渲染牌面时查找对应 SpriteFrame。手牌就是一个number[],排序用默认数值升序就能满足出牌展示需求。
// card-model.ts export const enum CardSuit { Hearts, Spades, Clubs, Diamonds } export class CardDef { static readonly BACK = 0; static readonly MIN = 3; // 最小牌面值 static readonly MAX = 15; // 2,斗地主中比 A 大 static readonly JOKER_SMALL = 16; static readonly JOKER_BIG = 17; } /** 生成一副牌:每种牌面 4 张,小王 1 张,大王 1 张,共 54 张 */ export function createDeck(): number[] { const deck: number[] = []; for (let v = CardDef.MIN; v <= CardDef.MAX; v++) { for (let i = 0; i < 4; i++) deck.push(v); } deck.push(CardDef.JOKER_SMALL, CardDef.JOKER_BIG); return deck; }这个模型的优点很直接:比较大小就是比较数字,去重统计就靠Map<number, number>,排序直接用Array.prototype.sort。createDeck生成 54 张牌,洗牌用 Fisher-Yates 算法打乱数组,随后发 17 张给玩家、17 张给下家、17 张给上家,留 3 张底牌。注意我这里刻意没做花色建模,是因为斗地主的牌型规则完全不关心花色,只有"同花顺"这类扑克玩法才需要花色。如果你从别的棋牌项目转过来,第一反应可能是把花色写进数据结构,这会白白增加排序和去重的复杂度。
3.2 classifySorted:一个函数识别单张、对子、顺子、炸弹等牌型
牌型识别是所有出牌逻辑的地基。我一般把输入先升序排序,然后统计每个牌面出现的次数,再根据次数数组的形态判断牌型。这里有个关键约定:传入的数组必须是升序且合法的,调用方要负责校验,识别函数只做分类。以下是一个可用的classifySorted实现,覆盖单张、对子、三带一、三带二、顺子、连对、炸弹、火箭和四带二,飞机暂时返回无效。
// card-pattern.ts export enum CardType { SINGLE = 1, PAIR, TRIO, TRIO_SINGLE, TRIO_PAIR, STRAIGHT, STRAIGHT_PAIR, BOMB, ROCKET, FOUR_TWO, INVALID } export interface Pattern { type: CardType; key: number; // 用于比较大小的主牌值 } export function classifySorted(cards: number[]): Pattern { const n = cards.length; if (n === 0) return { type: CardType.INVALID, key: 0 }; // 火箭:小王 + 大王,单独判断 if (n === 2 && cards[0] === 16 && cards[1] === 17) { return { type: CardType.ROCKET, key: 17 }; } // 按牌面统计次数 const countMap = new Map<number, number>(); for (const v of cards) { countMap.set(v, (countMap.get(v) || 0) + 1); } const values = [...countMap.keys()].sort((a, b) => a - b); const counts = [...countMap.values()].sort((a, b) => b - a); // 完全相同的牌:单张、对子、三条、炸弹 if (counts.length === 1) { const k = counts[0]; if (k === 1) return { type: CardType.SINGLE, key: values[0] }; if (k === 2) return { type: CardType.PAIR, key: values[0] }; if (k === 3) return { type: CardType.TRIO, key: values[0] }; if (k === 4) return { type: CardType.BOMB, key: values[0] }; } // 顺子:至少 5 张,每张牌只出现 1 次,牌面连续,不含 2 和王 if (n >= 5 && counts.every(c => c === 1) && values[0] >= CardDef.MIN && values[n - 1] <= CardDef.MAX && values[n - 1] - values[0] === n - 1) { return { type: CardType.STRAIGHT, key: values[n - 1] }; } // 连对:至少 3 对,每个牌面出现 2 次,牌面连续 if (n >= 6 && n % 2 === 0 && counts.every(c => c === 2) && values[0] >= CardDef.MIN && values[n / 2 - 1] <= CardDef.MAX && values[n / 2 - 1] - values[0] === n / 2 - 1) { return { type: CardType.STRAIGHT_PAIR, key: values[n / 2 - 1] }; } // 三带一 / 三带二 if (counts.length === 2) { if (counts[0] === 3 && counts[1] === 1) { const trio = values.find(v => countMap.get(v) === 3)!; return { type: CardType.TRIO_SINGLE, key: trio }; } if (counts[0] === 3 && counts[1] === 2) { const trio = values.find(v => countMap.get(v) === 3)!; return { type: CardType.TRIO_PAIR, key: trio }; } } // 四带二:1 个炸弹 + 任意两张单牌(或一对) if (counts[0] === 4 && n === 6) { const bomb = values.find(v => countMap.get(v) === 4)!; return { type: CardType.FOUR_TWO, key: bomb }; } return { type: CardType.INVALID, key: 0 }; }这段代码里最关键的是counts和values的处理。counts是按出现次数降序排列的数组,用来识别"三带一还是三带二";values是升序牌面数组,用来判断顺子、连对是否连续。key统一取主牌值,比如三带一取三条那张牌面,顺子取最大牌面。边界条件的处理都在注释里:顺子和连对不允许含 2 和王,因为斗地主规则里 2 和王不能进顺子。四带二我做了简化:4 张相同牌加任意两张,不细分是带两张单还是带一对,Demo 阶段足够用。飞机、三顺这类复杂牌型先返回INVALID,等主流程跑通再扩展。
3.3 canBeat 与出牌校验:同类型比 key,炸弹与火箭优先特判
牌型识别只是第一步,真正的业务逻辑是"谁压得住谁"。斗地主的比较规则可以浓缩成三条:火箭压一切,炸弹压非炸弹,同类型比较主牌值大小。这三条规则要在canBeat里单独实现,不能和classifySorted混在一起,因为比较逻辑依赖"上一手牌型"和"当前手牌型"两个输入。下面是出牌校验的核心代码。
// card-validator.ts export function canBeat(incoming: Pattern, last: Pattern): boolean { if (last.type === CardType.INVALID) return true; // 没人出牌,随便出 if (incoming.type === CardType.ROCKET) return true; // 火箭最大 if (last.type === CardType.ROCKET) return false; // 对方火箭压不住 if (incoming.type === CardType.BOMB && last.type !== CardType.BOMB) return true; if (incoming.type !== last.type) return false; // 类型不一致不能压 return incoming.key > last.key; }这段逻辑简洁但有三个细节容易漏。第一,类型不一致时直接返回 false,炸弹例外要放在类型判断之前,否则炸弹会先去比对类型,导致永远压不住普通牌。第二,incoming.type === CardType.BOMB && last.type !== CardType.BOMB这行要放在"同类型比 key"之前,因为炸弹和普通牌不是同类。第三,key对所有的牌型都有效,因为classifySorted已经把"要比的那张牌"选出来了。出牌完整的校验流程是:先检查选中的牌是否全在手牌里,再调用classifySorted得到 pattern,最后调用canBeat与上一手牌比较。全流程放到独立的card-validator.ts模块里,不要在 UI 回调里写,方便后面做残局测试和 AI 调用。
3.4 发牌与叫地主:Fisher-Yates 洗牌和最简单的叫地主状态
发牌相对简单,用 Fisher-Yates 洗牌打乱deck,然后顺序发牌,前 51 张每人 17 张,后 3 张是底牌。叫地主在 Demo 里可以做成固定逻辑:随机选一名玩家叫地主,或者弹出一个确认按钮由玩家选择。不考虑抢地主流程,重点先跑通"地主拿底牌、出牌顺序、一打二"的主循环。这个状态机的核心是GamePhase枚举,我通常这样定义。
export enum GamePhase { Dealing = 0, // 发牌中 Calling, // 叫地主 Playing, // 出牌阶段 Settling // 结算 }阶段切换用GameController管理,每个阶段进入和退出都打印cc.log,方便在微信开发者工具控制台里观察流程。叫地主阶段我建议做成纯逻辑并在 UI 上显示提示文字,先把交互按钮留给"出牌"和"不出",这样主循环能更快跑通。AI 的简单打法是:从手牌里挑一个最小单张出;如果上一手牌压不住,就选择不出;如果自己是地主且轮到自己出牌,先试对子、三带一,再试单张。能大致走完一局后,再回头优化叫地主策略。Demo 的目的是全链路跑通,不是把 AI 做得聪明。
4. 联机选型别急着做:单机人机、微信云开发与自建 WebSocket 的取舍
4.1 盲做联机是 Demo 最容易翻车的决定:先跑通单机人机
斗地主要好玩,本质是三家打牌,但 Demo 阶段完全可以先用"一个人机"把主流程走完。我自己写过的几个棋牌类 Demo,最大的教训是一开始就接实时对战,结果出牌流程还没稳定,又被房间同步、掉线重连这些问题拖住。单机版本至少能验证四件事:牌型识别是否正确、出牌顺序和回合制切换是否正常、选牌与 UI 交互是否跟手、一局结束后的结算流程是否完整。这四件事和联机无关,但联机版本跑不通基本都是它们出问题。
人机玩家的设计不需要复杂的策略。最简单的方式是给Player对象加一个isHuman字段,AI 轮到自己时用一个定时器延后 1 秒再出牌,再配合一个chooseSimplePlay(hand, lastPattern)函数返回要出的牌。定时器的作用是模拟人的思考时间,否则 AI 秒出会让玩家的操作节奏被打乱。这个单机版同时是联机版的"离线测试基座",第 6 章的残局测试也是在它上面跑的。如果你直接拿联机框架调试 AI,断网一次就不知道是网络问题还是逻辑问题。
4.2 三条联网路线怎么选:微信云开发、自建 WebSocket 还是第三方对战平台
单机跑通后,联机是一条绕不开的路。微信小游戏常见的三种做法分别适合不同阶段:微信云开发最快,适合 Demo 和原型验证;自建 WebSocket 可控性最强,适合团队已经有后端资源的情况;第三方对战平台提供开箱即用的房间、匹配和掉线重连,但要接受对方协议,且国内平台接入文档随版本变动大。下面是我在做选型时的对比表。
| 方案 | 适合阶段 | 工作量 | 主要限制 |
|---|---|---|---|
| 微信云开发 | 原型、Demo、小规模上线 | 低,云函数 + 云数据库即可 | 冷启动有延迟;长连接需单独处理 |
| 自建 WebSocket | 正式项目、深度定制 | 高,需要服务器、域名和证书 | 微信小游戏要求后台域名已备案且开通 HTTPS/WSS |
| 第三方对战平台 | 快速上线、不想维护后端 | 中,按文档接入 SDK 和回调 | 协议和 UI 约束受平台限制,迁移成本高 |
对 Demo 来说,我一般推荐微信云开发。理由很实际:不用自己准备服务器,云函数天然适合做房间状态存储,云数据库可以保存对局回放,而且微信开发者工具里可以直接调试,不需要额外配域名。缺点是云函数冷启动会有几百毫秒延迟,但对于一局斗地主来说完全可接受。自建 WebSocket 的优势是能实现精确到毫秒的消息推送,代价是服务器运维、证书更新、防攻击都要自己扛,这是 Demo 阶段不该背的负担。第三方对战平台虽然省事,但接入后你的逻辑要和它的房间协议深度耦合,一旦平台策略调整,改造成本很高。
4.3 出牌协议与状态机:一条 JSON 消息怎么描述一轮出牌
不管选哪条路线,一条出牌消息的格式在客户端和服务端之间要保持一致。我习惯用 JSON 消息,字段尽量短,因为微信小游戏上行消息体越小越好。下面是一条"出牌"消息的常见结构,注意seat是座位号而不是玩家 ID,客户端本地也要维护一个座位号到玩家名的映射。
{ "cmd": "play", "seat": 0, "cards": [3, 5, 5, 9], "pattern": { "type": 4, "key": 5 }, "ts": 1721012400000 }cards是实际打出的牌面值数组,pattern是classifySorted的计算结果,服务端收到后必须重新校验一次,不能只信客户端。ts是客户端时间戳,服务端用来判断这轮出牌是否超时。协议里的错误处理要提前定义好:非法牌型返回{ "error": 1001 },不是当前玩家出牌返回{ "error": 1002 }。设计协议时我会额外约定一条echo消息用于心跳和延迟测试,Demo 的心跳间隔建议 5 秒,超过 15 秒判定掉线。整体状态机只有四个阶段:等待发牌、叫地主、出牌循环、结算,任何一条消息都必须能落在某个阶段上,否则进入错误队列。
4.4 状态同步比帧同步更适合斗地主,别被"实时对战"带偏
很多做动作游戏出身的开发者,一听到"实时对战"就条件反射想用帧同步。但斗地主是典型的回合制卡牌游戏,玩家操作频率极低,一局可能持续十几分钟,帧同步的确定性优势根本用不上。状态同步的做法是:客户端只在操作时发送意图和服务端同步最新状态,比如"谁出了什么牌、还剩多少张、当前轮到几号座位"。服务端作为权威,每次状态变更后把完整或增量状态推给所有客户端。
状态同步的容错性也更好:某个客户端断线,服务端直接接管该玩家的托管,逻辑简单清晰;帧同步则要求所有客户端算力一致,任何一个客户端卡顿都会影响全局。还有一个容易忽略的点:斗地主的牌型合法性判断必须由服务端做,因为客户端可以被篡改。Demo 阶段服务端可以写简化校验,但架构上要留出这个位置。消息时序上我通常用服务端下发一个递增的seq字段,客户端发现丢包时主动请求补拉状态。这些设计在 Demo 阶段用云函数 + 云数据库都够实现,比一上来就搭 WebSocket 服务要稳得多。
5. Cocos Creator 斗地主微信小游戏 Demo 的五个高频坑:从适配到打包 apk 的排查记录
5.1 适配坑:刘海屏和全面屏手势条遮住操作按钮
现象:iPhone 上运行,底部"出牌""不出"按钮被 Home 条遮挡,点不到;安卓全面屏上也有类似情况,按钮贴底导致手势区域误触。
原因:微信小游戏运行在 WebView 中,Cocos 默认按设计分辨率适配,但安全区没有单独处理,底部操作按钮直接对齐到屏幕边缘,被系统手势区盖住。这个问题在开发工具里模拟器看不出来,真机上立刻暴露。
解决:构建后的游戏窗口里读取安全区,把操作层整体上移,或把按钮的底部边距设置成安全区高度。Cocos Creator 3.x 里用sys.getSafeAreaRect()能拿到安全区矩形,让 ActionLayer 的子节点对齐到这个矩形的底部即可。测试时除了 iPhone 刘海屏,还要专门测安卓几款手势导航机型,因为国产 ROM 的手势条高度并不统一。我的习惯是做一个SafeAreaMgr组件挂到 Canvas 上,App 启动时自动调整操作层 y 坐标,别在场景编辑器里手工挪。
5.2 事件坑:ScrollView 抢触摸,点牌不等于选牌
现象:把 17 张手牌放进 ScrollView 后,点击牌的响应时灵时不灵;手指轻微滑动就变成滚动,想选牌却选不中。
原因:ScrollView 会监听触摸移动并消费事件,点击和滑动的判定由内部逻辑处理,如果你的牌节点自己监听TOUCH_START切换选中状态,会和 ScrollView 的滚动判定冲突。斗地主手牌虽然多,但在 17 张以内根本不需要 ScrollView,用普通 Node 排开即可,这个坑完完全全是自己的选型造成的。
解决:确认 17 张牌在 960 设计宽度下能容纳后,直接去掉 ScrollView,手牌容器换成普通 Node,每张牌挂Button组件,在点击回调里切换选中状态。如果未来要做 54 张牌的"整理手牌"界面,那再单独用一个 ScrollView,同时确认把牌的点击判定改成抬起时判断位移距离。还有一个隐藏细节:Button 组件默认的Target是自身,点击时会有缩放反馈,反馈会改变节点的缩放值,后排位置要按缩放后的尺寸计算,否则选中的牌会轻微偏移。
5.3 构建坑:微信开发者工具里正常,安卓真机黑屏或纹理花屏
现象:在微信开发者工具里预览一切正常,但用 Cocos Creator 构建安卓 APK 安装到真机后,界面黑屏,或者部分纹理出现紫色、蓝色异常色块。
原因:安卓构建默认会启用纹理压缩,部分格式在个别 GPU 驱动或模拟器上不受支持;另外如果资源里有大尺寸 PNG 被压缩成 ETC2,而目标机型不支持,就会出现花屏或黑屏。
解决:检查构建发布面板里的纹理压缩设置。Demo 阶段稳妥做法是关闭纹理压缩,全部按 PNG 输出,或者只对指定资源开启压缩并保持默认格式。验证方法也很简单:构建 APK 后在两台不同芯片(高通和麒麟优先)的真机上跑一遍。微信小游戏分包同样会遇到资源格式问题,远程分包资源尽量用 WebP 或 JPEG,本地资源用 PNG,不要混合压缩到同一图集里。如果黑屏同时伴随日志报GL_INVALID_OPERATION,优先怀疑纹理格式而不是代码逻辑。
5.4 环境坑:打包成 APK 后 wx.login 直接报错
现象:用微信小游戏构建调试时,wx.login正常返回,换成安卓 APK 后控制台直接提示wx is not defined,登录流程当场崩溃。
原因:wx全局对象只在微信小游戏运行时存在,APK 是一个原生应用,运行环境里根本没有这个对象。很多初学者把登录、支付、排行榜之类的微信能力直接写在业务脚本里,没有做平台判断,打包成 APK 必然翻车。
解决:把所有微信 API 调用收敛到一个WxBridge模块,内部用sys.platform判断当前运行环境,微信小游戏走真实 API,原生平台走模拟数据或游客模式。我一般这样封装底线逻辑。
import { sys } from 'cc'; export function wxLogin(): Promise<string> { if (sys.platform === sys.Platform.WECHAT_GAME) { return new Promise((resolve, reject) => { wx.login({ success: (res: any) => resolve(res.code), fail: reject }); }); } // 原生端或浏览器端:返回固定测试 code return Promise.resolve('mock-login-code'); }这里就是把平台判断隔离在模块边界内,业务层永远只调用wxLogin(),不管底层走哪条路。另一个相关坑是微信小游戏的wxAPI 版本更新频率较高,有些接口在低版本基础库上不存在,建议在WxBridge里做能力检测,不要裸调。APK 的调试意义是验证 UI 和逻辑,不验证微信能力,所以在原生端返回 mock 数据是安全的。
5.5 体积坑:主包超过 4M 上限,微信开发者工具直接拒绝预览
现象:资源一多,构建出的微信小游戏主包超过 4MB,开发者工具预览报"主包超限",点击编译后白屏或直接失败。
原因:微信小游戏要求首包不超过 4MB,资源、代码、音频都计算在内。Cocos 默认构建会把场景和脚本打进首包,如果美术资源大量堆在主场景里,很容易超限。
解决:最常见的做法是把必用的 UI 资源做成图集,非核心资源放到子包或远程资源服务器。斗地主最重的资源是牌面图、背景图、按钮图,54 张牌面全部独立 png 会占用大量空间,合成一张图集后体积能压缩一半以上。Demo 阶段先做本地加载,后续再用assetManager.loadBundle加载子包。远程资源需要配置合法域名,如果只想本地玩,那就把牌面用代码生成纯色块,完全绕开体积问题。构建后在build目录里看每个 bundle 的体积分布,哪个 bundle 超限就优先优化哪个,这一步不能省。
6. 用残局测试锁死牌型逻辑:固定牌局验证,再上 Cocos Creator 打包 apk 真机
6.1 残局测试脚本:把牌型规则变成可断言的用例
手写 UI 点击去验证牌型很慢,而且容易漏边界。我的做法是把classifySorted和canBeat相关的用例写成一个独立脚本,放在项目的tests目录下,用 Node 直接跑。下面是一个简化版的测试入口,覆盖单张、对子、顺子、火箭、四带二和无效牌型。
// tests/card-pattern.spec.ts import { classifySorted, CardType } from '../scripts/card-pattern'; let failed = 0; function check(name: string, cards: number[], want: CardType) { const sorted = [...cards].sort((a, b) => a - b); const got = classifySorted(sorted).type; const ok = got === want; if (!ok) failed++; console.log(`${ok ? 'PASS' : 'FAIL'} ${name}: got=${got}, want=${want}`); } check('对子', [5, 5], CardType.PAIR); check('顺子', [3, 4, 5, 6, 7], CardType.STRAIGHT); check('火箭', [16, 17], CardType.ROCKET); check('四带二', [8, 8, 8, 8, 11, 12], CardType.FOUR_TWO); check('无效', [3, 5, 6, 7], CardType.INVALID); if (failed > 0) { console.error(`FAILED ${failed} cases`); process.exit(1); } console.log('ALL PASSED');测试脚本的输入数据要包含三种典型情况:合法牌型、恰好满足边界条件的牌型(比如顺子最短 5 张)、非法组合。边界条件最容易出错,所以我特意在测试里放了[3,4,5,6,7]和[3,5,6,7]两个用例,前者是标准顺子,后者差一张但形似顺子。这个脚本的本质是把规则"固定"下来,每次改逻辑后跑一遍,比肉眼测试可靠得多。process.exit(1)这行是为了 CI 场景,没配 CI 时手动跑也能一眼看到全红全绿。
6.2 真机验证清单和帧率观察
残局测试通过只代表逻辑对,不等于游戏能玩。用 Cocos Creator 打包 apk 或微信小游戏到真机后,我会按固定清单过一遍:手牌排列是否居中、选牌抬升是否跟手、按钮是否被安全区遮挡、出牌动画是否掉帧、AI 出牌后手牌刷新是否及时。帧率检查不用专业的 profiler,微信开发者工具自带性能面板,Android 真机可以用cc.director.getTotalFrames()配合setInterval做一个简易 FPS 显示器,观察 1 分钟内最低帧率。如果最低帧低于 30,优先检查场景里有没有不必要的实时阴影、大尺寸半透明图、动态合图是否生效。还有一类肉眼难发现的问题:长时间挂机后内存缓慢上涨,用window.performance.memory观察趋势,发现上涨就排查是否有定时器没有清理。
6.3 一个 Demo 的完成边界:能打满一局就是交付,别贪多
对"斗地主微信小游戏 Demo"这个标题,我认为完成标准不是上线,而是能在手机或开发者工具里完整打满一局:发牌、叫地主、出牌、不出、炸弹加分、手牌归零、结算界面出现。联机可以后置,AI 可以弱,美术可以丑,但回合流程必须闭环。我在项目里会专门留一个game-over的调试按钮,强制跳过到结算阶段,用于验证结算和重开逻辑,避免每次必须打完一局才能测下一局。断线重连、排行榜、分享回流这些功能,Demo 阶段全部不做,否则永远发不了版。
我个人的教训是:第一次做斗地主 Demo 时,我把前两周时间都花在研究 AI 出牌策略上,结果主流程还没跑通,后来痛定思痛砍掉策略,只留"最小可出牌法",一天就把闭环跑完了。从那以后我坚持"逻辑先行、测试先行、真机验证收尾"的顺序,牌型识别这类底层规则用残局用例锁死,交互相关的问题全部留到真机阶段再调。这套路径可能不够炫技,但足够稳,希望帮到你。
本文还有配套的精品资源,点击获取