Cocos Creator场景切换实战:核心API与避坑指南
2026/9/18 19:24:28 网站建设 项目流程

做游戏开发绕不开的一件事,就是场景切换。我在做一款剧情养成类视觉小说《卷王与摆烂系统》的时候,第一版图省事,把所有界面全塞进一个场景里,结果节点树越来越庞杂,UI互相遮挡,调试图层时视野一团乱,甚至改一个按钮的层级都要在几百个节点里翻半天。后来我老老实实把项目重构成多场景结构,用 Cocos Creator 创建多个场景,再通过代码调用和事件绑定来完成切换,整个项目才真正顺了起来。这篇博文就是把整个流程、核心API用法和我踩过的坑一起写出来,适合刚接触 Cocos Creator,想做小游戏、剧情类项目或者任何需要“分场景管理”的游戏的新手。

1. 创建多场景前,先想清楚场景拆分的逻辑

1.1 场景是游戏世界的“一幕戏”

在 Cocos Creator 里,场景(Scene)就是一个可编辑、可独立加载的节点容器。你可以把它理解成舞台剧的一“幕”:一开幕,舞台上摆好了背景、角色、灯光,玩家在有限时间里只和这幕戏交互;演完闭幕,舞台清空,换下一幕。

每一幕戏对应一个.scene文件,里面挂着这幕戏的所有节点和组件:摄像机、背景、UI、角色、逻辑脚本,全都在这一棵场景节点树里。当游戏运行到某个场景时,引擎会加载这个场景文件,把整棵节点树实例化出来,再依次回调节点上脚本的onLoadonEnablestart等生命周期函数。

场景切换本质上是“先卸载旧的,再安装新的”。旧场景里除常驻节点外的所有节点会被销毁,新场景重新构建。也就是说,切场景不是简单地隐藏一个界面、打开另一个界面,而是一场完整的“资源替换 + 内存释放”。理解这一点,你就会明白为什么不能把所有页面都塞进一个场景里——节点脏、资源堆积、内存压力大,而且每个界面的初始化时机都会被拖慢。

1.2 哪些内容适合拆成独立场景

这里给一个我常用的判断规则:是否“玩法形态”或“资源集合”发生了重大变化。

适合拆场景的典型场景:

  • 主菜单 / 设置界面 / 关卡选择:界面形态完全不同,不需要跟战斗场景共享一大堆运行状态。
  • 战斗场景 / 剧情场景 / 大地图探索:资源差异大,战斗要加载特效、音效、怪物配置;剧情要加载对话立绘、背景图、BGM。放一起会让首屏加载负担很重。
  • 需要明确释放内存的模块:比如进入一段长剧情后,上一关的地图资源不再需要,切场景后引擎能帮你回收。
  • 团队并行开发:程序A做主菜单,程序B做战斗,各改各的场景文件,合并时冲突概率低得多。

不太适合拆成独立场景的:

  • 弹窗、背包、设置面板:这类 UI 层用节点的显隐、层级调整就能完成,拆成场景反而会让“从哪打开到哪关闭”的逻辑变得笨重,还要额外处理跨场景数据。
  • 同一玩法内的轻微界面变化:比如战斗里血条样式调整、技能栏展开收缩,用节点状态切换更轻量。

我自己的视觉小说项目就按“Boot(启动)→ MainMenu(主菜单)→ Story(剧情)→ End(结局)”拆了四个场景。启动场景只负责显示一个 Logo 和版本号,给后续场景加载留出过渡时间;主菜单负责继续游戏、新游戏、设置;剧情场景承载对话、选项和演出;结局场景负责结算与存档清理。

1.3 场景切换时的生命周期与数据边界

切场景时有个很容易被忽略的点:场景内所有非持久化节点都会被销毁,它们的onDestroy会被触发。如果某个全局管理器只是挂在场景节点上,又没有做常驻处理,切一次场景就重置一次,你的玩家分数、背包、剧情进度全会飞掉。

Cocos Creator 提供的方案是“常驻节点”:

