《Cocos 游戏开发实战》5.2 场景切换与预加载

本节对比 director.loadScene、preloadScene 与 bundle.loadScene 三条切换路径,用转场遮罩消除裸切场景的黑帧,把下一关资源的预加载挪进结算界面,并用场景生命周期事件收尾清理;同时给出场景间传参的 SceneRouter 与可直接单测的纯逻辑实现。

5.2 场景切换与预加载

BootFlow 在最后一步调用了 director.loadScene('main'),把玩家送进了主界面。但《方块冲刺》不是一个单场景游戏:主界面、关卡选择、关卡内玩法、结算面板,这四块在工程里是四个独立场景。玩家点「开始游戏」要进关卡场景,通关后要回主界面,中途退出要能回到关卡选择——每一次都是场景切换。

场景切换是启动流程里最容易被写坏的一环。写坏的典型症状有三个:切场景时黑一帧、点按钮到画面出现之间没有反馈、回到主界面后发现上一关的定时器还在跑。这一节把这三个问题逐一对上解法。

本节复用 5.1 启动流程与首屏 里的 BootFlow 与 SAVE_KEY,不重复推导;资源侧的分包细节见 4.3 远程资源与热更 。

5.2.1 三条切换路径:先选对 API

3.8 里把玩家从场景 A 送到场景 B,有三条路径,适用场景完全不同。选错了会得到「切换很慢」或「切完还占着内存」两种相反的症状。

路径调用加载时机适用
直接切换director.loadScene(name)调用时才开始加载,加载完才切目标场景小、可以接受一段无反馈等待
预加载后切换director.preloadScene(name, ...) + director.loadScene(name)提前加载,切换时命中缓存目标场景较大,且有一段可预期的空闲(如结算界面)
从分包切bundle.loadScene(name, ...) + director.runSceneImmediate分包内场景,随分包加载关卡资源已进 bundle-levels(见 §C 的分包设定)

一个容易踩的坑:director.preloadScene 只是把场景资源下载并反序列化到内存,它不会切换,也不会自动释放。如果预加载了五个关卡场景却只进其中一个,其余四个会一直占着内存。3.8 里要释放它们,得显式对场景依赖的资源调 assetManager.releaseAsset,或者干脆改用 bundle.loadScene 按需加载。

三条路径的返回值语义也不一样,混用会写出「以为成功了其实没切」的代码:

调用返回值含义
director.loadScene(name)boolean只表示「这次调用是否发起了切换」,不代表加载成功
director.preloadScene(name, ...)void结果只能从 onLoaded 回调的 err 判断
bundle.loadScene(name, ...)void同上,成功与否看 onComplete 的 err

把 loadScene 的 true 当成「切换成功」是新手常犯的错:它只说明这次调用没有被忽略(例如同名场景正在加载时会被忽略并返回 false)。真正判断成败,一律看回调里的 err。

5.2.2 裸切会黑一帧:转场遮罩

director.loadScene 的工作方式是:先加载新场景,加载完成后销毁旧场景、挂载新场景,这两步之间有一帧没有任何可绘制内容。真机上表现为一次黑闪,H5 上更明显(浏览器合成器会露出底色)。

解法是在 UI 最顶层放一张全屏遮罩,切换前把它淡入到不透明,新场景起来后再淡出。这张遮罩必须挂在常驻节点上,否则它自己会随场景一起被销毁——这正是 5.3 常驻节点与全局管理 要解决的问题,这里先用它的结果。

import {
  _decorator, Component, Node, UIOpacity, BlockInputEvents, tween, director,
} from 'cc';
const { ccclass, property } = _decorator;

@ccclass('TransitionMask')
export class TransitionMask extends Component {
  @property(Node) mask: Node | null = null;

  private busy = false;

