diff --git a/src/scene/SceneManager.ts b/src/scene/SceneManager.ts index b4163d3..598e4d4 100644 --- a/src/scene/SceneManager.ts +++ b/src/scene/SceneManager.ts @@ -138,6 +138,7 @@ class SceneManager { let previousMountedBefore = false; let targetVisibleBefore = true; let targetMountedBefore = false; + let shouldFinalizePreviousNormal = false; const transaction = new TransitionTransaction({ prepare: () => { @@ -153,19 +154,9 @@ class SceneManager { previous.stage.visible = false; previous.stage._isHolderLast = true; } else { - // 销毁显示节点并卸载,但保留场景注册,便于之后再次 changeScene("welcome") 等 - const oldStage = previous.stage; - oldStage.destroy({ children: true }); - this.game.stage.removeChild(oldStage); - if (previous._assetsLoaded) { - await previous.unLoadBundle?.(); - previous._assetsLoaded = false; - } - await previous.onUnLoad?.(); - const nextStage = new Container(); - nextStage.label = previous.name; - (previous as { stage: Container }).stage = nextStage; - previous._layoutDone = false; + // 延后销毁:若目标场景中途失败,可回滚到原有效舞台节点,避免空容器伪恢复 + previous.stage.visible = false; + shouldFinalizePreviousNormal = true; } } else if (previous.type === SceneType.Resident) { previous.stage.visible = false; @@ -202,6 +193,23 @@ class SceneManager { // 触发回调 await this.emitStageChange(target, previous); + + if (shouldFinalizePreviousNormal) { + // 到这里说明切换已成功,再执行破坏性清理 + const oldStage = previous.stage; + this.game.stage.removeChild(oldStage); + oldStage.destroy({ children: true }); + if (previous._assetsLoaded) { + await previous.unLoadBundle?.(); + previous._assetsLoaded = false; + } + await previous.onUnLoad?.(); + const nextStage = new Container(); + nextStage.label = previous.name; + (previous as { stage: Container }).stage = nextStage; + previous._layoutDone = false; + } + logger.debug(`SceneManager: changed from "${previous.name}" to "${name}"`); }, rollback: async () => { diff --git a/tests/kernel/transition-transaction.test.ts b/tests/kernel/transition-transaction.test.ts index 25b1bf9..1b3e700 100644 --- a/tests/kernel/transition-transaction.test.ts +++ b/tests/kernel/transition-transaction.test.ts @@ -1,5 +1,74 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { TransitionTransaction } from "@/scene/TransitionTransaction"; +import { SceneType } from "@/enums/SceneType"; +import type { IBaseScene } from "@/scene/types"; + +class MockContainer { + visible = true; + label = ""; + _isHolderLast?: boolean; + children: MockContainer[] = []; + + addChild(child: MockContainer): MockContainer { + this.children.push(child); + return child; + } + + removeChild(child: MockContainer): MockContainer { + this.children = this.children.filter((item) => item !== child); + return child; + } + + destroy(options?: { children?: boolean }): void { + if (options?.children) { + this.children = []; + } + } +} + +vi.mock("pixi.js", () => ({ + Container: MockContainer, +})); + +vi.mock("@/core/Game", () => { + class MockGame { + private static instance: MockGame; + readonly stage = new MockContainer(); + + static getInstance(): MockGame { + if (!MockGame.instance) { + MockGame.instance = new MockGame(); + } + return MockGame.instance; + } + } + + return { default: MockGame }; +}); + +function createScene( + name: string, + type: SceneType = SceneType.Normal, + overrides?: Partial +): IBaseScene { + const stage = new MockContainer(); + stage.label = name; + + return { + name, + type, + stage: stage as never, + _assetsLoaded: true, + _layoutDone: true, + changeScene: () => {}, + loadBundle: async () => {}, + unLoadBundle: async () => {}, + layout: async () => {}, + onLoad: async () => {}, + onUnLoad: async () => {}, + ...overrides, + }; +} describe("TransitionTransaction", () => { it("rolls back when prepare fails and never commits", async () => { @@ -42,3 +111,45 @@ describe("TransitionTransaction", () => { expect(rollback).toHaveBeenCalledTimes(1); }); }); + +describe("SceneManager rollback safety", () => { + beforeEach(async () => { + vi.resetModules(); + const module = await import("@/scene/SceneManager"); + (module.default as unknown as { instance?: unknown }).instance = undefined; + }); + + it("keeps previous stage content intact when target load fails", async () => { + const module = await import("@/scene/SceneManager"); + const SceneManager = module.default; + const manager = SceneManager.getInstance(); + + const previous = createScene("previous"); + const previousContent = new MockContainer(); + (previous.stage as unknown as MockContainer).addChild(previousContent); + + const target = createScene("target", SceneType.Normal, { + onLoad: async () => { + throw new Error("target onLoad failed"); + }, + }); + + manager.registerScene(previous); + manager.registerScene(target); + await manager.initScene("previous"); + + await expect(manager.changeScene("target")).rejects.toThrowError( + "target onLoad failed" + ); + + const stage = manager["game"].stage as unknown as MockContainer; + expect(manager.currentScene).toBe(previous); + expect(previous.stage).not.toBeUndefined(); + expect((previous.stage as unknown as MockContainer).children).toContain( + previousContent + ); + expect((previous.stage as unknown as MockContainer).visible).toBe(true); + expect(stage.children.includes(previous.stage as never)).toBe(true); + expect(stage.children.includes(target.stage as never)).toBe(false); + }); +});