尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Cocos Creator 21点游戏开发实战:从零到发布微信小游戏

Cocos Creator 21点游戏开发实战:从零到发布微信小游戏 1. 项目概述与价值最近在翻看Cocos Creator的官方示例时又看到了那个经典的21点游戏Demo。这个项目由腾讯游戏和触控科技联合开发一直被很多新手开发者当作入门Cocos Creator的“第一课”。但说实话很多朋友照着Demo跑一遍感觉会了一关上教程自己从头做又不知道从哪下手。网上的教程要么太浅只讲按钮怎么点要么太散不成体系。我花了点时间把这个21点游戏从头到尾拆解、重构了一遍并且把过程中所有关键的实现逻辑、容易踩的坑以及如何把它优化成一个真正能发布的小游戏比如微信小游戏的完整流程都整理了出来。这个教程最大的特点就是“可复现”你不需要先去理解复杂的游戏框架跟着步骤走就能得到一个功能完整、代码清晰、甚至包体经过优化的21点游戏。对于想通过一个完整项目快速上手Cocos Creator并了解小游戏开发全流程的朋友这个教程会非常对胃口。2. 核心玩法与项目架构设计2.1 21点游戏规则与状态机设计21点Blackjack的核心规则并不复杂玩家和庄家电脑比点数谁更接近21点且不超过21点谁赢超过21点则“爆掉”直接输。但要把这个规则用代码清晰地表达出来就需要一个严谨的状态机。游戏的核心状态可以抽象为以下几个下注Betting游戏开始玩家放置筹码。发牌Dealing给玩家和庄家各发两张牌庄家一张明牌一张暗牌。玩家回合Player Turn玩家可以持续选择“要牌Hit”或“停牌Stand”。庄家回合Dealer Turn玩家停牌后庄家按固定规则通常点数小于17必须拿牌亮出暗牌并要牌。结算Settlement比较双方点数根据规则21点、爆牌、平局等判定输赢更新筹码。结束Game Over玩家筹码为0时游戏结束。在代码里我不会用一个巨大的switch-case或者一堆if-else来管理状态而是采用一个更清晰的管理类。我会创建一个GameManager脚本里面定义一个GameState的枚举然后用一个私有变量_currentState来记录当前状态。任何试图改变游戏状态的操作比如点击“要牌”按钮都必须通过GameManager提供的公共方法如changeStateTo来执行并且在这个方法里我会严格定义状态转换的合法性。例如不能从“下注”状态直接跳到“结算”状态。实操心得状态机是游戏逻辑的骨架。在动手写任何具体功能比如发牌动画之前先把状态图画出来明确每个状态能切换到哪些状态以及切换的条件是什么。这会让你后续的编码思路异常清晰减少Bug。2.2 Cocos Creator项目结构规划一个清晰的项目结构是高效开发和后期维护的基石。我不会把所有脚本和资源都扔在assets根目录下。参考官方Demo并结合最佳实践我的项目结构会是这样assets/ ├── scripts/ # 所有游戏逻辑脚本 │ ├── manager/ # 管理器脚本GameManager, AudioManager, UIManager │ ├── component/ # 挂载在节点上的组件Card, Chip, ButtonControl │ └── utils/ # 工具类DeckHelper, Calculator ├── resources/ # 动态加载的资源 │ ├── textures/ # 卡牌、UI图片等 │ ├── prefabs/ # 预制体可复用的节点如单张卡牌、一个筹码 │ └── audio/ # 音效和背景音乐 ├── scenes/ # 游戏场景 │ ├── Menu.fire # 菜单场景 │ └── Table.fire # 主游戏场景 └── internal/ # Cocos Creator内置资源通常不动为什么这么规划scripts按功能分层manager处理全局状态和协调component是具体的游戏对象行为utils提供纯函数工具。这符合“单一职责原则”修改一个功能时影响范围最小。resources集中管理所有需要代码动态加载cc.resources.load的图片、预制体、声音都放在这里。注意Cocos Creator构建时只有resources目录下的资源会被单独打包可供运行时加载。场景分离把菜单和游戏主界面分成两个场景逻辑更清晰也便于做场景切换和资源分包加载这点后面优化部分会详细讲。3. 核心模块实现详解3.1 卡牌系统的创建与数据管理卡牌是21点的核心。我们需要用代码表示一副标准的52张扑克牌不含大小王。首先定义一个卡牌数据类CardData它不继承cc.Component只是一个纯数据对象// scripts/utils/CardData.js cc.Class({ // 不需要extends cc.Component properties: { suit: cc.String, // 花色: Spades, Hearts, Clubs, Diamonds rank: cc.String, // 点数: A, 2, 3, ... , 10, J, Q, K value: cc.Integer, // 在21点中的实际点数值 }, });这里的关键是value的计算。在21点中A可以算作1点或11点通常按最有利的方式计算J、Q、K都算10点。我们在初始化一副牌时就需要为每张牌赋予正确的value。A先按11点算后续在计算手牌总点数时再做灵活处理。接着创建卡牌管理器DeckManager// scripts/manager/DeckManager.js cc.Class({ extends: cc.Component, properties: { cardPrefab: cc.Prefab, // 卡牌的预制体 }, onLoad() { this._deck []; // 当前牌堆 this.initDeck(); this.shuffle(); }, // 初始化一副52张的牌 initDeck() { const suits [Spades, Hearts, Clubs, Diamonds]; const ranks [A, 2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K]; this._deck []; for (let suit of suits) { for (let rank of ranks) { let cardData new CardData(); cardData.suit suit; cardData.rank rank; // 赋值逻辑 if (rank A) { cardData.value 11; // 初始按11算 } else if ([J, Q, K].includes(rank)) { cardData.value 10; } else { cardValue.value parseInt(rank); } this._deck.push(cardData); } } }, // 洗牌算法Fisher-Yates shuffle shuffle() { for (let i this._deck.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [this._deck[i], this._deck[j]] [this._deck[j], this._deck[i]]; } }, // 发一张牌 drawCard() { if (this._deck.length 0) { this.initDeck(); this.shuffle(); cc.warn(牌已发完重新洗牌); } return this._deck.pop(); }, });注意事项洗牌算法务必使用正确的随机算法。简单的this._deck.sort(() Math.random() - 0.5)在统计学上并不是均匀随机可能会让某些牌序出现的概率偏高。Fisher-Yates算法是标准选择。3.2 游戏逻辑与点数计算有了牌接下来就是实现游戏最核心的“要牌”、“停牌”和点数计算逻辑。这部分逻辑我放在GameManager中。首先我们需要一个方法计算一手牌的总点数并智能处理A1或11// scripts/manager/GameManager.js 中的方法 calculateHandValue(cards) { let total 0; let aceCount 0; // 第一轮遍历先把A当作11点计算并记录A的数量 for (let card of cards) { if (card.rank A) { aceCount; total 11; } else { total card.value; // 这里card.value已经是2-10或10 } } // 如果总点数超过21且手上有A则尝试将A从11点降为1点 while (total 21 aceCount 0) { total - 10; // 11变1相当于减10 aceCount--; } return total; }然后在GameManager中维护玩家和庄家的手牌数组并驱动状态流转// scripts/manager/GameManager.js cc.Class({ extends: cc.Component, properties: { playerHand: [cc.Node], // 玩家手牌节点数组 dealerHand: [cc.Node], // 庄家手牌节点数组 deckManager: cc.Node, // DeckManager节点 // ... 其他UI引用 }, onLoad() { this._playerCards []; this._dealerCards []; this._deckManager this.deckManager.getComponent(DeckManager); this._currentState GameState.BETTING; this.initUI(); }, // 开始新一局 startNewRound() { this.clearTable(); this._playerCards []; this._dealerCards []; // 发牌玩家两张庄家一张明牌一张暗牌 this.dealCardToPlayer(); this.dealCardToPlayer(); this.dealCardToDealer(true); // 明牌 this.dealCardToDealer(false); // 暗牌 this.changeStateTo(GameState.PLAYER_TURN); this.updateUI(); }, // 玩家要牌 onPlayerHit() { if (this._currentState ! GameState.PLAYER_TURN) return; this.dealCardToPlayer(); let playerValue this.calculateHandValue(this._playerCards); if (playerValue 21) { // 玩家爆牌直接进入结算 this.changeStateTo(GameState.SETTLEMENT); this.settleRound(false); // 玩家输 } this.updateUI(); }, // 玩家停牌 onPlayerStand() { if (this._currentState ! GameState.PLAYER_TURN) return; this.changeStateTo(GameState.DEALER_TURN); this.startDealerTurn(); }, // 庄家回合 startDealerTurn() { // 先亮出暗牌 this.revealDealerCard(); // 庄家规则点数小于17必须拿牌 let dealerValue this.calculateHandValue(this._dealerCards); const takeCardInterval setInterval(() { if (dealerValue 17) { this.dealCardToDealer(true); dealerValue this.calculateHandValue(this._dealerCards); this.updateUI(); } else { clearInterval(takeCardInterval); this.changeStateTo(GameState.SETTLEMENT); this.settleRound(null); // 传入null进入正常点数比较 } }, 1000); // 每1秒抽一张牌增加游戏节奏感 }, // 结算 settleRound(playerBusted) { if (playerBusted true) { // 玩家爆牌庄家赢 this.awardChips(false); } else if (playerBusted false) { // 庄家爆牌玩家赢这个逻辑在庄家回合触发 this.awardChips(true); } else { // 正常比较点数 let playerValue this.calculateHandValue(this._playerCards); let dealerValue this.calculateHandValue(this._dealerCards); if (playerValue dealerValue) { this.awardChips(true); } else if (playerValue dealerValue) { this.awardChips(false); } else { // 平局退回筹码 this.awardChips(null); } } // 更新UI显示结果 this.showResult(); // 几秒后回到下注状态 this.scheduleOnce(() { this.changeStateTo(GameState.BETTING); this.resetForNewRound(); }, 3); }, });实操心得setInterval用于庄家自动要牌可以营造很好的游戏节奏感但务必记得在适当的时候用clearInterval清除定时器否则会导致内存泄漏和逻辑错乱。更好的做法是用Cocos Creator的cc.tween或schedule来管理这种序列动作。3.3 UI界面与交互实现UI是玩家直接交互的部分必须清晰、响应快。主要界面包括菜单界面、游戏主界面含下注区、手牌区、按钮区、信息提示区。1. 下注系统下注区通常是一排不同面额的筹码按钮如151050100。每个筹码按钮绑定一个点击事件调用GameManager的placeBet方法。GameManager需要维护一个_currentBet变量和玩家的_totalChips变量。// GameManager中的方法 placeBet(amount) { if (this._currentState ! GameState.BETTING) return; if (this._totalChips amount) { this._currentBet amount; this._totalChips - amount; this.updateChipDisplay(); // 更新UI显示 } else { // 提示筹码不足 this.showToast(筹码不足); } }“确认下注”按钮在点击后会检查_currentBet是否大于0然后调用startNewRound开始游戏。2. 手牌展示发牌不是简单地把牌节点addChild到手牌区就完事了。为了有更好的视觉效果我们需要动态创建卡牌使用cc.instantiate从cardPrefab实例化一个新节点。设置卡牌数据将CardData对象传递给卡牌节点上的Card组件由该组件根据suit和rank去加载对应的卡牌精灵图SpriteFrame。添加发牌动画使用cc.tween让卡牌从牌堆位置移动到玩家手牌区并伴有旋转、缩放等效果。牌位计算随着手牌增多需要动态计算每张牌的位置使其扇形或线性展开避免重叠。3. 按钮状态管理游戏在不同状态下按钮的可用性不同。例如在BETTING状态只有下注按钮和“确认下注”可用在PLAYER_TURN状态“要牌”、“停牌”按钮可用在其他状态所有游戏操作按钮应禁用。 我通常会在GameManager的changeStateTo方法中调用一个updateButtonStates函数根据新状态来设置各个按钮节点的interactable属性。注意事项UI更新尤其是频繁更新的文本如筹码数要避免在update函数里每帧都执行find查找节点。最好的做法是在onLoad时就将需要频繁更新的UI组件引用缓存到变量中例如this._chipLabel this.node.getChildByName(ChipLabel).getComponent(cc.Label);然后在需要更新时直接操作this._chipLabel.string。4. 资源管理与性能优化实战4.1 图集制作与动态加载21点游戏有52张卡牌如果每张牌都是一个单独的png文件将会产生52次HTTP请求在小游戏平台是文件读取严重拖慢加载速度。图集Atlas是解决这个问题的标准方案。步骤准备资源将52张卡牌图片以及所有UI图标、按钮状态图等整理到一个文件夹如assets/resources/textures/cards。创建图集在Cocos Creator的资源管理器中右键点击该文件夹选择“创建 - Sprite Atlas”。这会生成一个.atlas文件。配置图集选中这个.atlas文件在属性检查器中点击“Pack”按钮Cocos Creator会自动将所有图片打包成一张大图如cards.plist和cards.png。动态加载使用在代码中我们不再直接加载单张卡牌图片而是加载图集然后通过精灵帧名SpriteFrameName来获取具体的牌。// 在Card组件中 cc.Class({ extends: cc.Component, properties: { cardAtlas: cc.SpriteAtlas, // 可以通过属性绑定也可以动态加载 }, setCardData(data) { // 根据花色和点数拼接出精灵帧的名字例如Spades_A const spriteFrameName ${data.suit}_${data.rank}; const sprite this.getComponent(cc.Sprite); // 从图集中获取对应的精灵帧 const spriteFrame this.cardAtlas.getSpriteFrame(spriteFrameName); if (spriteFrame) { sprite.spriteFrame spriteFrame; } }, });好处将数十次请求合并为一次极大减少网络/IO开销提升游戏加载和运行性能。4.2 音频管理器的封装好的游戏离不开音效。我们需要一个统一的AudioManager来管理背景音乐和各类音效发牌声、筹码声、胜利音效等。// scripts/manager/AudioManager.js cc.Class({ extends: cc.Component, properties: { bgmVolume: { default: 0.5, range: [0, 1] }, sfxVolume: { default: 0.7, range: [0, 1] }, audioClips: { default: [], type: cc.AudioClip } // 可以通过编辑器拖拽绑定 }, onLoad() { // 常驻节点切换场景时不销毁 cc.game.addPersistRootNode(this.node); this._currentBgmId null; }, // 播放背景音乐循环 playBGM(clipName, loop true) { if (this._currentBgmId ! null) { cc.audioEngine.stop(this._currentBgmId); } const clip this.getAudioClip(clipName); if (clip) { this._currentBgmId cc.audioEngine.play(clip, loop, this.bgmVolume); } }, // 播放音效一次 playSFX(clipName) { const clip this.getAudioClip(clipName); if (clip) { cc.audioEngine.play(clip, false, this.sfxVolume); } }, // 根据名字查找音频片段这里简单实现实际可用字典映射 getAudioClip(name) { // 假设audioClips数组里存放的是{name: bgm, clip: cc.AudioClip}对象 const item this.audioClips.find(item item.name name); return item ? item.clip : null; }, // 设置音量 setBGMVolume(volume) { this.bgmVolume volume; if (this._currentBgmId ! null) { cc.audioEngine.setVolume(this._currentBgmId, volume); } }, setSFXVolume(volume) { this.sfxVolume volume; }, });在GameManager中在发牌、下注、结算等时机调用AudioManager的playSFX方法即可。踩坑记录微信小游戏平台对音频播放有严格限制必须由用户交互如touchstart事件触发第一次播放。通常的解决方案是在游戏开始如菜单界面设置一个“点击屏幕开始”的按钮在它的回调函数里先播放一个极短的静声音频来“解锁”音频上下文。AudioManager的初始化或第一次播放需要在这个交互之后。4.3 针对微信小游戏的包体优化策略这是将Cocos Creator项目发布为可上线小游戏的关键一步。原版Demo直接构建后主包体积可能达到3-4MB而微信小游戏主包限制是4MB近期部分类目可提审至8MB我们必须优化。1. 基础构建配置引擎裁剪在项目 - 项目设置 - 功能裁剪中去掉你用不到的模块比如3D、物理、视频播放器等。只保留2D渲染、音频、UI等核心模块能显著减小引擎代码体积。图片压缩对所有图片资源在属性检查器中设置合适的压缩格式如WebP、PNG压缩等级并勾选“优化纹理”。2. 使用小游戏分包必做这是最有效的优化手段。原理是将游戏资源分成多个包启动时只加载主包包含启动页和核心代码游戏过程中再按需加载其他分包。在Cocos Creator构建面板中配置打开构建发布面板在“通用设置”里找到“主包压缩类型”选择“小游戏分包”。在“分包”选项里添加分包。如何划分一个典型的划分方式是主包启动场景Menu、核心引擎代码、GameManager、AudioManager等全局脚本。游戏分包游戏主场景Table、所有游戏用到的图片图集、音效、以及游戏场景专属的脚本。3. 进阶优化Asset Bundle按需加载分包是粗粒度的。Asset Bundle提供了更细粒度的资源管理能力。你可以将不同的场景、甚至不同的功能模块如卡牌资源、特效资源打成不同的Asset Bundle实现真正的按需加载。创建Asset Bundle在资源管理器中右键点击一个文件夹如resources/cards选择“配置为Bundle”。在属性检查器中设置Bundle名称如cards和优先级。代码中动态加载// 在需要卡牌资源的时候再加载 cc.assetManager.loadBundle(cards, (err, bundle) { if (err) { cc.error(err); return; } bundle.load(card_atlas, cc.SpriteAtlas, (err, atlas) { if (!err) { // 使用atlas this.cardAtlas atlas; } }); });4. 极限优化思路参考网络资料从搜索到的资料看有人通过更极致的改造将主包体积压到了70KB左右。核心思路是将初始场景也作为分包默认“初始场景分包”选项会把初始场景资源放进主包。我们可以手动不勾选此选项而在main.js中用代码先加载包含菜单场景的分包再运行菜单场景。分离游戏适配器adapter将小游戏平台适配文件adapter-min.js也放到分包里在game.js中通过wx.loadSubpackage先加载它再初始化引擎。 这样做的好处是主包体积极小启动速度极快。但代价是增加了代码复杂度和初始加载的异步步骤需要更精细地处理加载过程和错误。重要提示极限优化需要对Cocos Creator的构建流程和微信小游戏的加载机制有较深理解且可能因Cocos Creator版本更新而需要调整。对于大多数项目做好基础构建配置和标准分包已经能满足4MB的主包限制。建议先采用标准方案确有需要再研究极限方案。5. 构建发布与问题排查5.1 微信小游戏构建全流程安装开发者工具确保已安装微信开发者工具。Cocos Creator构建配置打开构建发布面板。发布平台选择“微信小游戏”。游戏名称和AppID填写你在微信公众平台申请的小游戏AppID没有的话可以先使用测试号。初始场景勾选你的菜单场景如Menu。主包压缩类型选择“小游戏分包”。MD5 Cache勾选给资源文件加上MD5后缀有利于缓存更新。分包配置根据你的规划添加分包路径如将assets/table目录设为一个分包。点击构建。构建完成后会生成一个build/wechatgame目录。导入与预览打开微信开发者工具选择“导入项目”目录指向刚才生成的build/wechatgame。导入后点击“编译”或“预览”即可在模拟器和真机上运行你的21点游戏。上传与发布在开发者工具中点击“上传”填写版本信息即可将代码上传至微信后台。登录微信公众平台在“版本管理”中提交审核审核通过后即可发布。5.2 常见问题与解决方案速查表问题现象可能原因解决方案构建后白屏控制台无报错1. 主包体积超过4MB限制。2. 资源引用丢失特别是图集。1. 检查构建日志中的包体大小使用分包和Asset Bundle优化。2. 检查构建后的assets目录下是否有图集文件.plist和.png检查代码中加载路径是否正确。在微信开发者工具中运行正常真机预览时黑屏或报错1. 真机网络环境问题资源加载失败。2. 使用了某些仅模拟器支持的API或配置。3. 音频播放未在用户交互后触发。1. 开启微信开发者工具的“不校验合法域名”选项进行测试。上线前需配置服务器域名。2. 检查代码避免使用wx开头的特定API应使用cc.sys平台判断。3. 确保第一次播放音频是在一个button的touchstart回调中。动态加载的图片如图集不显示1. 加载路径错误。2. 资源未放在resources目录下。3. 加载异步未完成就使用了资源。1. 使用cc.resources.load时路径是相对于resources的无需后缀。2. 确认要动态加载的资源都在assets/resources或其子目录下。3. 在加载成功的回调函数内使用资源。点击按钮无反应1. 按钮节点interactable为false。2. 按钮事件监听未正确绑定。3. 节点被其他UI遮挡。1. 检查代码中是否在某个状态下禁用了按钮。2. 检查编辑器中的点击事件绑定或代码中的node.on(‘click’, …)调用。3. 检查场景中节点的层级zIndex和大小。游戏逻辑混乱状态不对1. 状态机逻辑有漏洞状态可以非法切换。2. 异步操作如定时器、动画回调未考虑状态变化。1. 在GameManager的changeStateTo方法中加入日志打印所有状态切换仔细核对。2. 在启动异步操作前检查当前状态并在状态改变时取消未完成的异步操作如clearInterval。在iOS设备上声音播放异常或延迟iOS系统的音频播放策略较为严格。1. 确保所有音效播放都由用户交互触发。2. 可以尝试在游戏初始化时预加载cc.audioEngine.preload关键音效。3. 使用较短的音频格式如.mp3或.m4a。5.3 真机调试技巧微信开发者工具的模拟器毕竟只是模拟很多问题特别是性能、音频、触摸反馈必须在真机上才能发现。开启vConsole在game.json中设置debug: true可以在真机上看到日志和控制台对于排查运行时错误至关重要。性能面板在微信开发者工具的“调试器 - Audits”或真机上的vConsole中可以查看内存占用、帧率FPS、网络请求等信息。如果游戏卡顿可以检查是否有内存泄漏节点未销毁、或每帧执行了过于复杂的操作。远程调试用数据线连接手机和电脑在微信开发者工具中选择“真机调试”可以在电脑上直接操作手机上的小游戏并查看日志和调试代码效率极高。走完以上所有步骤你应该已经拥有了一个从零开始、代码可控、性能达标、并且可以发布上线的Cocos Creator 21点游戏。这个项目麻雀虽小五脏俱全涵盖了游戏开发的核心流程规则设计、状态管理、UI交互、资源处理、性能优化、构建发布。理解并实践完它你再去挑战更复杂的游戏项目心里就会非常有底了。
返回列表