director.addPersistRootNode(this.node); // 切场景后,这个节点不会被销毁

但常驻节点也有代价:场景切换后不会重新执行onLoad,它只初始化一次。所以常驻节点适合放跨场景共享的数据中心和事件总线,不适合放依赖当前场景节点树的具体功能。

我在项目里还会画一张简单的场景状态表,把每个场景的入口、出口、需要的初始数据、要销毁的临时资源列清楚。这样切换逻辑写起来才不会一团浆糊。

2. 实操准备:创建项目和多个场景

2.1 新建工程与第一个场景

Cocos Creator 从 3.x 开始,项目结构比较清晰。打开 Dashboard,选择新建项目,选一个空白模板或基础模板即可。引擎会默认生成一个assets文件夹,里面通常有一个Scene或者scenes文件夹,初始场景一般叫main.scene或类似名字。

我建议拿到新项目的第一件事,就是在assets下建立一套自己的目录结构,别让所有资源堆在根目录:

assets/ ├── scenes/ # 所有场景文件 ├── scripts/ # 所有 TypeScript 脚本 ├── resources/ # 动态加载用的资源 ├── prefabs/ # 预制体 ├── textures/ # 图片资源 └── audio/ # 音频资源

虽然 Cocos Creator 对目录不敏感,但清晰的结构能让你后期找资源少花一半时间。尤其场景文件多了以后,靠文件名检索会越来越痛苦。

2.2 添加第二个、第三个场景并做标识

创建新场景很简单:在assets/scenes文件夹上右键,选择Create -> Scene,命名即可。比如我要做四个场景,就建四个:

  • Boot.scene
  • MainMenu.scene
  • Story.scene
  • End.scene

双击每个场景进入编辑后,场景里默认会有一个 Main Camera 节点。我习惯在每个场景里加一个简单的 UI 标识:创建一个 Canvas 节点,在 Canvas 下再放一个 Label,文字写上场景名。这一步看似多余,但对新手测试阶段特别有用——你切过去以后至少能第一时间确认“我没走错片场”。

场景文件创建完记得Ctrl + S保存。Cocos 的场景文件本质上是一份序列化的 JSON 数据,保存就是把当前编辑器里所有节点和组件信息写到.scene文件里。如果不保存,编辑器里的修改不会写入文件。

2.3 设置入口场景与构建场景列表

多场景建好之后,有一个非常关键的配置:入口场景和参与构建的场景列表。

在 Cocos Creator 3.x 中,打开顶部菜单“项目”里的“项目设置”,或者在“构建发布”面板里能看到场景列表。你需要指定“启动场景”,也就是游戏启动后第一个加载的场景。我通常把Boot.scene设为启动场景,让它在加载完成后快速跳转到主菜单,同时预加载后续场景资源。

构建发布面板里还有一个容易忽略的地方:它只会打包你勾选过的场景。如果你的新场景没勾选,Android 包打出来之后切场景就会报“场景不存在”。每次加完新场景,记得去构建面板确认一下。

我遇到过一次比较尴尬的情况:在自己电脑上运行代码时切场景一切正常,打包到手机后再点按钮就黑屏,排查半天才发现是构建面板里漏勾了新增的Story.scene。这个坑对新手来说很隐蔽,先记在心里。

3. 代码切换场景:核心API与数据保存

3.1 director.loadScene 一行代码切换

代码切换场景的核心 API 是director.loadScene。它属于引擎的导演类director,调用后引擎会释放当前场景,并加载目标场景。

最简单的用法:

