☰
CocosCreator大厅子游戏整合:从Demo到可维护架构
2026/10/7 23:01:31 网站建设 项目流程

简介:面向Cocos Creator开发者的大厅与多个子游戏整合项目笔记及示例Demo,适合需要实现模块化游戏大厅、独立热更子游戏或多模式游戏集合的开发者参考学习,也可作为团队内部分享与项目起步模板。压缩包共64个文件,约7.09MB,核心包含js逻辑脚本、json配置、meta资源声明与fire场景文件,另有png、jpg图片、ttf字体资源和一个bat辅助脚本,基本覆盖大厅入口、子游戏场景切换、动态加载、热更新配置和资源管理等模块。已有943人学习或下载,文件目录结构清晰,便于对照源码笔记逐段阅读。通过学习该Demo,可以了解如何将独立子游戏按需打入整包,掌握项目结构设计、模块间导航切换、服务器更新接口组织、性能优化与调试发布等关键知识点,同时理解资源预加载与内存管理策略,掌握Cocos Creator打包发布流程中针对不同平台的配置要点,减少多子游戏项目从零搭建时的踩坑成本。

1. 大厅子游戏整合:一个demo容易,一套架构难

如果你的项目叫“cocosCreator大厅子游戏笔记demo”,那你多半已经看过不少类似的代码包:大厅一个场景,几个子游戏各占一个场景,点击按钮就能跳过去。demo做得再完整,也只是把“能跑”这件事证明了一遍。但真正放到线上,你会发现脱离demo形态的整合才是大头——包体怎么拆、子游戏怎么进怎么退、资源什么时候该释放、代码怎么不互相污染,随便一个问题都能让项目卡在测试阶段两三周。这篇笔记就是围绕“大厅+多子项目”的整合方案写的,面向的是需要把这个demo变成一个可维护、可上线的业务框架的开发者,不看广告,只看落地路径。

2. 目录与资源规划:把 demo 打磨成可拆分架构的关键一仗

2.1 先说清一个容易被忽略的前提:包体是分“层”的

很多做大厅整合的人第一反应是“我把所有子游戏场景都放进包里,做热更时不就好了”。这个思路没有错,但它回避了一个核心问题:首包到底想做到多大。子游戏一旦多起来,美术资源、ui图集、动画序列都是吃包体的猛兽。你把它们全塞进首包,启动下载时长会直接劝退一批用户。

CocosCreator 处理这类问题的标准姿势是 Bundle(资源分包)。Bundle 本身不是新概念,你可以把它理解成“可以单独加载、单独释放的资源集合”。大厅是一个 Bundle,每个子游戏是一个独立 Bundle,公共依赖放进 resources 或另一个共享 Bundle。启动时只加载大厅 Bundle,子游戏 Bundle 在点击入口时才拉取。这样首包体积能压到只包含大厅界面和几个必要入口图,子游戏资源按需走远程加载或后续热更新,体验上接近“点开即玩”。

目录结构上我习惯按“框架、入口、子游戏”三层来分:

assets/ bundles/ hallBundle/ scenes/ // 大厅场景 prefabs/ // 大厅ui、按钮、弹窗 scripts/ // 只属于大厅的逻辑 gameABundle/ scenes/ prefabs/ scripts/ gameBBundle/ scenes/ prefabs/ scripts/ resources/ commonAssets/ // 公共图集、公共音效、通用字体 scripts/ framework/ // 加载、注册、生命周期管理框架代码 shared/ // 所有子游戏共用的纯逻辑代码

这里有个容易被新手忽略的点:scripts/framework和scripts/shared千万不要塞进某个子 Bundle 里,否则会导致公共代码被每个子游戏各打一份,构建产物变大且逻辑分叉。框架代码应该编译进主包或独立 Bundle,子游戏 Bundle 只放自己的资源与局部脚本。

2.2 Bundle 划分与首包控制的三个可执行参数

