简介:面向游戏开发者的CocosCreator大厅子游戏整合demo,演示了如何在CocosCreator中构建游戏大厅,并接入多个可独立热更的子游戏。这种设计适用于在线游戏平台、多关卡或多种玩法组合的项目。资源共64个文件,压缩包约7.09MB,以js脚本、json配置、fire场景及png/jpg图片素材为主,并包含meta、ttf字体与bat构建脚本,覆盖了项目从逻辑、配置到资源组织的完整基础结构。目前已有942人学习浏览,适合正在学习Cocos Creator模块化开发、热更新和资源管理的开发者。从资源目录结构可以直观看到,大厅与各子游戏分目录存放,并配有build、tools等辅助模块;对照代码可重点学习场景切换与事件监听、动态加载策略、独立子游戏的热更配置,以及整包打包时的资源组织与性能优化思路,为后续搭建更复杂的多游戏整合项目提供可复用的参考框架。
1. 大厅子游戏架构是什么:先想清楚再动手写 demo
我在做了一轮 CocosCreator 大厅子游戏笔记 demo 之后,最大的感受是:这东西卡人的点从来不是引擎 API,而是“多项目整合”之后所有工程约定都要自己定。所谓大厅子游戏模式,就是开场只加载一个“大厅”场景,玩家要玩的子项目(可以是一堆休闲小游戏、抽奖转盘、活动页面)不常驻内存,按需拉取子游戏 bundle,进入子游戏,退出时再释放回大厅。它解决的是包体膨胀、启动变慢、子项目间代码互相污染的问题。适合做合集型 App、盒子型分发、或者内部活动聚合页的团队。对新手来说,先不要急着把全部代码塞进一个场景,而是理解大厅和子项目之间存在一条清晰边界,否则后续的构建、热更、远程资源管理全部会跟着失控。
2. 搭建大厅子游戏工程:一份工程还是多份工程,三种组织方式
2.1 方式一:单工程 + 多 Bundle,最稳的 demo 起点
常见做法是把“大厅”和每个“子游戏”放在同一个 CocosCreator 工程里,通过给文件夹打上 Asset Bundle 标记,让构建时自动把子项目拆成独立包。我一般这样组织目录结构:
assets/ hall/ // 大厅工程:开始场景、公共 UI、公共工具库 subgames/ subgame1/ // 子游戏1,这个目录设为 bundle subgame2/ // 子游戏2,这个目录设为 bundle在 Creator 资源管理器中选中subgames/subgame1文件夹,在属性检查器里勾选“配置为 Bundle”,bundle 名称建议用小写和下划线,比如subgame1。每个 bundle 内有自己的场景、脚本、预制体和资源,外部大厅代码不直接 require 子游戏内部的模块。
从这份 demo 实践来看,这种方式的收益非常直接:构建时每个 bundle 独立生成config.json和资源文件,主包只保留大厅必需资源。子游戏之间即便都叫GameManager.ts这类烂大街文件名,也不会被合并到同一份代码里。模块隔离这件事,是靠文件夹边界实现的,而不是靠起名玄学。
以下是开场加载子游戏的核心代码,在 Creator 3.x 环境下运行:
// hall/scripts/Hall.ts import { _decorator, Component, assetManager, director } from 'cc'; const { ccclass } = _decorator; @ccclass('Hall') export class Hall extends Component { openSubGame(bundleName: string, scenePath: string) { // 1. 先判断是否已经加载过,避免重复加载耗内存 const existed = assetManager.getBundle(bundleName); if (existed) { director.loadScene(scenePath); return; } // 2. 从本地包或远程服务器拉取 bundle assetManager.loadBundle(bundleName, (err, bundle) => { if (err) { console.error(`[hall] load bundle ${bundleName} failed`, err); return; } // 3. 场景路径直接传给 director director.loadScene(scenePath); }); } }参数说明:bundleName要和构建面板里的 bundle 名称完全一致,不是文件夹名,而是勾选“配置为 Bundle”后填写的名称;scenePath指的是该 bundle 内场景资源在assets下的路径,比如subgame1/scenes/GameMain,不需要带.scene后缀。这里我没有在 loadBundle 里回调中预加载资源,是因为loadBundle完成时,bundle 内场景已经可以被引擎定位,直接 loadScene 是可行且最简的做法。
2.2 方式二:多工程预构建,适合子项目由不同小组维护
当子游戏来自不同团队、或者需要独立迭代和发版时,可以再把边界往外推一层:大厅项目和每个子游戏项目分开,各自构建,最终产物统一放到远程服务器,大厅启动后按 URL 加载远程 bundle。
这个方案我没在大范围线上项目里用满,但在多团队协作的 demo 推演里跑通过。核心步骤是:每个子游戏工程独立构建,构建目标平台选同一个(比如 web-mobile 或 Android),构建结果里找到对应 bundle 目录,单独上传到服务器的remote/subgame1路径。大厅启动时,用assetManager.loadBundle({ url: 'https://cdn.example.com/remote/subgame1' })加载远程包。
// 多工程方式下,大厅直接用远程地址加载 assetManager.loadBundle({ url: 'https://cdn.example.com/remote/subgame1', priority: 2 }, (err, bundle) => { if (err) { // 远程加载失败时可以降级到本地兜底包 console.error('[hall] remote bundle error, fallback to local', err); assetManager.loadBundle('subgame1_local', (err2, localBundle) => { if (err2) return; director.loadScene('subgame1/scenes/GameMain'); }); return; } director.loadScene('subgame1/scenes/GameMain'); });参数含义:url指远端 bundle 的 config.json 所在目录;priority控制加载队列的优先级,数字越大越优先。这里有个容易翻车的细节:远程 bundle 的 URL 里不能把config.json写进去,只写到 bundle 目录。比如目录下有config.json、index.js、资源文件,配置 URL 就写到.../subgame1这层。
多工程方案能减少工程体积、避免无关资源互相引用,但代价是公共代码会被重复打进每个子游戏包,资源无法跨项目共享。如果你的大厅和子游戏要共用一套 UI 基类、网络层、登录模块,就必须把这些公共代码放进大厅包里,子游戏通过全局对象访问,否则每个子游戏体积会噌噌往上飙。
2.3 方式三:先别做“全场景常驻”,模块分包更符合真实开发
很多人第一次接到“大厅子游戏”需求时,会直接在一个场景里把所有子游戏的内容堆进去,按钮隐藏起来,靠切换 Canvas 显隐来模拟“进入子游戏”。这种方案在 demo 里看着没问题,但一旦子游戏资源达到上百 MB,首屏加载会直接劝退用户。子游戏应该被当成可以被动态挂载和卸载的模块,而不是被当成大厅场景里的一堆子节点。
我建议只把“公共逻辑”放到大厅 bundle,子游戏的业务逻辑和美术资源全部留在子 bundle。判断依据很简单:如果一段代码或一个图集,只有某个子游戏用,那就放进该子游戏目录;如果多个子游戏都会用,再放到公共目录。这样构建后主包能保持轻量,子游戏也能独立得到清理和释放。
3. 大厅与子游戏之间的通信:事件总线、返回唤起和资源释放
3.1 用事件总线解耦,别直接引用子游戏类
大厅和子游戏如果直接互相 new 类或者引用对方节点,就会破坏 bundle 的卸载边界。常见做法是让两者只依赖事件,不在代码里强引用对方模块。
我在 demo 里维护了一个独立的事件模块:
// hall/scripts/events/HallBus.ts import { EventTarget } from 'cc'; // 全局唯一事件总线,大厅和子游戏都通过它通信 export const hallBus = new EventTarget();子游戏内部可以在任意地方发事件:
// subgame1/scenes/GameMain.ts import { _decorator, Component } from 'cc'; import { hallBus } from 'hall/scripts/events/HallBus'; // 注意:该引用只在类型层面,不构建进子游戏 const { ccclass } = _decorator; @ccclass('GameMain') export class GameMain extends Component { start() { // 向大厅上报子游戏初始化完成 hallBus.emit('subgame-ready', { name: 'subgame1' }); } onResultBtn() { // 把子游戏结果回传给大厅 hallBus.emit('subgame-result', { score: 100, level: 3 }); } }这里用一个独立模块做事件广播,比直接用director的全局事件或找人“挂到一个公共节点”要干净。hallBus.emit的第二个参数是事件载荷,可以是对象或数组。需要特别提醒的是:子游戏代码里 import 大厅的HallBus模块,在构建时会把这段逻辑合并到子包里,但这不会破坏场景卸载,因为HallBus本身只是个无状态的EventTarget实例,没有持有任何纹理或场景节点,它天然就是安全的。
3.2 监听方记得用 off,否则返回大厅会收到重复回调
这是最常被忽视的坑。大厅里的监听代码如果只有 on,没有在子游戏结束时 off,那么第二次进入同一个子游戏时,同一个事件回调会注册两次,子游戏发一次消息,大厅就执行两遍,表现为弹窗出现两次、计分翻倍、甚至场景重复加载。
建议的写法是:在大厅场景的onDestroy里统一解绑,或者把监听器集中在一个 manager 中,每次进入子游戏前先 off 再 on:
// 大厅挂载的节点组件里 private onSubGameReady = (data: any) => { console.log('[hall] subgame ready', data); } onEnable() { hallBus.on('subgame-ready', this.onSubGameReady, this); } onDisable() { hallBus.off('subgame-ready', this.onSubGameReady, this); }一种轻微翻车情况是:你觉得返回大厅时要带着子游戏数据,于是把一个 scene 里的节点引用放进了全局事件载荷,然后在子游戏释放后去访问那个节点。结果肯定是访问到已经销毁的节点,控制台报Invalid reference之类的错。所以事件载荷里只传普通数据,不要传节点、组件、预制体这类引擎对象。
3.3 退出子游戏的关键动作:停场景、释放 bundle、还原大厅
我把“返回大厅”这个动作拆成三步:第一步让子游戏场景停止运行,第二步释放子游戏 bundle 的常驻资源,第三步把大厅场景切回来。注意不能省略第一步,因为当前场景如果没有完全切出,直接释放 bundle 可能会出现正在使用的资源被提前释放,黑屏随之而来。
// 子游戏内点返回按钮 backToHall() { // 通知大厅清理监听 hallBus.emit('back-to-hall'); // 保存本次子游戏的 bundle 名,切场景前记一下 const bundleName = 'subgame1'; // 释放场景 director.loadScene('hall/scenes/HallMain', () => { // 等待新场景首帧渲染后再释放 bundle,避免释放动作打断场景切换 const bundle = assetManager.getBundle(bundleName); if (bundle) { bundle.releaseAll(); assetManager.removeBundle(bundle); } }); }releaseAll()会把该 bundle 内的所有资源和脚本持有内容标记为释放;removeBundle则把 bundle 实例从 assetManager 里移除。以后再次打开这个子游戏,需要重新走loadBundle。这里我特意用了loadScene的第二个参数回调,确保新场景已经激活再释放资源。否则可能在场景切换的瞬态里,渲染管线还引用着旧纹理,导致中间帧出现花屏或闪黑。这也是我自己第一次做大厅子游戏 demo 时真实踩过的坑,后来改成“先切场景,成功回调后释放”才稳定。
4. 构建打包时的关键参数:场景、Bundle 名和远程路径
4.1 构建面板“参与构建场景”怎么勾:主场景只留大厅
打开构建发布面板后,最上方会列出所有“参与构建场景”。在这里最容易犯的错是把子游戏场景也勾进去,导致子游戏内容被打进主包,大厅首包体积立刻膨胀。
正确做法是:只勾选大厅的启动场景。子游戏 bundle 里即使有场景,也无需出现在“参与构建场景”列表中,因为loadBundle加载的是整包,bundle 里的场景是随着 bundle 被引擎读取的。我长期使用的约定是:
- 勾选字段只包含
hall/scenes/HallMain。 - 每个子游戏有且只有一个主场景,命名为
GameMain.scene,放在子游戏 bundle 目录下。 - 子游戏 bundle 目录不勾选任何“启动场景”相关选项。
构建时还要记得在“构建任务”里设置包名、应用名称、版本号。远程加载场景时,版本号和 bundle 的配置加载没有直接关系,但本地缓存策略需要靠 URL 里的版本目录区分,这点后面单独说。
4.2 bundle 文件夹的三个参数:名称、压缩、目标平台
把subgames/subgame1勾选为 bundle 后,属性检查器里会出现几个关键项:
- Bundle 名称:构建后的目录名,也是
loadBundle时填的标识。 - 压缩类型:默认为 non,可选 jpg、png、webp 等。这里不是通用压缩,而是纹理压缩格式;如果子游戏有透明 UI,建议别乱选,容易出黑底。
- 目标平台:默认对所有平台生效;如果只想给某个平台打这个 bundle,就勾选限定平台。
我在 demo 里通常设置bundleNameDistance: 'subgame1',构建输出是assets/subgame1/这样的结构。这里的名称一旦定了,后续代码里到处引用就不要再去改目录名。改名的代价是:所有loadBundle入口、配置表、版本管理脚本全部要一起改。建议在工程根目录建一个bundle-config.json,把 bundle 名称集中管理起来:
{ "subgames": [ { "bundleName": "subgame1", "entryScene": "subgame1/scenes/GameMain", "remoteUrl": "1.0.0" }, { "bundleName": "subgame2", "entryScene": "subgame2/scenes/GameMain", "remoteUrl": "1.0.0" } ] }这个 json 不是引擎要求的功能,是工程约定。因为 Creator 没有内置“大厅到子游戏的入口表”,如果不做一张表,后续新增子游戏时就要改大厅代码,非常不工程化。把入口配置独立出来,等于给大厅留了一个“随时可以加子项目”的位置。
4.3 远程包路径:目录版本号比文件名散落更靠谱
如果子游戏包放进远程服务器,最简单的做法是把 bundle 目录放到带版本号的目录下:
remote/ 1.0.0/ subgame1/ config.json index.js ... 1.0.1/ subgame1/ config.json index.js ...大厅加载远程 bundle 时,URL 指向https://你的域名/remote/1.0.0/subgame1。每次子游戏更新,只上传新版本目录,并让大厅配置文件里的 version 指向新路径。不推荐覆盖同一个目录,因为客户端缓存会让旧版本文件被错误复用,出现各种难排查的资源和代码不一致。
构建时还要留意目标平台。如果构建的是 web 平台,远程 bundle 的跨域取决于服务器 CORS 配置;如果构建的是原生平台,本地文件系统路径和远程路径的拼接规则不同。原生平台下loadBundle的 URL 参数建议用完整协议头,否则可能按本地路径解析。我见过不少项目在浏览器里调试通过,打 Android 包后远程 bundle 加载不出来,多数就是 URL 少写了协议头。
5. 避坑:大厅 + 子项目落地时的五个真实血泪经验
5.1 坑一:子游戏资源进了主包,首包体积完全没降
现象:构建后主包和“不拆包”时几乎一样大,子游戏 bundle 存在但很小。
原因:子游戏目录里的资源被大厅场景或其他公共资源引用了。Cocos Creator 构建时默认的处理逻辑是,被多个 bundle 引用的资源可能被提升到主包,这样会导致拆包失去意义。常见元凶是子游戏里的公共图集、公共脚本、共享 prefab,混放到了大厅资源目录。
解决:把子游戏目录当作一个“黑匣子”,外部不要直接引用它里面的任何资源。实在需要共用的 UI 组件或工具函数,必须复制一份到公共目录,或者提取成 npm 模块。每次构建后检查“构建发布”面板里的日志,看主包大小变化,如果明显异常就去查资源依赖。
5.2 坑二:多个子游戏共用同名 prefab 或场景,加载时场景引用被彼此覆盖
现象:先打开子游戏 A,退出后打开子游戏 B,结果加载的是 A 的资源配置。
原因:两个 bundle 里有同名的资源和脚本,引擎在缓存查找时出现歧义。比如都有assets/resources/Prefabs/Item.prefab,但因为不是同一个 bundle,所有引用在最终构建时可能出现 UUID 冲突。
解决:给子游戏目录加唯一前缀命名,比如subgame1_、subgame2_,文件名也尽量带上标识。关键资源不要使用resources目录名,因为resources是全局资源目录,进构建时会被主包处理掉,bundle 隔离失效。我在代码里统一用bundle.get(path)来取资源,避免 Copy 资源以后路径混乱。
5.3 坑三:返回大厅黑屏或白屏,但控制台没有报错
现象:子游戏切换到大厅场景时,只有 UI 正常,3D 节点全部消失,或者整个画面变黑。
原因:最常见的是场景切换后,某个节点引用了子游戏 bundle 里的材质或纹理,而该资源被释放了。场景切换回大厅后,渲染器还在尝试绘制旧资源,但资源已经销毁,导致黑屏。
解决:在返回大厅时,先不要马上释放 bundle,给一帧渲染缓冲时间。另外检查大厅场景有没有定义“常驻节点”,如果大厅组件持有子游戏创建的节点引用,那么释放时会出现悬挂引用。我习惯是在场景切换完成后,用director.getScene()重新拿到当前场景的根节点,确保引用的不再是旧场景内容。
5.4 坑四:子游戏脚本编译报错,提示找不到公共类
现象:子游戏代码里引用了大厅的公共方法,本地编辑器运行正常,构建后子游戏 bundle 单独运行时报错。
原因:子游戏 bundle 构建时会把引用的公共代码一起打包进去,但代码路径和公共代码实际所在位置不一致,尤其在多工程开发的时候。另一个常见情况是大厅公共代码在子游戏工程里不存在,导致构建某个模块时直接找不到符号。
解决:让子游戏只依赖一个稳定的接口层,这个接口层由大厅提供,子游戏里用声明文件或最小 stub 类代替,不要直接引用大厅内部实现。如果实在绕不开,最简单的处理是把公共逻辑放在大厅包的全局window或globalThis上,子游戏运行时动态获取。
5.5 坑五:远程 bundle 加载不报错也不触发回调
现象:loadBundle的 success 回调没走,error 也没走,整个流程悬停。
原因:远程服务器返回了非 JSON 内容,或者 URL 写到了config.json本身,导致引擎解析 bundle 配置失败。因为配置解析是异步的,有时错误被吞在底层逻辑里,外部拿不到回调。
解决:先用浏览器直接访问https://你的域名/remote/1.0.0/subgame1/config.json,确认返回的是合法 JSON。然后检查 URL 是不是多了config.json后缀,或者少了目录分隔符。我在开发环境里通常准备一个本地 mock 服务器,把远程目录映射到本地静态文件,这样调试时很快能定位是网络问题还是配置问题。
6. 让大厅子游戏从 demo 长成生产骨架:每次只改一处就能加游戏
我目前习惯在组件里只做“分发动作”,真正的 bundle 清单全部交给配置表,这样以后每新增一个子游戏,不需要动大厅代码。大厅启动时先加载一份subgame_list.json:
[ { "id": "game1", "name": "消消乐", "bundleName": "sg_game1", "entry": "sg_game1/scenes/GameMain", "version": "1.0.0", "remoteUrl": "https://cdn.example.com/remote/sg_game1/1.0.0" }, { "id": "game2", "name": "斗地主", "bundleName": "sg_game2", "entry": "sg_game2/scenes/GameMain", "version": "1.2.0", "remoteUrl": "https://cdn.example.com/remote/sg_game2/1.2.0" } ]大厅拿到这个列表后,在 UI 上渲染出按钮列表,点击时直接按列表字段去加载。新增游戏 = 构建子游戏 -> 上传远程目录 -> 在列表 json 加一条记录。这里我保留了多年前端还是套壳开发留下的习惯:哪怕是一个 demo,也把配置和数据分离,否则每次改需求都要经过“翻代码 -> 改常量 -> 重新出包”这个过程,非常低效。
版本管控也要跟上。子游戏每个构建产出一个带版本号的目录,服务器保留两个版本即可,防止客户端缓存的旧版本拉不到资源。返程时把 URL 里的版本和version字段做对比,不需要整包热更,因为子游戏本身是 bundle,新版本直接替换远程文件,客户端下次加载自然获取新版。如果遇到“强制更新”需求,就在配置表里加一个"force": true字段,大厅加载时看到这个字段就提示玩家回大厅刷新列表。
我现在的习惯是:每次打出来的包,本地先起一个http-server,模拟远程目录访问一遍,确认能从大厅切到子游戏、能正常返回,然后才传服务器。这一步救过我很多次,因为在本地双击index.html和通过 http 服务访问是两码事,bundle 加载和场景切换都会受到协议影响。希望这套整合大厅和多个子项目的思路,能帮你把前期的坑跳过一大半。
本文还有配套的精品资源,点击获取