  /** 切到目标场景:先淡入遮罩,加载完新场景后淡出 */
  switchTo(sceneName: string): void {
    if (this.busy || this.mask === null) return;
    this.busy = true;
    const mask = this.mask;

    // 遮罩期间吞掉所有点击,避免玩家连点两次按钮触发两次切换
    mask.addComponent(BlockInputEvents);
    const op = mask.getComponent(UIOpacity) ?? mask.addComponent(UIOpacity);

    tween(op)
      .to(0.18, { opacity: 255 })
      .call(() => {
        director.loadScene(sceneName, (err) => {
          if (err !== null) {
            console.error('[TransitionMask] 场景切换失败', err);
          }
          tween(op)
            .to(0.18, { opacity: 0 })
            .call(() => {
              this.busy = false;
              mask.getComponent(BlockInputEvents)?.destroy();
            })
            .start();
        });
      })
      .start();
  }
}

两个细节值得说明。第一,BlockInputEvents 是在切换期间才加上的——它平时不挂,否则会挡住正常 UI 的点击。第二,busy 标志防的是「淡入还没结束又点了一次」,BlockInputEvents 防的是「遮罩已经半透明了但玩家还能点到下面的按钮」,两者管的是不同窗口,缺一不可。

5.2.3 预加载:把停顿挪到玩家看不见的地方

关卡场景 level-1 有地形图集、金币动画、道具图标,直接切会有一秒左右的停顿。但《方块冲刺》有一个天然的缓冲窗口:结算面板停留的这几秒。玩家在看分数的时候,后台把下一关加载好,点「下一关」就能瞬间切走。

import { _decorator, Component, director, assetManager, ProgressBar } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('SettlementPreloader')
export class SettlementPreloader extends Component {
  @property(ProgressBar) bar: ProgressBar | null = null;

  private nextScene = 'level-2';

  onEnable(): void {
    // 结算面板出现的同一帧就开始预加载,不占用任何用户可见的等待
    director.preloadScene(
      this.nextScene,
      (completed: number, total: number) => {
        if (this.bar !== null && total > 0) this.bar.progress = completed / total;
      },
      (err, sceneAsset) => {
        if (err !== null) {
          console.warn('[SettlementPreloader] 预加载失败,将退回同步加载', err);
          return;
        }
        console.log('[SettlementPreloader] 已就绪', sceneAsset?.name);
      },
    );
  }

  onDisable(): void {
    // 面板被关掉但预加载还在跑时,交给 director 自己完成即可;
    // 这里不主动取消,因为 preloadScene 没有提供取消接口
  }

  goNext(): void {
    director.loadScene(this.nextScene);
  }
}

要注意 preloadScene 没有取消接口。如果玩家在结算面板点了「返回主界面」而不是「下一关」,那个预加载仍然会把 level-2 拉完——多花一次流量,但换来的是逻辑简单。要不要为这点流量加一层「可取消」的封装,取决于目标平台是不是按流量计费,微信小游戏上一般选择接受。

5.2.4 生命周期事件:把清理挂在正确的时机

场景切换时,旧场景的节点和组件会被销毁,但挂在全局对象上的监听、定时器、tween 不会。回到主界面后「上一关的定时器还在跑」,根因就在这里。3.8 提供了两个场景级事件来收口:

import { _decorator, Component, director, Director } from 'cc';
const { ccclass } = _decorator;

@ccclass('SceneLifecycleGuard')
export class SceneLifecycleGuard extends Component {
  onEnable(): void {
    director.on(Director.EVENT_BEFORE_SCENE_LAUNCH, this.onBeforeLaunch, this);
    director.on(Director.EVENT_AFTER_SCENE_LAUNCH, this.onAfterLaunch, this);
  }

  onDisable(): void {
    director.off(Director.EVENT_BEFORE_SCENE_LAUNCH, this.onBeforeLaunch, this);
    director.off(Director.EVENT_AFTER_SCENE_LAUNCH, this.onAfterLaunch, this);
  }

  private onBeforeLaunch(): void {
    // 新场景即将挂载:旧场景的自定义全局监听在这里撤掉
    this.unscheduleAllCallbacks();
  }