把资源分包拆到哪个粒度,要考虑加载时间、内存占比和构建配置三个维度。太小了会造成大量小文件请求,太大会失去按需加载的意义。我的常用经验值供参考:

  • 一个子游戏的 Bundle 控制在 2–5 MB(压缩前),达到这个量级就值得继续拆内部资源
  • 首包只顾大厅自身,公共依赖尽量打成一个共享 Bundle
  • 子游戏之间完全不共享的美术资源,一定不允许互相引用,否则构建时会被重复打进两个 Bundle

构建层面在 CocosCreator 的构建发布面板里,每个文件夹被标记为 Bundle 后,构建时会在"配置"中自动生成对应的 Bundle 配置。你需要检查一下当前项目的“构建发布 → 浏览器/原生平台 → Bundle 配置”面板,确认每个 Bundle 的“压缩类型”是不是符合预期。一般 js 文件选merge_dep,图片音频选默认压缩,不要自己乱改格式,不然会出现资源能加载但解析异常的怪问题。

2.3 子游戏的注册表:一份 JSON 定清楚所有元信息

当子游戏数量变多,最忌讳的是把入口按钮写死在代码里。加一个子游戏就要改大厅逻辑,再发布一版大厅,这违背了“大厅稳定、子游戏可加”的初衷。我一般会维护一份subgame_config.json放进 resources 或远程配置地址,格式大致如下:

{ "games": [ { "id": "101", "name": "捕鱼达人", "version": "1.0.0", "bundleName": "gameABundle", "scenePath": "scenes/GameA", "icon": "textures/icons/fishing", "isHotUpdate": true }, { "id": "102", "name": "消消乐", "version": "1.2.1", "bundleName": "gameBBundle", "scenePath": "scenes/GameB", "icon": "textures/icons/eliminate", "isHotUpdate": false } ] }

这段 JSON 的解析逻辑不复杂,但它解决的问题非常大:新增子游戏时不需要修改大厅代码,只需在配置里加一条,通过网络下发即可。bundleName对应构建中的 Bundle 名,scenePath是子游戏内场景的路径,isHotUpdate字段用来区分当前子游戏是走首包、远程 Bundle 还是后续热更补丁。注意,version最好与子游戏资源版本号联动,否则会出现老客户端加载新资源导致字段不匹配的问题。

2.4 公共依赖与去重:不把框架代码打进子游戏

CocosCreator 构建时有一个容易踩的配置项:如果你的子 Bundle 脚本引用了公共目录里的类或组件,构建工具在打包时可能自动把相关代码拷贝进子 Bundle。你会在构建后看到子游戏包体里出现一堆你根本没写过的框架代码,这正是公共代码没有独立成 Bundle 的结果。

框架层建议做成一个frameworkBundle,所有子游戏在加载时先依赖主包或框架包,代码引用保持在运行时解析,构建配置里给框架包勾选“不合并进其他 Bundle”的选项(不同版本选项名称略有差异,但思路一致:让公共代码只出现一次)。如果你发现子 Bundle 的构建日志里出现了scripts/framework相关文件,说明依赖解析被打断了,要回过去检查是否有代码直接 import 了框架路径。正确做法是子游戏代码通过全局入口或事件总线访问框架能力,而不是 import 具体类。

3. 整合框架落地:拿到一份最小可跑的 CocosCreator 大厅代码

3.1 框架代码的四个核心接口

底层设计不需要过度抽象,但至少要保证四个能力:加载 Bundle、加载场景、注册子游戏生命周期、释放子游戏。下面这份精简版框架代码是常见做法,你可以直接抄去改造成自己的工程:

