diff --git a/src/scene/SceneManager.ts b/src/scene/SceneManager.ts index 8d639af..b4163d3 100644 --- a/src/scene/SceneManager.ts +++ b/src/scene/SceneManager.ts @@ -134,7 +134,18 @@ class SceneManager { } const target = this.getSceneOrThrow(name); + let previousVisibleBefore = true; + let previousMountedBefore = false; + let targetVisibleBefore = true; + let targetMountedBefore = false; + const transaction = new TransitionTransaction({ + prepare: () => { + previousVisibleBefore = previous.stage.visible; + previousMountedBefore = this.game.stage.children.includes(previous.stage); + targetVisibleBefore = target.stage.visible; + targetMountedBefore = this.game.stage.children.includes(target.stage); + }, commit: async () => { // 处理前一个场景 if (previous.type === SceneType.Normal) { @@ -195,6 +206,22 @@ class SceneManager { }, rollback: async () => { this._currentScene = previous; + previous.stage.visible = previousVisibleBefore; + target.stage.visible = targetVisibleBefore; + + const previousMountedNow = this.game.stage.children.includes(previous.stage); + if (previousMountedBefore && !previousMountedNow) { + this.game.stage.addChild(previous.stage); + } else if (!previousMountedBefore && previousMountedNow) { + this.game.stage.removeChild(previous.stage); + } + + const targetMountedNow = this.game.stage.children.includes(target.stage); + if (targetMountedBefore && !targetMountedNow) { + this.game.stage.addChild(target.stage); + } else if (!targetMountedBefore && targetMountedNow) { + this.game.stage.removeChild(target.stage); + } }, }); diff --git a/src/scene/TransitionTransaction.ts b/src/scene/TransitionTransaction.ts index a894227..085bde1 100644 --- a/src/scene/TransitionTransaction.ts +++ b/src/scene/TransitionTransaction.ts @@ -1,6 +1,11 @@ type TransitionAction = () => Promise | void; +const NOOP_TRANSITION_ACTION: TransitionAction = () => {}; export interface TransitionTransactionConfig { + /** + * 事务准备阶段:用于快照状态或前置校验。 + * 保持可选,未传时会执行 no-op,避免调用方额外分支。 + */ prepare?: TransitionAction; commit: TransitionAction; rollback: TransitionAction; @@ -12,7 +17,7 @@ export class TransitionTransaction { private readonly rollback: TransitionAction; constructor(config: TransitionTransactionConfig) { - this.prepare = config.prepare ?? (() => {}); + this.prepare = config.prepare ?? NOOP_TRANSITION_ACTION; this.commit = config.commit; this.rollback = config.rollback; } diff --git a/tests/kernel/scene-manager-rollback.test.ts b/tests/kernel/scene-manager-rollback.test.ts new file mode 100644 index 0000000..a66c85f --- /dev/null +++ b/tests/kernel/scene-manager-rollback.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SceneType } from "@/enums/SceneType"; +import type { IBaseScene } from "@/scene/types"; + +class MockContainer { + visible = true; + label = ""; + _isHolderLast?: boolean; + children: MockContainer[] = []; + destroyed = false; + + 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(): void { + this.destroyed = true; + } +} + +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, + onLoad?: () => Promise | void +): IBaseScene { + const stage = new MockContainer(); + stage.label = name; + + return { + name, + type, + stage: stage as never, + _assetsLoaded: true, + _layoutDone: true, + changeScene: () => {}, + onLoad, + onUnLoad: async () => {}, + loadBundle: async () => {}, + unLoadBundle: async () => {}, + layout: async () => {}, + }; +} + +describe("SceneManager rollback consistency", () => { + beforeEach(async () => { + vi.resetModules(); + const module = await import("@/scene/SceneManager"); + (module.default as unknown as { instance?: unknown }).instance = undefined; + }); + + it("restores previous scene visibility and mount when transition fails", async () => { + const module = await import("@/scene/SceneManager"); + const SceneManager = module.default; + + const manager = SceneManager.getInstance(); + const previous = createScene("previous"); + const target = createScene("target", SceneType.Normal, async () => { + throw new Error("target onLoad failed"); + }); + + manager.registerScene(previous); + manager.registerScene(target); + await manager.initScene("previous"); + + expect(previous.stage.visible).toBe(true); + expect(manager.currentScene).toBe(previous); + + await expect( + manager.changeScene("target", { isHolderLast: true }) + ).rejects.toThrowError("target onLoad failed"); + + expect(manager.currentScene).toBe(previous); + expect(previous.stage.visible).toBe(true); + expect(manager["game"].stage.children.includes(previous.stage as never)).toBe( + true + ); + expect(manager["game"].stage.children.includes(target.stage as never)).toBe( + false + ); + }); +}); diff --git a/tests/kernel/transition-transaction.test.ts b/tests/kernel/transition-transaction.test.ts index 6022d34..25b1bf9 100644 --- a/tests/kernel/transition-transaction.test.ts +++ b/tests/kernel/transition-transaction.test.ts @@ -21,4 +21,24 @@ describe("TransitionTransaction", () => { expect(rollback).toHaveBeenCalledTimes(1); expect(commit).not.toHaveBeenCalled(); }); + + it("rolls back when commit fails", async () => { + const commitError = new Error("commit failed"); + const prepare = vi.fn(async () => {}); + const commit = vi.fn(async () => { + throw commitError; + }); + const rollback = vi.fn(async () => {}); + + const transaction = new TransitionTransaction({ + prepare, + commit, + rollback, + }); + + await expect(transaction.execute()).rejects.toThrowError("commit failed"); + expect(prepare).toHaveBeenCalledTimes(1); + expect(commit).toHaveBeenCalledTimes(1); + expect(rollback).toHaveBeenCalledTimes(1); + }); });