  private onAfterLaunch(): void {
    const scene = director.getScene();
    console.log('[SceneLifecycleGuard] 已进入场景', scene?.name);
  }
}

EVENT_BEFORE_SCENE_LAUNCH 与 EVENT_AFTER_SCENE_LAUNCH 是全局事件,只触发一次,和挂在某个节点上的 Node.EventType.ACTIVE_IN_HIERARCHY_CHANGED 不是一回事。注意这两个常量必须写成 Director.EVENT_*(类上的静态成员),写成 director.EVENT_* 会直接过不了 tsc——director 是 Director 的实例,实例上没有这两个属性。

事件时机适合做的事
Director.EVENT_BEFORE_SCENE_LOADING开始加载新场景前打点、记录切换耗时起点
Director.EVENT_BEFORE_SCENE_LAUNCH新场景即将挂载撤销旧场景的全局监听与定时器
Director.EVENT_AFTER_SCENE_LAUNCH新场景已挂载读取场景名、恢复状态、触发进场动画

5.2.5 场景间传参:SceneRouter

director.loadScene 只接受一个场景名,没有传参入口。想在切场景时带上「本关分数」「是否重试」这类数据,只能走一个不随场景销毁的载体。最常见的错误做法是把参数挂在某个常驻节点上再用 find 去取——路径一改就断。正确做法是一个纯 TS 的路由器,与引擎无关,可以直接单测。

// src/core/scene-router.ts —— 与引擎无关,可直接单测
export interface RoutePayload {
  readonly [key: string]: string | number | boolean;
}

export class SceneRouter {
  private pending: RoutePayload = {};
  private current = '';

  /** 切场景前登记参数;同名参数后写覆盖先写 */
  go(target: string, payload: RoutePayload = {}): void {
    this.pending = { ...payload };
    this.current = target;
  }

  /** 新场景 onLoad 时取出参数;取出即清空,避免下一次误读 */
  consume(): { target: string; payload: RoutePayload } {
    const out = { target: this.current, payload: this.pending };
    this.pending = {};
    return out;
  }

  get currentScene(): string {
    return this.current;
  }
}

配一个单测,把「取出即清空」这条契约钉死:

// tests/scene-router.test.ts
import { describe, it, expect } from 'vitest';
import { SceneRouter } from '../src/core/scene-router';

describe('SceneRouter', () => {
  it('参数取出后清空,第二次读到空对象', () => {
    const r = new SceneRouter();
    r.go('level-1', { score: 120, retry: false });
    const first = r.consume();
    expect(first.target).toBe('level-1');
    expect(first.payload.score).toBe(120);
    expect(r.consume().payload).toEqual({});
  });
});

引擎侧的桥接只有两行:按钮里调 router.go('level-1', { score }),然后让 TransitionMask.switchTo('level-1');新场景的入口组件在 onLoad 里调 router.consume()。这样「参数」和「切换动画」两件事彻底解耦,将来换转场效果不用碰路由逻辑。

5.2.6 工程收口

这一节做完,工程里多了这些文件与可配置项:

  • assets/scripts/game/transition-mask.ts:转场遮罩组件,挂在常驻根上。
  • assets/scripts/game/settlement-preloader.ts:结算界面里的下一关预加载。
  • assets/scripts/game/scene-lifecycle-guard.ts:全局监听与定时器的收口点。
  • assets/scripts/core/scene-router.ts:场景参数路由,纯逻辑可单测。
  • assets/scenes/level-1.scene:关卡场景,构建时随 bundle-levels 分包发布。

下一节把这一节里反复出现的「常驻根」讲清楚:director.addPersistRootNode 到底做了什么、什么节点适合常驻、以及常驻节点上的组件生命周期和普通节点有什么不同。

阅读导航:上一节:5.1 启动流程与首屏 · 下一节:5.3 常驻节点与全局管理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画