代码
import {
Camera,
Color,
Director,
Layers,
Node,
RenderTexture,
Scene,
SceneAsset,
Sprite,
SpriteFrame,
UIOpacity,
UITransform,
Widget,
Vec3,
director,
game,
view,
} from 'cc';
import RootNode from '../RootNode';
import Log from '../../util/Log';
/** crossfade 过渡参数。 */
export interface CrossFadeOptions {
/** 淡出时长(秒),默认 0.35。 */
duration?: number;
}
/**
* 场景过渡工具(方案 B:老场景 crossfade 到新场景)。
*
* 原理:
* 1. 在切场景「前」——此时老场景 Canvas 仍存活——用一个临时 Camera 把当前
* UI 画面渲染到 RenderTexture,得到老画面的静态快照。
* 2. 把快照包成全屏 Sprite,挂到常驻层(RootNode)最顶,opacity=255,盖住屏幕。
* 3. `director.runScene(scene)`——注意用 runScene 而非 runSceneImmediate:
* 切换延迟到当前帧末尾,保证第 1 步截图时老场景还没被销毁。
* 4. 新场景 launched 回调里执行业务 init。
* 5. init 完成后把覆盖层 opacity 从 255 tween 到 0,露出新场景,结束销毁所有临时资源。
*/
export class SceneTransition {
/**
* 带 crossfade 过渡地切换场景。
*
* @param scene 已加载好的目标 Scene(loadScene 的产物)。
* @param opts 过渡参数。
* @param onLaunched 新场景上屏后的回调(业务 init 放这里)。返回的 Promise
* resolve(或函数同步返回)后才开始淡出,从而保证「init 完成才露出新场景」。
*/
public static async crossFadeRunScene(
scene: SceneAsset,
opts: CrossFadeOptions,
onLaunched: () => void | Promise<void>,
): Promise<void> {
const duration = opts?.duration ?? 0.35;
// ---- 1. 截图:老场景仍存活时抓一帧 ----
const snapshot = SceneTransition.captureCurrentScene();
// ---- 2. 覆盖层:把快照挂到常驻层最顶 ----
let overlay: Node | null = null;
let opacityComp: UIOpacity | null = null;
if (snapshot) {
overlay = SceneTransition.buildOverlay(snapshot.spriteFrame);
opacityComp = overlay.getComponent(UIOpacity);
}
// ---- 3. 切场景(帧末切换) ----
director.runScene(scene, undefined, async () => {
// 清理只执行一次,且不依赖 tween 是否被正常调度。
let finished = false;
const finish = () => {
if (finished) return;
finished = true;
if (overlay?.isValid) overlay.destroy();
snapshot?.dispose();
};
try {
// ---- 4. 业务 init(等待其完成才淡出) ----
await onLaunched();
} catch (e) {
Log.Warn('SceneTransition onLaunched error:', e);
}
// ---- 5. 淡出 + 清理 ----
if (!overlay || !opacityComp) {
finish();
return;
}
SceneTransition.fadeOut(overlay, opacityComp, duration, finish);
});
}
/**
* 手动逐帧淡出覆盖层,不依赖 tween 系统。
*
* 之所以不用 tween:runScene 的 launched 回调发生在切场景边界,tween 有概率
* 未被正常调度/被打断,导致 opacity 卡在 255、快照永远盖屏(“有概率不消失”)。
* 这里用 director 的 tick 手动插值,并带兜底:无论如何到时都会 finish。
*/
private static fadeOut(overlay: Node, opacityComp: UIOpacity, duration: number, finish: () => void): void {
if (duration <= 0) {
finish();
return;
}
// Director 事件回调不传 dt,用 game.totalTime 自己算增量。
let last = game.totalTime;
let elapsed = 0;
const onTick = () => {
// 覆盖层若已被外部销毁,立即收尾,避免访问失效节点。
if (!overlay.isValid || !opacityComp.isValid) {
director.off(Director.EVENT_AFTER_UPDATE, onTick);
finish();
return;
}
const now = game.totalTime;
elapsed += Math.max(0, (now - last) / 1000);
last = now;
const t = Math.min(1, elapsed / duration);
opacityComp.opacity = Math.round(255 * (1 - t));
if (t >= 1) {
director.off(Director.EVENT_AFTER_UPDATE, onTick);
finish();
}
};
director.on(Director.EVENT_AFTER_UPDATE, onTick);
}
/**
* 把「当前正在显示的场景 Canvas」渲染到一张 RenderTexture。
* 必须在 runScene 之前调用(老场景 Canvas 尚未销毁)。
*
* @returns 快照的 SpriteFrame + dispose 清理函数;无法截图时返回 null。
*/
private static captureCurrentScene(): { spriteFrame: SpriteFrame; dispose: () => void } | null {
const scene = director.getScene();
if (!scene) return null;
// 找到场景里用于渲染 UI 的相机(排除我们自己临时挂的)。
const srcCamera = SceneTransition.findSceneCamera(scene);
if (!srcCamera) {
Log.Warn('SceneTransition: no camera found in current scene, skip snapshot');
return null;
}
const size = view.getVisibleSize();
const width = Math.max(1, Math.floor(size.width));
const height = Math.max(1, Math.floor(size.height));
const rt = new RenderTexture();
rt.reset({ width, height });
// 用临时 Camera 复制源相机参数,渲染一帧到 RT。
// 直接复用源相机的 targetTexture 会污染屏幕显示,故新建独立相机。
const camNode = new Node('__SceneSnapshotCamera__');
camNode.setWorldPosition(srcCamera.node.worldPosition);
camNode.setWorldRotation(srcCamera.node.worldRotation);
scene.addChild(camNode);
const cam = camNode.addComponent(Camera);
cam.projection = srcCamera.projection;
cam.priority = srcCamera.priority + 1;
cam.visibility = srcCamera.visibility;
cam.clearFlags = Camera.ClearFlag.SOLID_COLOR;
cam.clearColor = new Color(0, 0, 0, 0);
cam.orthoHeight = srcCamera.orthoHeight;
cam.near = srcCamera.near;
cam.far = srcCamera.far;
cam.fov = srcCamera.fov;
cam.targetTexture = rt;
// 主动渲染一帧到 RT。
cam.camera?.update(true);
director.root?.frameMove(0);
// 拆掉临时相机(RT 内容已经写入)。
camNode.destroy();
const spriteFrame = new SpriteFrame();
spriteFrame.texture = rt;
return {
spriteFrame,
dispose: () => {
spriteFrame.destroy();
rt.destroy();
},
};
}
/** 在场景里找一个可用于截图的相机(跳过我们临时挂的快照相机)。 */
private static findSceneCamera(scene: Scene): Camera | null {
const cams = scene.getComponentsInChildren(Camera);
for (const c of cams) {
if (c.node.name === '__SceneSnapshotCamera__') continue;
if (!c.enabledInHierarchy) continue;
return c;
}
return cams.length > 0 ? cams[0] : null;
}
/** 构建盖住屏幕的全屏快照覆盖层,挂到常驻层最顶。 */
private static buildOverlay(spriteFrame: SpriteFrame): Node {
const parent = RootNode.Ins?.node;
const node = new Node('__SceneTransitionOverlay__');
node.layer = Layers.Enum.UI_2D;
const uiTf = node.addComponent(UITransform);
const size = view.getVisibleSize();
uiTf.setContentSize(size.width, size.height);
const opacity = node.addComponent(UIOpacity);
opacity.opacity = 255;
const sprite = node.addComponent(Sprite);
sprite.spriteFrame = spriteFrame;
sprite.sizeMode = Sprite.SizeMode.CUSTOM;
sprite.type = Sprite.Type.SIMPLE;
if (parent) {
parent.addChild(node);
node.setPosition(Vec3.ZERO);
// 拉满全屏,并置于最顶。
const w = node.addComponent(Widget);
w.isAlignTop = w.isAlignBottom = w.isAlignLeft = w.isAlignRight = true;
w.top = w.bottom = w.left = w.right = 0;
w.updateAlignment();
node.setSiblingIndex(parent.children.length - 1);
} else {
// 兜底:常驻 RootNode 不可用时挂当前场景(此时尚未 runScene)。
const scene = director.getScene();
scene?.addChild(node);
}
return node;
}
}
如何使用
// 原本使用director.runScene的地方改为以下代码
SceneTransition.crossFadeRunScene(scene, { duration: 0.45 }, async () => {
// code
})