Browse Source

fix(scene): keep stage tree consistent on transition rollback

Made-with: Cursor
master
npmrun 2 weeks ago
parent
commit
fcacb5b6ce
  1. 27
      src/scene/SceneManager.ts
  2. 7
      src/scene/TransitionTransaction.ts
  3. 109
      tests/kernel/scene-manager-rollback.test.ts
  4. 20
      tests/kernel/transition-transaction.test.ts

27
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);
}
},
});

7
src/scene/TransitionTransaction.ts

@ -1,6 +1,11 @@
type TransitionAction = () => Promise<void> | 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;
}

109
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> | 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
);
});
});

20
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);
});
});

Loading…
Cancel
Save