// framework/SubgameManager.ts import { AssetManager, director, Director, Scene, warn } from 'cc'; export interface ISubgameContext { gameId: string; onEnter(params?: Record<string, unknown>): void; onExit(): void; } export class SubgameManager { private static _instance: SubgameManager; static get instance(): SubgameManager { if (!this._instance) { this._instance = new SubgameManager(); } return this._instance; } private _currentGame: ISubgameContext | null = null; async enterGame(config: { bundleName: string; scenePath: string; params?: Record<string, unknown> }): Promise<void> { // 1. 先退出当前子游戏,保证同一时刻只有一个子游戏活跃 await this.exitCurrentGame(); // 2. 加载子游戏 Bundle const bundle = await this.loadBundle(config.bundleName); if (!bundle) { throw new Error(`[SubgameManager] bundle 加载失败: ${config.bundleName}`); } // 3. 通过 scenePath 实例化子游戏场景 const scene = await this.loadSceneFromBundle(bundle, config.scenePath); // 4. 通知子游戏进入,并传入参数 const subgameRoot = scene.getChildByName('SubgameRoot'); if (subgameRoot) { this._currentGame = subgameRoot.getComponent('GameEntry') as unknown as ISubgameContext; if (this._currentGame) { this._currentGame.onEnter(config.params); } } } private loadBundle(name: string): Promise<AssetManager.Bundle> { return new Promise((resolve, reject) => { assetManager.loadBundle(name, (err, bundle) => { if (err) { reject(err); } else { resolve(bundle); } }); }); } }

这段代码解决的是“统一入口”问题:大厅只需要调用SubgameManager.instance.enterGame(config),不用关心具体子游戏里有哪些脚本。注意ISubgameContext接口约定了onEnter和onExit,子游戏根节点上的脚本实现这两个方法即可被框架管理。

3.2 场景实例化与回调丢失的隐患

上面代码里我用了loadSceneFromBundle,这个函数需要你自己实现,因为director.loadScene默认只能加载当前工程场景列表里的场景,不一定能直接加载 Bundle 内场景。CocosCreator 3.x 中,Bundle 内场景可以通过资源类型Scene加载后手动切换,常见写法如下:

// framework/sceneLoader.ts import { AssetManager, director, SceneAsset, instantiate, Node, find } from 'cc'; export function loadSceneFromBundle(bundle: AssetManager.Bundle, scenePath: string): Promise<Scene> { return new Promise((resolve, reject) => { bundle.load(`${scenePath}`, SceneAsset, (err, sceneAsset) => { if (err) { reject(err); return; } const scene = instantiate(sceneAsset.scene) as Scene; const canvas = find('Canvas'); if (canvas) { canvas.addChild(scene); } else { reject(new Error('当前场景没有 Canvas,请检查大厅场景结构')); } resolve(scene); }); }); }

这里有一个关键点:我没有使用director.loadScene,而是用instantiate把目标场景实例化成节点,挂载到大厅的 Canvas 下。这样做的好处是,大厅的根节点和常驻节点不会在切换时被销毁,返回大厅时只需要把子游戏节点从 Canvas 下移除即可,不用重新加载整个大厅场景,体验上“回去”很快。缺点是需要你自己管理场景内组件的onLoad和onDestroy调用时机,子游戏代码不能依赖场景切换带来的自动生命周期。

3.3 子游戏的 GameEntry 脚本约定

子游戏端需要做一个入口脚本,挂到子游戏场景的根节点上。以下是约定示例:

// gameABundle/scripts/GameEntry.ts import { Component, Node } from 'cc'; export class GameEntry extends Component { private _params: Record<string, unknown> | null = null; onEnter(params?: Record<string, unknown>): void { this._params = params; // 根据参数初始化子游戏,比如玩家 id、房间号 console.log('[GameEntry] 进入子游戏', this._params); } onExit(): void { // 保存进度、清理事件监听、停止定时器 console.log('[GameEntry] 退出子游戏'); } }

onEnter和onExit的调用时机要严格遵循框架层约定:进入新子游戏前先调用旧子游戏的onExit。这一步千万别省略,否则旧子游戏的全局事件监听会残留,导致新子游戏界面被旧逻辑干扰。把这两个方法当作子游戏与大门的契约,子游戏内部爱怎么改都行,大门只管这两个入口。