import { _decorator, Component, director } from 'cc'; const { ccclass } = _decorator; @ccclass('GameFlow') export class GameFlow extends Component { start() { // 延迟 2 秒后进入 MainMenu 场景 this.scheduleOnce(() => { director.loadScene('MainMenu'); }, 2); } }

这里要注意loadScene接收的参数是场景的文件名(不含扩展名),不是场景显示名,也不是文件路径。比如MainMenu.scene就传'MainMenu'

如果你想在加载完成后再做一些处理,可以传第二个参数作为回调:

director.loadScene('Story', () => { console.log('Story 场景加载完成'); // 这里可以做加载完成后的初始化 });

loadScene是同步触发的,但实际加载过程是异步的。不要在调用loadScene的下一行就去访问新场景里的节点,那时候场景还没构建好,会拿到空引用。正确的做法是把后续逻辑放到回调里,或者在新场景中某个脚本的onLoad/start里执行。

3.2 预加载场景,告别切场的白屏等待

新手做切场景最容易遇到的问题是:直接loadScene过去,那个瞬间画面会卡一下,如果场景资源大,甚至会出现几秒黑屏。原因很简单——调用loadScene时才去硬盘/包体里读资源、解密、实例化,这段时间玩家只能看到一片空白。

解决办法是用director.preloadScene提前把资源加载进缓存:

import { director } from 'cc'; // 游戏启动阶段就预加载 Story 场景 director.preloadScene('Story', (completedCount, totalCount) => { const progress = completedCount / totalCount; console.log(`预加载进度:${Math.floor(progress * 100)}%`); });

预加载完成后,再调用director.loadScene('Story')时,目标场景的资源基本已经在缓存里,切换速度会快很多。

我的习惯是分步预加载:主菜单加载后立刻预加载剧情场景,剧情场景播放开头演出时再预加载结局场景。这样玩家观感上几乎感受不到加载等待。当然预加载也有代价,会占用额外内存,所以不要一股脑把后续所有场景都预加载了,按“下一步很可能去的地方”来预加载就行。

如果你要做真正的加载进度条,通常需要一个独立的 Loading 场景。流程是:启动场景 → 触发预加载和loadScene(Loading)→ Loading 场景里显示进度条和提示 → 下载/加载完毕后再跳转目标场景。这也是成熟商业项目的常见方案。

3.3 跨场景数据传递三件套

场景一多,跨场景数据就成了绕不开的问题。比如我的视觉小说项目里,玩家的“卷王值”“摆烂值”、当前剧情章节、已解锁结局,都必须穿过多个场景而不丢失。我总结了三套方案,各有适用场景:

方案一:全局单例(静态类)

不需要挂载到节点上,直接在 TypeScript 模块里定义:

export class GameState { private static _instance: GameState; public static get instance(): GameState { if (!this._instance) { this._instance = new GameState(); } return this._instance; } public score: number = 0; public chapter: number = 1; public unlockedEndings: string[] = []; }

任何场景都能直接GameState.instance.score读写。缺点是没有生命周期管理,如果项目很大、数据类型复杂,容易变成一团“全局垃圾场”。适合中小项目。

方案二:常驻节点

把挂有数据管理器脚本的节点设为常驻,切场景不会被销毁:

// 在某个启动脚本里 const dataNode = new Node('DataManager'); dataNode.addComponent(DataManager); director.addPersistRootNode(dataNode);

这样DataManager组件里的@property变量就能跨场景保存。适合需要绑组件、需要被多个场景里的 UI 节点通过 Inspector 引用的数据。

方案三:本地存储

import { sys } from 'cc'; // 写入 sys.localStorage.setItem('player_score', '100'); // 读取 const score = Number(sys.localStorage.getItem('player_score'));

适合保存进度、存档、设置项。注意localStorage存的是字符串,复杂结构要JSON.stringify/JSON.parse

方案数据存活范围适合场景缺点
全局单例整个运行期运行中的临时数据无生命周期管理,大项目易乱
常驻节点整个运行期需要组件绑定、跨场景引用节点销毁要手动处理
localStorage跨多次启动存档、设置、断点续玩不适合频繁写入的临时数据

我三个方案都在用:运行中的状态用全局单例,场景间共享的 UI 数据用常驻节点,存档落盘用 localStorage。

4. 事件绑定:按钮点击、代码监听与自定义事件

4.1 用Button组件的Click Events可视化绑定

Cocos Creator 最常用的场景切换触发方式就是按钮点击。在编辑器里可视化绑定,适合不太想写代码的人,也适合快速原型验证。

步骤是这样的:

  1. 在场景里创建一个节点,添加Sprite并放一张图片,或者直接用内置的 Button 模板节点(右键Create -> UI -> Button)。
  2. 给这个节点挂一个你已经写好的脚本组件(比如SceneSwitcher)。
  3. 选中按钮节点,在 Button 组件属性面板里找到Click Events
  4. 点击加号新增一条事件,把 Target 拖到挂载脚本的节点上,Component 下拉选择脚本组件,Handler 下拉选择要调用的方法。
  5. 如果有需要,可以在CustomEventData里填一个字符串参数,比如目标场景名'Story'

这种可视化绑定的好处是直观,但要注意两个限制:回调目标必须是一个组件上的公开方法,参数只能传字符串/数字/布尔等简单类型。比如你可以传'Story'作为场景名,但不能传对象或数组。对于复杂参数,还是得走代码绑定。

4.2 代码监听点击事件,适配动态生成的UI

如果一个按钮是通过代码动态创建的,或者你在列表里循环生成了几十个按钮,再逐个去 Inspector 里绑定事件就太痛苦了。这时候用代码监听更高效。

Cocos Creator 3.x 里有两种常见的点击监听:

监听 Button 组件的 CLICK 事件:

import { Button } from 'cc'; const button = this.node.getComponent(Button); button?.node.on(Button.EventType.CLICK, this.onClick, this); private onClick() { director.loadScene('Story'); }

监听节点自身的触摸事件:

import { Node } from 'cc'; this.node.on(Node.EventType.TOUCH_END, this.onTouchEnd, this); private onTouchEnd() { director.loadScene('Story'); }

两者最大的区别是:Button 的 CLICK 事件会做点击区域检测、状态切换(按下/释放),响应的是完整的“按钮点击语义”;而 Node 的 TOUCH_END 是纯触摸事件,只要你在这个节点区域内松手就会触发,哪怕没有 Button 组件。如果你的节点本身不是 Button,但想当按钮用,监听触摸事件也能实现。

这里有个细节:Button 的 CLICK 在触摸结束后才会触发,如果你希望“按下立即跳转”,触摸事件的响应更及时,但容易误触。游戏 UI 通常用 CLICK,因为玩家一旦手指滑出按钮区域,通常不希望触发操作。

动态创建按钮时,我会把场景名通过闭包传进去:

const btnNode = new Node('Button'); btnNode.addComponent(Button); btnNode.on(Button.EventType.CLICK, () => { director.loadScene(sceneName); // sceneName 是循环变量 }, this);

但要注意,如果监听回调用了匿名函数,场景切换后如果节点被销毁,这个匿名回调无法正常解绑,可能引发内存泄漏。更稳妥的做法是命名函数,或者在节点销毁时手动off

4.3 解耦利器:自定义事件触发场景切换

实际项目里,按钮和最终执行动作的代码往往不在同一个组件里。比如按钮在 UI 层,场景切换逻辑在流程控制层,中间可能隔着好几个组件。如果把按钮直接director.loadScene,一旦某天你想改成先弹确认框再切场景,就得改一堆按钮代码。

这时自定义事件系统就派上用场了。Cocos Creator 3.x 里可以直接用EventTarget实现一个轻量的事件总线:

import { EventTarget } from 'cc'; export const gameEvents = new EventTarget(); // 发送事件 gameEvents.emit('SWITCH_SCENE', 'Story'); // 监听事件 gameEvents.on('SWITCH_SCENE', (sceneName: string) => { director.loadScene(sceneName); }, this);

用事件总线的核心价值是解耦。UI 按钮只负责发一个“用户想切场景”的意图,具体切到哪、怎么切、要不要做安全校验,全部由流程控制层决定。这样按钮组件可以在多个项目里复用,逻辑也更清晰。

使用时要注意onoff必须成对出现。如果场景里某个组件监听了全局事件,场景销毁后这个监听还挂在gameEvents上,下次切换场景时就会重复触发回调。标准做法是在onDestroyoff掉:

protected onDestroy(): void { gameEvents.off('SWITCH_SCENE', this._handleSwitchScene, this); }

我最早就是因为没做off,剧情场景切回主菜单再切回来,同一个按钮会同时触发两次场景跳转,导致直接跳到了下一个场景,排查了很久才发现是事件重复注册的问题。这个坑,新手十有八九会遇到。

5. 一个完整的实例:剧情项目里的场景切换设计

5.1 规划场景路径

回到我的《卷王与摆烂系统》项目。这是一款剧情养成类视觉小说,玩家的核心体验是“选择行为 → 积累数值 → 触发不同剧情分支 → 走向不同结局”。整个项目被设计成四个场景:

场景名职责进入方式出口
Boot启动引导、显示Logo、预加载资源游戏启动自动切 MainMenu
MainMenu主菜单:开始新游戏、继续游戏、设置Boot 自动跳转按钮事件切 Story
Story剧情演出、选项分支、数值变化主菜单点击开始剧情结束切 End
End结局展示、结局解锁记录剧情结束后跳转按钮回主菜单

Boot 场景挂一个简单的启动脚本,显示 Logo 的同时预加载 MainMenu 和 Story 场景,加载完再切 MainMenu。这样玩家从启动图标到进入主菜单,几乎感觉不到加载间隙。

5.2 把切换逻辑封装成通用组件

为了不让每个场景按钮都写一遍director.loadScene,我封装了一个SceneSwitcher组件,挂在任意按钮节点上,在 Inspector 里填目标场景名即可:

import { _decorator, Component, Button, director } from 'cc'; const { ccclass, property } = _decorator; @ccclass('SceneSwitcher') export class SceneSwitcher extends Component { @property public targetScene: string = ''; private _isSwitching = false; protected onLoad(): void { const button = this.getComponent(Button); if (button) { button.node.on(Button.EventType.CLICK, this._onClick, this); } else { // 兼容没有 Button 组件的普通节点 this.node.on(Button.EventType.CLICK, this._onClick, this); } } protected onDestroy(): void { const button = this.getComponent(Button); if (button) { button.node.off(Button.EventType.CLICK, this._onClick, this); } else { this.node.off(Button.EventType.CLICK, this._onClick, this); } } private _onClick(): void { if (this._isSwitching || !this.targetScene) { return; } this._isSwitching = true; // 先预加载再切换,避免白屏 director.preloadScene(this.targetScene, () => { director.loadScene(this.targetScene, () => { this._isSwitching = false; }); }); } }

这样一个通用组件解决了我项目里 80% 的场景切换需求。后续如果要加“切场景前播放音效”“切场景前弹确认框”,我只需要在这个组件里加对应的@property配置就好,所有按钮都不用动。

5.3 防止连点、加载遮罩这些细节不可少

场景切换有个常见体验问题:玩家连续点了几次按钮,loadScene被触发两次,轻则场景重复初始化,重则直接跳过了目标场景或卡死。

我在SceneSwitcher里用_isSwitching做了防连点锁。更完整的方案是加一层加载遮罩:

import { instantiate, Node, Prefab, resources } from 'cc'; private _showLoadingMask(): void { // 从 resources 动态加载一个全屏遮罩预制体 resources.load('prefabs/LoadingMask', Prefab, (err, prefab) => { if (err) { console.error(err); return; } const mask = instantiate(prefab); this.node.scene.addChild(mask); mask.name = '__loading_mask__'; }); }

遮罩的作用不只是防止连点,还能挡住切换瞬间的空白画面,让玩家视觉上觉得“切得很顺”。我习惯把遮罩设成全屏半透明节点,上面加一个旋转动画的 Loading 图标,虽然不是真进度条,但体验比裸切好太多。

细节决定观感,尤其是面向玩家的小游戏,场景切换的流畅度直接影响到游戏评价。

6. 常见问题与排查技巧

6.1 点了按钮场景没反应

这是新手问得最多的问题。我总结了几个排查方向:

  • 按钮上有没有 Button 组件?如果只有 Sprite,没加 Button 组件,点击事件可能根本没被捕获。
  • Button 的 Interactable 是否为 true?如果被置灰了,默认情况下点击事件不会触发。
  • 事件绑定的方法名拼写是否正确?可视化绑定时很容易选错方法或选错组件。
  • 节点上是否被其他 UI 挡住?一个透明的全屏节点盖在按钮上面,点击事件会被它截走。
  • 是不是场景名传错了?director.loadScene('Story')传的是文件名,大小写和文件名不一致会报错。

我的调试技巧是在回调函数第一行加个console.log,先确认事件到底有没有触发。如果触发了再往下查为什么场景没加载;如果没触发,查绑定和遮挡。

6.2 切场景后黑屏或长时间空白

黑屏一般有两种原因。第一种是目标场景资源太大,加载时间长;第二种是场景脚本在onLoadstart里抛了异常,卡住了后续逻辑。

资源大的问题用preloadScene解决,我已经提过。脚本报错则要打开浏览器调试面板看 Console 输出,通常控制台会直接打印桃红色的报错信息,照着定位即可。

还有一种隐蔽情况:目标场景里有个相机没设置好,或者相机裁剪距离不对,导致画面里什么都没有。这时候画面不是黑的,而是背景色,但看起来像黑屏。检查目标场景里是不是真的有 Camera、ClearFlags 是否正确、Canvas 是否在相机视野内。

6.3 重复触发与事件泄漏

重复触发最常见的原因就是前面提到的:事件没解绑。组件被销毁后,它订阅的全局事件还留在EventTarget上,下次触发时回调还在执行。

我排查这种问题的方法很简单:在回调里打日志,连续切两次场景看日志是不是打了两遍。是的话就说明监听重复注册了,重点检查onoff是否成对。

另一个容易踩的坑是:用node.on监听触摸或点击,但节点本身是常驻节点,不会被场景销毁。这种情况下你在别的场景里对它做了二次监听,同样会导致重复触发。记住一个原则:谁监听,谁解绑。

6.4 数据丢失问题

场景切换后发现分数、名字、设置全没了,十有八九是数据挂在了场景节点上。切场景时非持久化节点会一起销毁,绑在上面的数据自然也没了。

解决方案就是我 3.3 节说的三件套:临时的数据用全局单例,需要组件绑定的挂常驻节点,需要落盘的用 localStorage。给数据设计一个明确的“归属地”,比出了 bug 再挪数据要省事得多。

6.5 打包APK时关于场景的几个坑

结合后面准备上线的经验,打包 Android APK 时场景相关的坑主要有这几个:

  • 构建发布面板里忘记勾选新场景。编辑器里运行没问题,但 APK 包里的场景列表是构建时固定的,漏勾的场景在真机上根本不存在,切过去就报错。
  • 动态加载的资源路径写错。如果你用resources.load动态加载预制体,资源必须放在resources目录下。打包后路径大小写错误会导致加载失败。
  • 首场景设置不对。有些项目把主菜单设成了首场景,切 Boot 场景导致启动后白屏。我建议独立一个 Boot 场景作为入口,承担初始化逻辑。
  • Android 证书、包名没填对。这个不属于场景逻辑,但新手经常会卡在构建失败上,报错五花八门。先检查构建面板里包名是否合法、签名证书是否配置。

打包前的最后一件事,我习惯把构建出来的包装到真机上,逐个场景走一遍,确认切换流程和资源加载都正常。编辑器里运行和真机运行差距很大,真机走一遍能发现很多编辑器里看不出来的问题。

最后再分享一个我个人的小技巧:给场景切换相关的方法命名时,统一加go前缀,比如goMainMenu()goStory()goEnd()。这样代码搜索起来一目了然,也方便区分哪些方法是“改变游戏流程”的,哪些只是内部逻辑。场景切换看起来是件小事,但把细节打磨好了,整个项目的开发体验和最终游戏手感都会明显提升。

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

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

立即咨询