ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CocosCreator大厅子游戏整合:bundle分包与独立热更实践

CocosCreator大厅子游戏整合:bundle分包与独立热更实践 简介这是一份面向 Cocos Creator 开发者的“大厅多个子游戏”整合练习 Demo适合正在搭建游戏大厅、多关卡合集或独立子游戏聚合项目的团队。它演示如何将大厅作为入口把多个子游戏按模块组织并结合独立热更与整包打包思路解决项目拆分、场景切换、动态加载和更新发布的常见问题。压缩包共 64 个文件约 7.09MB包含 19 个 js 逻辑脚本、12 个 json 配置与资源清单、12 个 meta 资源映射、9 张 png 和 5 张 jpg 美术素材、3 个 ttf 字体、2 个 fire 场景文件以及 txt 说明和 bat 辅助脚本结构清晰便于按模块对照学习。项目重点覆盖大厅 UI 导航、资源管理、热更机制、性能优化和跨平台部署等关键环节可帮助前端或独立开发者快速掌握多游戏项目的整合思路。已有 943 人学习下载适合从单个游戏向大厅化架构过渡的开发者参考。1. CocosCreator 大厅子游戏整合这份 demo 笔记解决的是工程结构问题接手过 CocosCreator 大厅子游戏项目的人应该都有同感最头疼的不是某个玩法写不出来而是「大厅和子游戏怎么摆在同一个工程里还不互相踩」。这份「cocosCreator 大厅子游戏笔记 demo」就是一套把 hall、game1 分开管理、按需加载、支持独立热更的整合示范压缩包里 hall 和 game1 各自带 assets 与 build放一起跑就是完整的大厅入口带一个可切入的子游戏。它适合要做多玩法聚合、想搞独立热更又不想推倒重来的 CocosCreator 团队也适合第一次接触此类结构的开发者照着梳理自己的工程边界。接下来我按拆解这个 demo 的顺序把结构设计、场景切换、资源管理、热更链路逐个讲透顺带把我在类似项目里踩过的坑一并标出来。2. 项目结构设计hall 与 game1 的目录划分逻辑2.1 为什么要按 bundle 拆子游戏先给结论这个 demo 里大厅和子游戏本质上是「同一工程、多个 bundle」。bundle资源包是 CocosCreator 做分包的单位构建时每个 bundle 会单独输出配置和资源子游戏的场景、脚本、贴图、音频都锁在自己包里大厅在运行期按需把 bundle 加载进来用完之后再释放。这个设计带来的直接好处是主包只装大厅和公共资源子游戏不进首包首包体积明显降下来启动速度也稳。更关键的是后续热更会方便很多——要更新 game1 的逻辑只需要替换 game1 这个 bundle 的内容完全不碰大厅。很多人第一次拿到这类项目会误以为「大厅子游戏」就是多搞几个场景文件来回 loadScene其实那是把所有东西塞在一个包里资源互相引用构建出来一堆冗余。用 bundle 划分之后每个子游戏就是一个黑匣子对外只暴露一个入口场景和一个 bundle 名内部想怎么组织都不影响大厅。这也是这份笔记最值得参考的地方它在目录层面就把边界划清楚了。目录 / 文件作用归属assets/hall大厅场景、大厅 UI 脚本、公共纹理主包 bundleassets/game1子游戏完整逻辑、场景、专属资源独立 bundleassets/resources大厅和子游戏共用的启动配置、公共 UI主包 resourcesassets/common跨模块复用脚本比如网络、音频管理主包脚本tools打包配置、热更资源生成脚本工程工具链build构建产物输出目录产物不进版本库2.2 目录与加载入口的落地拆开这个 demo 的项目根目录你会看到 hall 和 game1 各成体系而不是混在一个 assets 里。以常见实践为例目录大概长成下面这样project/ ├── assets/ │ ├── hall/ │ │ ├── scenes/ │ │ │ └── hall.scene │ │ ├── scripts/ │ │ │ ├── HallManager.ts │ │ │ └── GameEntryButton.ts │ │ └── textures/ │ ├── game1/ │ │ ├── scenes/ │ │ │ └── game1.scene │ │ ├── scripts/ │ │ │ ├── Game1Root.ts │ │ │ └── Game1Bootstrap.ts │ │ └── resources/ │ ├── resources/ │ │ └── config/ │ │ └── game_list.json │ └── common/ │ └── scripts/ │ └── EventBus.ts └── tools/ ├── pack/ └── hotupdate/这里最需要理解的是assets/resources和普通子目录的区别。resources下面的东西可以直接用resources.load同步拿到不需要先加载 bundle所以像game_list.json这种大厅启动时必须读的配置放这里最合适。而 hall、game1 这种顶层的业务目录在构建时会按目录被识别为独立的 bundle这是 CocosCreator 的约定逻辑assets 下每个一级子目录默认都是一个 bundle。所以你在 Creator 编辑器里看到 hall 和 game1 文件夹旁边会有一个 bundle 标识千万别手贱勾掉。2.3 扩展一个 game2 要动哪些文件这个 demo 只有 game1但它的结构是能横向扩展的。加一个 game2 需要做的动作其实很固定在 assets 下新建 game2 目录把场景和脚本放进去在game_list.json里加一条子游戏记录在大厅场景里加一个入口按钮配置 bundle 名和场景名。仅此而已不需要改大厅的逻辑代码。{ games: [ { key: game1, bundle: game1, scene: game1, version: 1.0.0 }, { key: game2, bundle: game2, scene: game2, version: 1.0.0 } ] }key是业务标识bundle是资源包名scene是子游戏入口场景名version给热更比对用。字段不要随意改名后面热更脚本会直接按这个结构读配置。我一般建议把这个 JSON 当成子游戏注册表来维护新增游戏先加配置再去大厅摆按钮配置驱动比硬编码省事得多。3. 场景切换与事件分发大厅到子游戏的三种导航方式3.1 三种导航方式与选型在大厅和子游戏之间切换常用做法有三种各有适用场景。第一种是director.loadScene直接切换全局场景适合子游戏独立运行、不需要和大厅共享状态的场景这个 demo 用的就是这条路结构简单、内存好清理但切换时会有短暂黑屏。第二种是先把子游戏 bundle 加载好再用bundle.loadScene从包内加载场景适合需要在大厅里做嵌入预览的场合。第三种是用节点预加载加遮蔽切换需要自己管理所有子游戏节点的挂载和卸载灵活但复杂度高一般项目用不到。对多数聚合游戏产品来说第一种方案足够。关键点在于切换前必须先确保目标 bundle 已加载否则loadScene会找不到场景。很多新手直接写director.loadScene(game1)首包没包含 game1 场景真机上稳定报错这就是场景切换的第一道坑。3.2 大厅按钮监听与进入子游戏大厅的入口按钮通常挂在 hall 场景里每个按钮对应一个子游戏。点击后先走配置读取再加载 bundle最后切场景。下面这段是我按 Creator 3.x 的 TypeScript 接口整理的和 demo 里的 HallManager 思路一致// HallManager.ts import { assetManager, resources } from cc; const GAME_CONFIG_PATH config/game_list; export interface GameItem { key: string; bundle: string; scene: string; version: string; } export class HallManager { /** 从公共配置文件读取子游戏清单 */ static loadGameList(): PromiseGameItem[] { return new Promise((resolve, reject) { resources.load(GAME_CONFIG_PATH, (err, jsonAsset) { if (err) { reject(new Error(读取游戏配置失败: ${err.message})); return; } const data jsonAsset.json as { games: GameItem[] }; resolve(data.games); }); }); } /** 加载指定子游戏 bundle重复加载不会导致重复构建 */ static loadBundle(name: string): Promisenull { return new Promise((resolve, reject) { assetManager.loadBundle(name, (err) { if (err) { reject(new Error(bundle[${name}] 加载失败: ${err.message})); return; } resolve(null); }); }); } }GAME_CONFIG_PATH指向 resources 下的配置文件用resources.load读取是因为它在任何 bundle 加载之前就能用。loadBundle里 assetManager 会做幂等处理同一个 bundle 加载多次不会重复初始化放心调用。需要注意loadBundle的第二个参数是回调我这里包成了 Promise纯粹是为了调用方写起来清爽。有了这两个方法大厅按钮的点击逻辑就非常薄了// GameEntryButton.ts import { _decorator, Component, Node, director } from cc; import { HallManager } from ./HallManager; const { ccclass, property } _decorator; ccclass(GameEntryButton) export class GameEntryButton extends Component { property({ tooltip: 子游戏 bundle 名 }) bundleName game1; property({ tooltip: 目标场景名 }) sceneName game1; onClick() { HallManager.loadBundle(this.bundleName) .then(() { director.loadScene(this.sceneName); }) .catch((err) { console.error([GameEntryButton] 进入 ${this.bundleName} 失败, err); }); } }这段代码里property装饰器很关键它让 bundle 名和场景名暴露在编辑器面板上策划或客户端同学可以直接在 Inspector 里配不用每次改代码。onClick需要挂到按钮的 ClickEvents 上回调绑定到该组件的 onClick 方法。注意请先在场景里给按钮配置好事件绑定代码本身不会自动为按钮添加监听。3.3 子游戏内返回大厅与事件清理子游戏返回大厅常规操作也是director.loadScene(hall)但这里有个容易翻车的细节子游戏场景销毁时挂在场景节点上的组件会走onDestroy而你在大厅注册的全局事件监听不会跟着场景自动清理。特别是用 EventBus 这类跨模块通信工具时子游戏在onLoad里监听了一个事件返回大厅后监听器还挂在 EventBus 上下次进入子游戏会重复注册回调执行两遍。我一般会在子游戏的根脚本里做对称处理// Game1Root.ts import { _decorator, Component, director, Event } from cc; import { EventBus } from ../common/scripts/EventBus; const { ccclass, property } _decorator; ccclass(Game1Root) export class Game1Root extends Component { private readonly events: Array[string, Function] []; onLoad() { const handler this.onBackToHall.bind(this); EventBus.on(back_to_hall, handler); // 存起来等 onDestroy 统一注销 this.events.push([back_to_hall, handler]); } private onBackToHall() { director.loadScene(hall); } onDestroy() { for (const [name, handler] of this.events) { EventBus.off(name, handler); } this.events.length 0; } }思路很简单所有通过 EventBus 注册的回调都存进数组onDestroy里统一注销。别嫌麻烦等你哪天遇到返回大厅再进子游戏发现游戏逻辑执行了两遍就明白这几行代码是后悔药。4. 动态加载与内存管理子游戏资源按需请求的实操4.1 先加载机制再跑通流程子游戏资源不要全量塞进主包这是整套设计的前提。CocosCreator 的动态加载分两档一档是resources.load面向 resources 目录适合公共配置和少量通用资源另一档是bundle.load面向具体 bundle适合子游戏内部资源。demo 里的 game1 资源都放在自己的 bundle 里所以进子游戏后再用 bundle 加载生命周期跟着子游戏走。// Game1Bootstrap.ts import { _decorator, Component, assetManager, AudioClip } from cc; const { ccclass, property } _decorator; ccclass(Game1Bootstrap) export class Game1Bootstrap extends Component { onLoad() { const bundle assetManager.getBundle(game1); if (!bundle) { console.warn([Game1Bootstrap] game1 bundle 未加载跳过内部资源加载); return; } // 加载子游戏专属音频路径相对 bundle 根目录 bundle.load(audio/bgm, AudioClip, (err, clip) { if (err) { console.error([Game1Bootstrap] 加载音频失败: ${err.message}); return; } // 这里拿到 clip可以赋给 AudioSource 组件 }); } }assetManager.getBundle(game1)是同步获取 bundle 实例的方法如果大厅还没加载过 game1这里拿到的就是 null。所以这段逻辑放在子游戏场景的 onLoad 里前提是入口按钮已经loadBundle过了。路径audio/bgm是相对 bundle 根目录的不要加assets/前缀也不要加文件后缀这是 CocosCreator 资源加载最常见的手误之一。4.2 释放时机与引用计数动态加载的核心困境是资源加载进内存什么时候释放。释放早了界面还没用上就白屏释放晚了内存一直涨低端机直接被杀。CocosCreator 的资源系统有引用计数同一个资源被多处引用计数不为 0 时release不会真正释放。理解这个机制后你的释放策略就应该是「离开子游戏时统一释放整个 bundle 的资源」。// 离开 game1 时调用 export function releaseGame1() { const bundle assetManager.getBundle(game1); if (!bundle) return; // 释放所有该 bundle 内无引用的资源 bundle.releaseAll(); // 不在这里移除 bundle 实例保留加载状态方便二次进入 }参数说明bundle.releaseAll()会遍历 bundle 内的资源逐一减少引用计数计数归零才真正卸载。这里我不调用assetManager.removeBundle(game1)因为移除后二次进入又要重新加载会有加载白条。保留 bundle 实例、只释放资源是性能和体验之间的平衡点。子游戏里的常驻节点如果有组件引用这些资源release 会把它们标记为无效所以释放前要保证所有用到资源的节点已经被销毁。4.3 进度反馈与加载失败重试子游戏资源多的时候闷头加载会让玩家以为卡死了。bundle.load支持传入 onProgress 回调可以做成进度条。更重要的是失败重试逻辑移动端网络抖动很常见一次加载失败不代表资源有问题。function loadGame1WithRetry(maxRetry 3) { const bundle assetManager.getBundle(game1); if (!bundle) { HallManager.loadBundle(game1) .then(() loadGame1WithRetry(maxRetry)) .catch((err) console.error([loadGame1] bundle 加载失败, err)); return; } let retryCount 0; const tryLoad () { bundle.loadScene(game1, (err) { if (err retryCount maxRetry) { retryCount 1; console.warn([loadGame1] 场景加载失败第 ${retryCount} 次重试); tryLoad(); return; } if (err) { console.error([loadGame1] 重试 ${maxRetry} 次仍失败, err); } }); }; tryLoad(); }这个函数的逻辑分两层第一层保证 bundle 存在不存在就先用loadBundle补加载第二层是场景加载失败后的有限重试maxRetry默认 3 次。重试之间建议加一点延时否则网络还没恢复就连续失败体验更差。实际项目里我会把重试次数和版本号一并上报到远端方便判断是客户端问题还是服务器资源没同步。5. 踩坑记录场景黑屏、资源路径失效与热更静默失败的排查5.1 进子游戏直接黑屏控制台没有任何报错现象大厅点击游戏入口场景卡在黑屏控制台干干净净。 原因目标场景被构建进了独立的子游戏 bundle但入口代码没有先加载 bundle 就调用了director.loadScene场景资源找不到引擎直接停止加载。有时候报错被吞了看起来就像纯黑屏。 解决把场景切换改成「先loadBundle再loadScene」两段式入口按钮的 onClick 里加HallManager.loadBundle的 Promise 链。排查时先在编辑器里构建一次打开构建面板确认 game1 bundle 产物里确实有game1.scene再用浏览器开发者工具的 Network 面板看场景请求是否发出。5.2 bundle.load 的路径写错资源一直加载失败现象子游戏内部用bundle.load加载图片或音频报错说找不到资源但资源明明就在工程里。 原因bundle.load的路径是相对 bundle 根目录的assets/game1/textures/icon.png要写成textures/icon且不能带扩展名。新手容易直接把 assets 全路径粘进去或者带.png后缀。 解决先确认资源在目录树里的位置再确认 bundle 配置。也可以在代码里用bundle.getPathsWithExt(png)打印当前 bundle 里所有 png 路径对照实际路径排查比盲猜快得多。5.3 返回大厅再进子游戏逻辑执行了两次现象第一次进子游戏正常返回大厅再进金币翻倍、弹窗重复出现日志里同一个事件打印两遍。 原因子游戏脚本在 onLoad 里注册了全局事件监听切回大厅时场景销毁但监听器还挂在全局事件总线上二次进入又注册一遍。 解决注册的监听统一在onDestroy注销。推荐把事件注册集中到一个数组里销毁时遍历注销避免遗漏。这个坑最容易出现在有跨场景通信的项目里EventBus 用越爽这里摔得越疼。5.4 热更后版本号没变客户端永远不更新现象服务端上传了新的子游戏资源客户端进入后还是旧逻辑日志看不到任何热更请求。 原因热更流程比对的是 bundle 的版本号检查脚本按game_list.json里的 version 字段发起请求但构建工具没更新 version.json或者版本名没变客户端认为资源一致跳过下载。 解决把版本号写入构建脚本每次重新构建子游戏 bundle 时自动递增一个次版本号而不是手动改。版本文件用 JSON 数组下发键是 bundle 名比对粒度精确到子游戏级这样 game1 更新不会触发 game2 重新下载。5.5 真机上子游戏加载慢低端机直接闪退现象真机测试时点进子游戏白屏时间过长低端设备直接闪退模拟器上没事。 原因子游戏 bundle 首次加载走的是网络或本地解压资源密集情况下主线程被卡死。模拟器资源在本地几乎无耗时真机才暴露问题。 解决子游戏内的大纹理和音频做压缩或降采样能用图集的地方别零散出图。另外在大厅阶段用assetManager.loadBundle对高频子游戏做预加载把加载耗时挪到玩家看大厅的时间里。预加载要注意量一次别把所有子游戏都拉了先拉日志统计里点击率最高的两三个。6. 进阶验证整包打包后如何确认独立热更链路是通的6.1 独立热更的验证思路整包和热更不是二选一先打包后热更才是完整闭环。这个 demo 里「将独立子游戏打入整包」用场景切换保证功能可用「独立热更子游戏」用 bundle 级更新保证后续能修。需要验证的核心问题只有一个game1 单独更新后只影响 game1大厅和其他子游戏完全不受牵连。验证链路分五步整包构建一个全功能版本改 game1 的任意脚本比如加一行日志重新构 game1 的 bundle把新 bundle 和更新后的 version.json 丢到热更服务器真机打开 App 进游戏确认新日志出现返回大厅功能正常。6.2 一个简单的版本比对脚本// VersionCheck.ts import { resources } from cc; export interface RemoteGameVersion { key: string; version: string; url: string; } export function diffLocalVersions(remoteList: RemoteGameVersion[]): string[] { const needUpdate: string[] []; // 本地版本从 resources 下的配置读取 resources.load(config/game_list, (err, jsonAsset) { if (err) return; const localGames (jsonAsset.json as { games: Array{ key: string; version: string } }).games; for (const local of localGames) { const remote remoteList.find((r) r.key local.key); if (remote remote.version ! local.version) { needUpdate.push(remote.url); } } }); return needUpdate; }比对逻辑是按key匹配不看bundle名这样 bundle 结构调整时配置还能兼容。remoteList由远端 version.json 下发字段对齐后把需要更新的资源 URL 收集起来交给下载队列批量拉取。下载完成后回到打包入口重新加载对应 bundle再走一遍场景切换流程确认新资源生效。验证到位的标准是整包覆盖安装后不更新本地功能完整强制走一次热更子游戏状态切换正常游戏内资源是新版本。从那以后我每次验收这类整合项目都强制走一遍「整包 单 bundle 热更」的闭环先确认基础可用再确认增量可控——这套流程能挡住大部分发布事故希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进