4. Bundle 动态加载与场景切换:资源、回调、进度都在这里排队

4.1 加载进度与失败重试的工程化做法

在真实的网络环境里,远程 Bundle 加载不会像本地那样瞬间完成。如果你只是调assetManager.loadBundle,用户点击子游戏入口后会有一个毫无反馈的等待期。需要给加载过程加进度条和超时重试。以下代码可以在enterGame之前插入进度事件。

// framework/SubgameManager.ts 增加进度回调 enterGameWithProgress( config: { bundleName: string; scenePath: string; params?: Record<string, unknown> }, onProgress: (percent: number) => void ): Promise<void> { return new Promise((resolve, reject) => { assetManager.loadBundle( config.bundleName, (finished: number, total: number) => { const percent = total > 0 ? finished / total : 0; onProgress(percent); }, (err, bundle) => { if (err) { reject(err); return; } // 继续执行场景加载... resolve(); } ); }); }

这里的重点是loadBundle的第二个回调参数(即进度回调)。在 CocosCreator 3.x 中它的签名与 2.x 不同,传入的finished和total单位是请求数量,不是字节数。所以进度条显示的百分比仅供参考,别拿来做精确到小数点后几位的特效。我一般会做一层“假进度”兜底:前 80% 走真实加载回调,剩余 20% 在加载完成后做场景实例化的过渡动画时补齐,避免用户看到进度条卡在 99% 不动。

4.2 远程 Bundle 与热更地址的参数匹配

assetManager.loadBundle默认从本地包体或已下载的热更目录读取。如果你把子游戏 Bundle 发到服务器,需要先配置远程包地址。CocosCreator 的assetManager有setBundleServer方法,或者在项目设置里配置“资源服务器地址”。常见做法是登录后向服务器拉取一份“资源版本表”,里面写明了每个 Bundle 的远端地址和版本号,客户端拿着这张表动态设置加载路径。

一个需要警惕的细节:版本表与客户端本地版本不一致时,如果你只是把自己实现的版本写进缓存并判断,会出现用户A能进子游戏、用户B不能进的诡异情况,而且往往只在特定机型上出现。这类问题的根因通常不是加载代码,而是地址拼接错误。建议把地址字段做成完整 URL,不要用相对路径拼字符串,否则服务器升级了端口或路径变更,老的缓存配置就会失效。排查时可以先打开浏览器的 Network 面板看加载请求的 URL 是否符合预期,排除地址拼接问题,再去查 JSON 配置。

4.3 卸载时机与内存峰值的“后悔药”

子游戏从大厅切换出去后,很多人会忘记释放 Bundle。CocosCreator 的assetManager.releaseBundle能释放整个 Bundle 的资源,但调用时必须保证该 Bundle 下没有任何场景节点还在使用资源,否则会报引用计数错误,甚至黑屏。

我的一般做法是三步:

  1. 从场景树上移除子游戏根节点,并在节点上调用node.destroy();
  2. 等一帧(scheduleOnce)后再调用bundle.releaseAll();
  3. 最后调用assetManager.releaseBundle(bundleName)。

第二步之所以要等一帧,是因为destroy()后资源引用计数不会立刻变为零,立即释放会导致组件回调还在执行时资源已经被移出内存。这个顺序问题我在原生端踩过两次,每次都表现为“子游戏退出后大厅点击无响应”或“界面纹理变成白色占位图”。

// 退出并释放 Bundle 的顺序控制 async exitAndRelease(bundleName: string): Promise<void> { const rootNode = this._currentSubgameRoot; if (rootNode) { rootNode.destroy(); } await this.waitForFrames(1); const bundle = assetManager.getBundle(bundleName); if (bundle) { bundle.releaseAll(); assetManager.releaseBundle(bundleName); } } private waitForFrames(n: number): Promise<void> { return new Promise((resolve) => { director.once(Director.EVENT_AFTER_UPDATE, () => resolve()); }); }

这里waitForFrames用Director.EVENT_AFTER_UPDATE事件来实现“下一帧再执行”的效果,比setTimeout更可靠。注意,如果你在微信小游戏平台,帧回调频率可能受前台后台切换影响,最好加一个最长时间限制,避免用户切后台再回到游戏时回调一直未触发。

4.4 生命周期状态机:防止子游戏互相覆盖

当用户点了一个子游戏,加载中又退出去点另一个子游戏,加载回调后却把两个子游戏都显示出来的怪事就可能出现。框架层需要一个简单的状态机来避免这种并发操作。

enum SubgameState { Idle = 'idle', Loading = 'loading', Running = 'running', Exiting = 'exiting' }
  • Idle:当前无子游戏活跃
  • Loading:正在加载子游戏资源,此时所有入口按钮应该被禁置或显示遮罩
  • Running:子游戏正常运行中
  • Exiting:正在退出和释放,此时不允许进入新游戏

在enterGame开头判断当前 state,如果不是 Idle 或 Running,直接拦截并返回。否则先置为 Loading。加载成功后状态变为 Running,退出时先置为 Exiting,完全释放后再回到 Idle。这个小状态机大约二十行代码,但能避免掉很多线上反馈“界面错乱”的疑难杂症。

5. 避坑:大厅整合子游戏的 5 个高发故障现场

5.1 场景加载完成后,回调里拿不到 Canvas

现象:子游戏场景通过instantiate挂到大厅 Canvas 下后,子游戏里某个组件的onLoad用find('Canvas')拿到的节点是对的,但下一行代码访问它的子节点时返回null。

原因:场景实例化的节点树在instantiate时已经完整,但各组件的onLoad执行顺序不一定保证父节点的子节点全部 mounted 完,尤其在挂载时机和addChild之间还有引擎内部的流程时。

解决:不要在子组件的onLoad里大胆假设整个 Canvas 下的兄弟节点已经就绪。改为在场景根节点挂一个GameEntry,由它主动在整个场景脚本start执行完后,再向子组件发事件或直接调用方法。如果不改架构,也可以用scheduleOnce延迟一帧执行初始化逻辑,但要注意微信小游戏上帧间隔不稳定,建议用“延迟一帧 + 判断非空”的双保险。

5.2 子游戏退出后,纹理和音频占用不释放,帧率回不来

现象:连续进出几个子游戏后,游戏帧率明显下降,内存面板一路走高。

原因:子游戏里的动态加载图片、音频通过resources.load或bundle.load加载后,没有被记录在退出节点的destroy流程中。如果这些资源是在脚本里直接赋给 SpriteFrame 或 AudioSource,引擎不会自动释放它们。

解决:在子游戏内统一用一个资源管理器,记录所有动态加载的资源,退出时批量release。不要在子游戏组件里散装处理资源。另外检查子游戏里是否有常驻单例节点(比如某个 manager 加到了persistRootNode),这个节点会让整个子游戏的核心逻辑永不销毁,等同于内存泄漏。我习惯在onExit里打印一份子游戏节点树快照,确认没有残留节点挂在persistRootNode下。

5.3 远程 Bundle 首次加载失败,用户卡在黑屏入口

现象:用户点击子游戏入口,进度条走了一小半就卡死,几秒后弹出加载失败提示。重试一次后又能进入。

原因:服务器 CDN 某些节点首次连接慢,或超时时间设置太短,或loadBundle失败后没有自动重试机制。如果错误码是资源 404,多半是远程路径版本号没对上。

解决:失败时不要直接提示用户“网络错误”,先做一次静默重试,第二次失败才弹窗。重试地址建议拼上版本号参数,例如https://yourcdn.com/bundles/gameABundle?v=101,这样能绕过 CDN 的过期缓存。另外把超时时间从默认的 10 秒往上调,移动网络下首次建立连接经常超过 15 秒。如果项目是大厅制,建议在大厅进入时就预下载热门子游戏 Bundle,用户点击时走本地加载,成功率会好看很多。

5.4 构建产物里出现重复的公共代码

现象:构建后检查子 Bundle 文件,发现里面混入了framework的 js 代码,包体比预期大了 40%。

原因:子游戏场景中直接挂载了来自framework目录组件的 Prefab,构建时依赖分析扫描到该组件,就把相关框架代码连带打进子 Bundle。

解决:回到目录规划那一节,把框架代码打包成独立的 frameworkBundle,并在子游戏构建配置的“排除”列表里显式排除框架路径。更彻底的做法是子游戏内不允许出现任何对框架目录的静态引用,所有框架访问都通过全局单例接口。遇到这类问题,检查构建日志里是否有 “WARNING: asset ... is referenced by multiple bundles” 的提示,有就说明依赖没有理清。

5.5 子游戏代码里出现“找不到类”的报错,但编辑器预览正常

现象:编辑器模式一切正常,构建到小游戏或原生包后,某个子游戏场景打开时控制台报 “Can not find class GameEntry”,但代码明明已经写好了。

原因:CocosCreator 移除了某些构建模式下的非场景引用脚本,如果GameEntry没有在场景中被引用,构建过程不会把它打进包内。另一种常见场景是脚本放在子 Bundle 的根目录下。

解决:在子 Bundle 的入口场景里显式把入口脚本挂到根节点上,这样构建器会保留它。如果不想挂载,可以在settings.py或构建插件里手动添加一个包含该脚本的“启动场景”。还有一种是直接把脚本目录放到构建配置的“包含脚本”列表里。这个坑在微信小游戏和原生平台高发,PC 预览反而看不到,建议每次发布前在目标端真机自测一次入口场景。

6. 进阶:把整合做成可维护的长期手艺

当你把“能跑”的 demo 变成真正的主宿结构后,会发现日常开发里反复做的事变成了三类:加子游戏、改子游戏、调加载策略。这里我整理了一个自用的整合验收清单,每次发布前过一遍,能省掉大量线上返工。

第一,检查大厅入口配置是否来自远端,并且有本地缓存兜底。大厅 UI 要在断网时给用户一个可点击的“刷新”按钮,而不是一个空白的子游戏列表。第二,子游戏包体大小和加载时间要形成报表,每次发新版前对比上一次数据,如果某个子游戏包体膨胀超过 30%,先查是不是公共资源被打进了子 Bundle。第三,退出流程要自动化测试。每进出一次子游戏,记录内存峰值和场景节点数,如果节点数没有回到进游戏前的水平,说明存在节点残留。我用的办法是进出一个子游戏后立即打印director.getScene().children.length,对比基准值,超过基准值 3 个以上就要查树结构。

关于调试技巧,我会把 SubgameManager 的每次enterGame、exitGame、loadBundle失败都加一条带SUB前缀的日志。线上用户反馈问题时,只要把客户端日志拉回来,就能靠日志串出完整的子游戏切换链路,定位到具体的加载阶段。这比让用户描述“点了没反应”高效得多。框架层不要舍不得打日志,真正的整合问题大多发生在流程时序上,有日志才有判断依据。

另外一个长期维护的经验:子游戏的入口节点一定不要用固定名称去find,比如SubgameRoot。一旦子游戏多了,某个子游戏改过界面层次结构,就可能导致其他子游戏拿到错误节点。我后来改成根据GameEntry组件来查找子游戏根节点,而不依赖节点的名字。这个改动很小,却让我少接了几次线上问题排查。

最后说一点习惯上的事:我每次接到一个新子游戏,都会先花半小时看它的事件监听、定时器和场景节点挂在谁上面,而不是直接看它好不好玩。整合项目里,子游戏的质量很大程度体现在它退出时是不是把东西都带走。这个习惯帮我避开了很多后期重构的麻烦。希望这篇笔记里提到的方案和坑,能让你在这个方向上少走几趟弯路,也希望你能在大厅框架里做出属于自己顺手的工具箱,祝顺利。

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

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

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

立即咨询