Browse Source

feat(scene): implement loading overlay and enhance scene management lifecycle

- Introduced SceneLoadingOverlay for improved user feedback during asset loading.
- Updated SceneManager to manage loading overlay visibility and state reporting.
- Refactored scene lifecycle methods to integrate loading overlay functionality.
- Removed unused assets and scenes to streamline project structure.

Made-with: Cursor
master
npmrun 2 weeks ago
parent
commit
808af7f67d
  1. 531
      docs/superpowers/plans/2026-04-26-pixi-dx-runtime-assets-redesign-plan.md
  2. 1363
      package-lock.json
  3. BIN
      public/bg.png
  4. BIN
      public/controller_prompt_bg.png
  5. 8
      public/manifest.json
  6. 52
      src/devtools/overlay/DebugOverlay.ts
  7. 4
      src/init.ts
  8. 14
      src/scene/BaseScene.ts
  9. 165
      src/scene/SceneLoadingOverlay.ts
  10. 100
      src/scene/SceneManager.ts
  11. 35
      src/stages/_global/page_00_global.ts
  12. 15
      src/stages/initSceneLayout.ts
  13. 64
      src/stages/page_init.ts
  14. 15
      src/stages/welcome/circle.ts
  15. 34
      src/stages/welcome/page_welcome.ts
  16. 46
      src/stages/welcome2/page_welcome2.ts
  17. 32
      tests/kernel/transition-transaction.test.ts

531
docs/superpowers/plans/2026-04-26-pixi-dx-runtime-assets-redesign-plan.md

@ -0,0 +1,531 @@
# Pixi DX-First Runtime & Assets Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 重构运行时与资源系统,在 Web H5 下建立可观测、可回滚、可维护的 DX 优先框架底座,并交付最小内置调试 UI。
**Architecture:** 采用 `kernel / scene / assets / devtools / ui-core` 五层结构。场景切换改为事务模型(prepare/commit/rollback),资源改为 `AssetGraph + AssetSession` 持有关系追踪。开发态通过 overlay 与命令面板直接消费运行时事件流,优先保证定位效率,再逐步做性能优化。
**Tech Stack:** TypeScript, PixiJS 8, Vite 5, Vitest(新增), @pixi/devtools(已有)
---
## Scope Check
本计划覆盖同一重构主题下的两个强耦合子系统(运行时内核与资源系统),并包含最小 devtools 交付。三部分共享同一事件流与状态模型,适合在单计划中分任务推进。
## Planned File Structure
### Create
- `src/kernel/AppRuntime.ts`:封装 renderer/ticker/stage 初始化、resize、render 入口
- `src/kernel/RuntimeEvents.ts`:定义运行时事件类型与发布接口
- `src/kernel/RuntimePlugin.ts`:插件接口与插件注册器
- `src/scene/SceneStateMachine.ts`:场景状态机与状态迁移校验
- `src/scene/TransitionTransaction.ts`:切换事务(prepare/commit/rollback)
- `src/scene/SceneContext.ts`:场景可用上下文(assets/events/runtime)
- `src/assets/AssetGraph.ts`:bundle 依赖图与解析
- `src/assets/AssetSession.ts`:会话申请/释放/owner 追踪
- `src/assets/AssetInspectorSnapshot.ts`:供 overlay 使用的只读快照
- `src/devtools/overlay/DebugOverlay.ts`:调试面板容器与刷新调度
- `src/devtools/overlay/widgets/SceneWidget.ts`:场景状态展示
- `src/devtools/overlay/widgets/AssetWidget.ts`:资源关系展示
- `src/devtools/overlay/widgets/EventWidget.ts`:事件时间线展示
- `src/ui-core/UiPanel.ts`:面板基础组件
- `src/ui-core/UiText.ts`:文本基础组件
- `src/ui-core/UiButton.ts`:按钮基础组件
- `src/ui-core/UiList.ts`:列表基础组件
- `src/devtools/CommandPalette.ts`:运行时命令入口
- `tests/kernel/scene-state-machine.test.ts`:状态机单测
- `tests/kernel/transition-transaction.test.ts`:事务单测
- `tests/kernel/runtime-events.test.ts`:运行时事件流单测
- `tests/assets/asset-session.test.ts`:资源会话单测
- `vitest.config.ts`:测试配置
### Modify
- `package.json`:新增 `test`、`test:watch` 脚本和 vitest 依赖
- `src/core/Game.ts`:退役为兼容壳,委托给 `AppRuntime`(过渡期)
- `src/core/AssetManager.ts`:拆分职责,迁移至 `assets` 新实现
- `src/scene/SceneManager.ts`:接入状态机和事务
- `src/scene/BaseScene.ts`:场景生命周期改造(setup/enter/leave/dispose)
- `src/init.ts`:运行时/场景/devtools 统一装配
- `src/main.ts`:开发态开关和命令面板入口
---
### Task 1: 建立测试基线与重构骨架
**Files:**
- Create: `vitest.config.ts`
- Create: `tests/kernel/scene-state-machine.test.ts`
- Modify: `package.json`
- Test: `tests/kernel/scene-state-machine.test.ts`
- [ ] **Step 1: 写一个失败的状态机迁移测试**
```ts
import { describe, expect, it } from "vitest";
import { SceneStateMachine } from "@/scene/SceneStateMachine";
describe("SceneStateMachine", () => {
it("rejects invalid transition from created to ready", () => {
const sm = new SceneStateMachine("welcome");
expect(() => sm.transition("ready")).toThrowError(
/invalid transition: created -> ready/
);
});
});
```
- [ ] **Step 2: 运行测试并确认失败(因为目标文件尚不存在)**
Run: `npm run test -- tests/kernel/scene-state-machine.test.ts`
Expected: FAIL with module resolution error for `@/scene/SceneStateMachine`
- [ ] **Step 3: 添加最小测试基础设施与最小实现**
```ts
// vitest.config.ts
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
},
});
```
```ts
// src/scene/SceneStateMachine.ts
export type SceneState =
| "created"
| "setup"
| "assetsLoading"
| "entering"
| "ready"
| "leaving"
| "disposed";
const ALLOWED: Record<SceneState, SceneState[]> = {
created: ["setup"],
setup: ["assetsLoading"],
assetsLoading: ["entering"],
entering: ["ready"],
ready: ["leaving"],
leaving: ["disposed", "ready"],
disposed: [],
};
export class SceneStateMachine {
public state: SceneState = "created";
constructor(public readonly sceneId: string) {}
transition(next: SceneState): void {
if (!ALLOWED[this.state].includes(next)) {
throw new Error(`invalid transition: ${this.state} -> ${next}`);
}
this.state = next;
}
}
```
- [ ] **Step 4: 再次运行测试并确认通过**
Run: `npm run test -- tests/kernel/scene-state-machine.test.ts`
Expected: PASS (1 passed)
- [ ] **Step 5: 提交**
```bash
git add package.json vitest.config.ts src/scene/SceneStateMachine.ts tests/kernel/scene-state-machine.test.ts
git commit -m "test: add vitest baseline and scene state machine skeleton"
```
---
### Task 2: 实现场景事务切换与回滚
**Files:**
- Create: `src/scene/TransitionTransaction.ts`
- Create: `tests/kernel/transition-transaction.test.ts`
- Modify: `src/scene/SceneManager.ts`
- Test: `tests/kernel/transition-transaction.test.ts`
- [ ] **Step 1: 写失败测试,验证 prepare 失败会 rollback**
```ts
import { describe, expect, it, vi } from "vitest";
import { TransitionTransaction } from "@/scene/TransitionTransaction";
it("rolls back when prepare fails", async () => {
const tx = new TransitionTransaction({
prepare: vi.fn().mockRejectedValue(new Error("load failed")),
commit: vi.fn(),
rollback: vi.fn(),
finalize: vi.fn(),
});
await expect(tx.run()).rejects.toThrow(/load failed/);
expect(tx.hooks.rollback).toHaveBeenCalledTimes(1);
expect(tx.hooks.commit).not.toHaveBeenCalled();
});
```
- [ ] **Step 2: 运行测试确认失败**
Run: `npm run test -- tests/kernel/transition-transaction.test.ts`
Expected: FAIL with missing module `@/scene/TransitionTransaction`
- [ ] **Step 3: 写最小事务实现并接入 SceneManager**
```ts
// src/scene/TransitionTransaction.ts
export interface TransitionHooks {
prepare: () => Promise<void>;
commit: () => Promise<void>;
rollback: () => Promise<void>;
finalize: () => Promise<void>;
}
export class TransitionTransaction {
constructor(public readonly hooks: TransitionHooks) {}
async run(): Promise<void> {
try {
await this.hooks.prepare();
await this.hooks.commit();
} catch (error) {
await this.hooks.rollback();
throw error;
} finally {
await this.hooks.finalize();
}
}
}
```
```ts
// src/scene/SceneManager.ts (new usage snippet)
const tx = new TransitionTransaction({
prepare: async () => { /* load target scene assets + setup */ },
commit: async () => { /* mount target and enter */ },
rollback: async () => { /* restore previous ready scene */ },
finalize: async () => { /* emit report */ },
});
await tx.run();
```
- [ ] **Step 4: 运行测试确认通过**
Run: `npm run test -- tests/kernel/transition-transaction.test.ts`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add src/scene/TransitionTransaction.ts src/scene/SceneManager.ts tests/kernel/transition-transaction.test.ts
git commit -m "feat(scene): add transition transaction with rollback flow"
```
---
### Task 3: 实现 AssetGraph 与 AssetSession
**Files:**
- Create: `src/assets/AssetGraph.ts`
- Create: `src/assets/AssetSession.ts`
- Create: `src/assets/AssetInspectorSnapshot.ts`
- Create: `tests/assets/asset-session.test.ts`
- Modify: `src/core/AssetManager.ts`
- Test: `tests/assets/asset-session.test.ts`
- [ ] **Step 1: 写失败测试,验证 owner/session 引用追踪**
```ts
import { describe, expect, it } from "vitest";
import { AssetSession } from "@/assets/AssetSession";
it("tracks owner and releases resources by session", async () => {
const session = new AssetSession("scene:welcome", "tx:1");
await session.require("bundle.home");
const snap = session.snapshot();
expect(snap.owners["bundle.home"]).toContain("scene:welcome");
await session.releaseAll();
expect(session.snapshot().activeBundles).toHaveLength(0);
});
```
- [ ] **Step 2: 运行测试确认失败**
Run: `npm run test -- tests/assets/asset-session.test.ts`
Expected: FAIL with missing module `@/assets/AssetSession`
- [ ] **Step 3: 写最小实现并改造旧 AssetManager 为门面**
```ts
// src/assets/AssetSession.ts
type Snapshot = { activeBundles: string[]; owners: Record<string, string[]> };
export class AssetSession {
private bundles = new Set<string>();
private ownerMap = new Map<string, Set<string>>();
constructor(
private readonly ownerId: string,
private readonly transitionId: string
) {}
async require(bundleName: string): Promise<void> {
this.bundles.add(bundleName);
if (!this.ownerMap.has(bundleName)) this.ownerMap.set(bundleName, new Set());
this.ownerMap.get(bundleName)!.add(this.ownerId);
}
async releaseAll(): Promise<void> {
this.bundles.clear();
this.ownerMap.clear();
}
snapshot(): Snapshot {
const owners: Record<string, string[]> = {};
for (const [k, v] of this.ownerMap) owners[k] = [...v];
return { activeBundles: [...this.bundles], owners };
}
}
```
```ts
// src/core/AssetManager.ts (facade idea)
import { AssetSession } from "@/assets/AssetSession";
export class AssetManagerFacade {
createSession(ownerId: string, transitionId: string): AssetSession {
return new AssetSession(ownerId, transitionId);
}
}
```
- [ ] **Step 4: 运行测试确认通过**
Run: `npm run test -- tests/assets/asset-session.test.ts`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add src/assets/AssetGraph.ts src/assets/AssetSession.ts src/assets/AssetInspectorSnapshot.ts src/core/AssetManager.ts tests/assets/asset-session.test.ts
git commit -m "feat(assets): add asset graph/session with owner tracking"
```
---
### Task 4: 落地 AppRuntime 与事件总线插件化
**Files:**
- Create: `src/kernel/AppRuntime.ts`
- Create: `src/kernel/RuntimeEvents.ts`
- Create: `src/kernel/RuntimePlugin.ts`
- Create: `tests/kernel/runtime-events.test.ts`
- Modify: `src/core/Game.ts`
- Modify: `src/init.ts`
- Test: `tests/kernel/runtime-events.test.ts`
- [ ] **Step 1: 先写失败测试,验证事件会在切换时发出**
```ts
import { describe, expect, it } from "vitest";
import { createRuntimeEvents } from "@/kernel/RuntimeEvents";
it("emits transition events in order", () => {
const events = createRuntimeEvents();
const names: string[] = [];
events.onAny((e) => names.push(e.type));
events.emit({ type: "transition:started", transitionId: "tx:1" });
events.emit({ type: "transition:finished", transitionId: "tx:1" });
expect(names).toEqual(["transition:started", "transition:finished"]);
});
```
- [ ] **Step 2: 运行测试确认失败**
Run: `npm run test -- tests/kernel/runtime-events.test.ts`
Expected: FAIL with missing module `@/kernel/RuntimeEvents`
- [ ] **Step 3: 添加运行时与事件实现,并让旧 Game 仅做委托**
```ts
// src/kernel/RuntimeEvents.ts
type RuntimeEvent = { type: string; [k: string]: unknown };
type Handler = (event: RuntimeEvent) => void;
export function createRuntimeEvents() {
const handlers = new Set<Handler>();
return {
emit(event: RuntimeEvent) { handlers.forEach((h) => h(event)); },
onAny(handler: Handler) {
handlers.add(handler);
return () => handlers.delete(handler);
},
};
}
```
```ts
// src/core/Game.ts (delegation snippet)
import { AppRuntime } from "@/kernel/AppRuntime";
const runtime = new AppRuntime();
export default runtime;
```
- [ ] **Step 4: 运行核心测试集合确认通过**
Run: `npm run test -- tests/kernel/runtime-events.test.ts tests/kernel/scene-state-machine.test.ts tests/kernel/transition-transaction.test.ts`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add src/kernel/AppRuntime.ts src/kernel/RuntimeEvents.ts src/kernel/RuntimePlugin.ts src/core/Game.ts src/init.ts tests/kernel/runtime-events.test.ts
git commit -m "refactor(kernel): introduce app runtime and event bus plugin system"
```
---
### Task 5: 交付最小 Debug Overlay + Command Palette
**Files:**
- Create: `src/devtools/overlay/DebugOverlay.ts`
- Create: `src/devtools/overlay/widgets/SceneWidget.ts`
- Create: `src/devtools/overlay/widgets/AssetWidget.ts`
- Create: `src/devtools/overlay/widgets/EventWidget.ts`
- Create: `src/devtools/CommandPalette.ts`
- Create: `src/ui-core/UiPanel.ts`
- Create: `src/ui-core/UiText.ts`
- Create: `src/ui-core/UiButton.ts`
- Create: `src/ui-core/UiList.ts`
- Modify: `src/main.ts`
- Test: 手工验证(见步骤)
- [ ] **Step 1: 定义手工验收断言(固定文本,后续逐条打勾)**
```md
1. 按 `~` 能切换 overlay 显隐,且不影响场景点击事件。
2. overlay 中可看到当前 sceneId、sceneState、最近 20 条 runtime 事件。
3. overlay 中可看到 active bundle 数和 owner/session 计数。
4. 命令面板执行 `scene.goto(welcome)` 后,sceneState 在 2 秒内回到 ready。
```
- [ ] **Step 2: 运行开发环境并确认当前不满足断言**
Run: `npm run dev`
Expected: 无 overlay / 无 palette(作为失败基线)
- [ ] **Step 3: 实现最小 UI 与 devtools 装配**
```ts
// src/devtools/overlay/DebugOverlay.ts
export class DebugOverlay {
private visible = true;
toggle(): void { this.visible = !this.visible; }
isVisible(): boolean { return this.visible; }
update(): void { /* render widgets from runtime snapshots */ }
}
```
```ts
// src/devtools/CommandPalette.ts
type Command = { id: string; run: () => Promise<void> | void };
export class CommandPalette {
private cmds = new Map<string, Command>();
register(c: Command): void { this.cmds.set(c.id, c); }
execute(id: string): Promise<void> | void { return this.cmds.get(id)?.run(); }
}
```
```ts
// src/main.ts (hook snippet)
window.addEventListener("keydown", (e) => {
if (e.key === "`") overlay.toggle();
});
```
- [ ] **Step 4: 手工验证通过**
Run: `npm run dev`
Expected:
- 按快捷键可开关 overlay
- overlay 显示场景状态/事件列表/资源摘要
- 命令 `scene.goto(welcome)` 可执行并反映到状态
- [ ] **Step 5: 提交**
```bash
git add src/devtools src/ui-core src/main.ts
git commit -m "feat(devtools): add debug overlay and runtime command palette"
```
---
### Task 6: 收口迁移与回归验证
**Files:**
- Modify: `src/scene/BaseScene.ts`
- Modify: `src/stages/page_init.ts`
- Modify: `src/stages/initSceneLayout.ts`
- Modify: `src/stages/welcome/page_welcome.ts`
- Modify: `src/stages/welcome2/page_welcome2.ts`
- Test: 全量测试 + 构建验证
- [ ] **Step 1: 写迁移校验测试(至少一个场景完整生命周期)**
```ts
import { describe, expect, it } from "vitest";
import { SceneStateMachine } from "@/scene/SceneStateMachine";
it("completes full lifecycle for migrated scene", () => {
const sm = new SceneStateMachine("welcome");
sm.transition("setup");
sm.transition("assetsLoading");
sm.transition("entering");
sm.transition("ready");
expect(sm.state).toBe("ready");
});
```
- [ ] **Step 2: 运行该测试确认失败(迁移前不满足)**
Run: `npm run test -- tests/kernel/scene-state-machine.test.ts`
Expected: FAIL(若场景未按新生命周期接线)
- [ ] **Step 3: 迁移场景到新接口并清理旧调用路径**
```ts
// BaseScene new interface sketch
export interface RuntimeScene {
setup(ctx: SceneContext): Promise<void> | void;
enter(ctx: SceneContext): Promise<void> | void;
leave(ctx: SceneContext): Promise<void> | void;
dispose(ctx: SceneContext): Promise<void> | void;
}
```
- [ ] **Step 4: 运行全量验证**
Run: `npm run test`
Expected: PASS (all tests)
Run: `npm run build`
Expected: build success with generated dist assets
- [ ] **Step 5: 提交**
```bash
git add src/scene/BaseScene.ts src/stages tests package.json
git commit -m "refactor(scene): migrate scenes to runtime lifecycle and finalize rollout"
```
---
## Final Verification Checklist
- [ ] 所有测试通过:`npm run test`
- [ ] 构建通过:`npm run build`
- [ ] 开发态工具可用:`npm run dev` + overlay/palette 手工检查
- [ ] 无遗留占位项(无“待补充/稍后实现”字样)

1363
package-lock.json

File diff suppressed because it is too large

BIN
public/bg.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

BIN
public/controller_prompt_bg.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

8
public/manifest.json

@ -4,14 +4,6 @@
"name":"load-screen",
"assets":[
{
"alias":"cbg",
"src":"/controller_prompt_bg.png"
},
{
"alias":"bg",
"src":"/bg.png"
},
{
"alias":"dnf",
"src":"https://www.kkkk1000.com/images/learnPixiJS-AnimatedSprite/dnf.png"
},

52
src/devtools/overlay/DebugOverlay.ts

@ -19,8 +19,10 @@ export class DebugOverlay {
private readonly assetWidget: AssetWidget;
private readonly eventWidget: EventWidget;
private readonly commandPalette = new CommandPalette();
private readonly statusText = new UiButton("Ready", () => {});
private visible = false;
private readonly offCallbacks: Array<() => void> = [];
private statusTimer: number | null = null;
private readonly onWindowKeydown = (event: KeyboardEvent) => {
if (event.defaultPrevented) {
return;
@ -59,6 +61,9 @@ export class DebugOverlay {
const refreshBtn = new UiButton("Refresh", () => this.refresh());
this.root.appendChild(refreshBtn.element);
this.statusText.element.style.cursor = "default";
this.statusText.element.style.opacity = "0.9";
this.root.appendChild(this.statusText.element);
this.registerCommands();
this.bindKeys();
@ -105,9 +110,20 @@ export class DebugOverlay {
title: "Reload Current Scene",
run: () => {
const current = this.deps.sceneManager.currentScene;
if (current) {
void this.deps.sceneManager.changeScene(current.name);
if (!current) {
this.showStatus("No current scene", "warn");
return;
}
this.showStatus(`Reloading ${current.name}...`, "info");
void this.deps.sceneManager
.changeScene(current.name)
.then(() => {
this.showStatus(`Reloaded ${current.name}`, "success");
})
.catch((error: unknown) => {
const msg = error instanceof Error ? error.message : String(error);
this.showStatus(`Reload failed: ${msg}`, "error");
});
},
});
}
@ -118,6 +134,10 @@ export class DebugOverlay {
off();
}
this.offCallbacks.length = 0;
if (this.statusTimer !== null) {
window.clearTimeout(this.statusTimer);
this.statusTimer = null;
}
this.eventWidget.dispose();
this.commandPalette.dispose();
this.root.remove();
@ -128,6 +148,34 @@ export class DebugOverlay {
this.assetWidget.render();
this.eventWidget.render();
}
private showStatus(
text: string,
level: "info" | "success" | "warn" | "error" = "info"
): void {
this.statusText.element.textContent = text;
const color =
level === "success"
? "#22c55e"
: level === "warn"
? "#f59e0b"
: level === "error"
? "#ef4444"
: "#60a5fa";
this.statusText.element.style.background = "rgba(15,23,42,0.75)";
this.statusText.element.style.borderColor = color;
this.statusText.element.style.color = color;
if (this.statusTimer !== null) {
window.clearTimeout(this.statusTimer);
}
this.statusTimer = window.setTimeout(() => {
this.statusText.element.textContent = "Ready";
this.statusText.element.style.borderColor = "rgba(148,163,184,0.45)";
this.statusText.element.style.color = "#f8fafc";
this.statusTimer = null;
}, 2200);
}
}
function isEditableTarget(target: EventTarget | null): boolean {

4
src/init.ts

@ -36,6 +36,7 @@ export async function initApp(): Promise<void> {
await game.init();
const sceneModules = import.meta.glob("./stages/**/page_*.ts", { eager: true });
const enabledSceneNames = new Set(["init", "00_global"]);
for (const path in sceneModules) {
try {
@ -43,6 +44,9 @@ export async function initApp(): Promise<void> {
const match = path.match(/page_(.*?)\.ts$/);
if (!match) continue;
const fileSceneName = match[1];
if (!enabledSceneNames.has(fileSceneName)) {
continue;
}
const raw = (mod as { default: unknown }).default;
if (typeof raw !== "function") {

14
src/scene/BaseScene.ts

@ -1,5 +1,6 @@
import { Container, Ticker } from "pixi.js";
import { SceneType } from "@/enums/SceneType";
import type { SceneLoadingOverlayConfig, SceneLoadingOverlayState } from "./SceneLoadingOverlay";
import type { IBaseScene } from "./types";
export abstract class BaseScene implements IBaseScene {
@ -9,6 +10,7 @@ export abstract class BaseScene implements IBaseScene {
_assetsLoaded: boolean = false;
_layoutDone: boolean = false;
_loadingReporter?: (state?: SceneLoadingOverlayState) => void;
constructor(name: string, type: SceneType = SceneType.Normal) {
this.name = name;
@ -31,6 +33,18 @@ export abstract class BaseScene implements IBaseScene {
protected onSceneEnter(): Promise<void> | void {}
protected onSceneExit(): Promise<void> | void {}
protected getSceneLoadingOverlayConfig(): SceneLoadingOverlayConfig | undefined {
return undefined;
}
protected reportSceneLoading(state?: SceneLoadingOverlayState): void {
this._loadingReporter?.(state);
}
getLoadingOverlayConfig(): SceneLoadingOverlayConfig | undefined {
return this.getSceneLoadingOverlayConfig();
}
// 旧生命周期接口:与当前 runtime/transaction 对齐,内部桥接到新钩子
async loadBundle(): Promise<void> {
await this.onSceneLoadBundle();

165
src/scene/SceneLoadingOverlay.ts

@ -0,0 +1,165 @@
import Game from "@/core/Game";
import { Container, Graphics, Text, TextStyle } from "pixi.js";
export interface SceneLoadingOverlayConfig {
enabled?: boolean;
minDisplayMs?: number;
maskAlpha?: number;
safeTop?: number;
safeBottom?: number;
safeHorizontal?: number;
panelWidth?: number;
panelHeight?: number;
title?: string;
loadingText?: string;
readyText?: string;
}
export interface SceneLoadingOverlayState {
progress?: number;
text?: string;
}
const DEFAULT_CONFIG: Required<SceneLoadingOverlayConfig> = {
enabled: true,
minDisplayMs: 350,
maskAlpha: 0.55,
safeTop: 24,
safeBottom: 34,
safeHorizontal: 16,
panelWidth: 460,
panelHeight: 138,
title: "资源加载中",
loadingText: "加载中",
readyText: "即将就绪",
};
export class SceneLoadingOverlay {
private readonly game = Game.getInstance();
private readonly root = new Container();
private readonly mask = new Graphics();
private readonly panel = new Graphics();
private readonly progressBg = new Graphics();
private readonly progressFill = new Graphics();
private readonly titleText = new Text();
private readonly statusText = new Text();
private config: Required<SceneLoadingOverlayConfig> = DEFAULT_CONFIG;
private mounted = false;
private startedAt = 0;
private progress = 0;
constructor() {
this.root.zIndex = 999999;
this.root.label = "scene-loading-overlay";
this.titleText.style = new TextStyle({
fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif",
fontSize: 20,
fontWeight: "700",
fill: 0xffffff,
align: "center",
letterSpacing: 1,
});
this.titleText.anchor.set(0.5);
this.statusText.style = new TextStyle({
fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif",
fontSize: 18,
fill: 0xd6ecff,
align: "center",
letterSpacing: 1,
});
this.statusText.anchor.set(0.5);
this.root.addChild(
this.mask,
this.panel,
this.progressBg,
this.progressFill,
this.titleText,
this.statusText
);
}
show(config?: SceneLoadingOverlayConfig): void {
this.config = { ...DEFAULT_CONFIG, ...(config ?? {}) };
if (!this.config.enabled) {
return;
}
this.startedAt = performance.now();
this.progress = 0;
this.titleText.text = this.config.title;
this.update({ progress: 0 });
if (!this.mounted) {
this.game.stage.addChild(this.root);
this.mounted = true;
}
}
update(state?: SceneLoadingOverlayState): void {
if (!this.config.enabled || !this.mounted) {
return;
}
if (typeof state?.progress === "number") {
this.progress = Math.max(0, Math.min(1, state.progress));
}
const { width: W, height: H } = this.game.getInfo();
const safeTop = Math.max(0, this.config.safeTop);
const safeBottom = Math.max(0, this.config.safeBottom);
const safeHorizontal = Math.max(0, this.config.safeHorizontal);
const usableW = Math.max(240, W - safeHorizontal * 2);
const usableH = Math.max(120, H - safeTop - safeBottom);
const panelW = Math.min(this.config.panelWidth, Math.max(280, usableW * 0.76));
const panelH = this.config.panelHeight;
const panelX = (W - panelW) / 2;
const idealPanelY = safeTop + usableH * 0.5 - panelH / 2;
const panelY = Math.max(safeTop, Math.min(idealPanelY, H - safeBottom - panelH));
const barW = panelW - 56;
const barH = 18;
const barX = panelX + 28;
const barY = panelY + 62;
const fillW = Math.max(10, barW * this.progress);
this.mask.clear();
this.mask.rect(0, 0, W, H);
this.mask.fill({ color: 0x04070d, alpha: this.config.maskAlpha });
this.panel.clear();
this.panel.roundRect(panelX, panelY, panelW, panelH, 18);
this.panel.fill({ color: 0x0f1f35, alpha: 0.9 });
this.panel.stroke({ color: 0x7fc0ff, alpha: 0.28, width: 2 });
this.progressBg.clear();
this.progressBg.roundRect(barX, barY, barW, barH, 9);
this.progressBg.fill({ color: 0x28425f, alpha: 0.96 });
this.progressFill.clear();
this.progressFill.roundRect(barX, barY, fillW, barH, 9);
this.progressFill.fill({ color: 0x66d9ff, alpha: 0.98 });
const percent = Math.round(this.progress * 100);
this.titleText.position.set(W / 2, panelY + 35);
this.statusText.position.set(W / 2, panelY + 102);
this.statusText.text =
state?.text ?? (percent >= 100 ? this.config.readyText : `${this.config.loadingText} ${percent}%`);
}
async hide(): Promise<void> {
if (!this.config.enabled || !this.mounted) {
return;
}
const elapsed = performance.now() - this.startedAt;
if (elapsed < this.config.minDisplayMs) {
await new Promise((resolve) => setTimeout(resolve, this.config.minDisplayMs - elapsed));
}
this.progress = 1;
this.update({ progress: 1, text: this.config.readyText });
this.game.stage.removeChild(this.root);
this.mounted = false;
}
}
export default SceneLoadingOverlay;

100
src/scene/SceneManager.ts

@ -3,6 +3,7 @@ import Game from "@/core/Game";
import { logger } from "@/core/Logger";
import { SceneType } from "@/enums/SceneType";
import { BaseScene } from "./BaseScene";
import SceneLoadingOverlay from "./SceneLoadingOverlay";
import { TransitionTransaction } from "./TransitionTransaction";
import type { IBaseScene } from "./types";
@ -30,6 +31,7 @@ class SceneManager {
private scenes: Map<string, IBaseScene> = new Map();
private _currentScene: IBaseScene | null = null;
private changeCallbacks: StageChangeCallback[] = [];
private loadingOverlay = new SceneLoadingOverlay();
private constructor() {
this.game = Game.getInstance();
@ -107,15 +109,7 @@ class SceneManager {
scene.stage.visible = true;
}
if (!scene._assetsLoaded) {
await scene.loadBundle?.();
scene._assetsLoaded = true;
}
if (!scene._layoutDone) {
await scene.layout?.();
scene._layoutDone = true;
}
await scene.onLoad?.();
await this.runSceneEnterLifecycle(scene);
await this.emitStageChange(scene, undefined);
logger.debug(`SceneManager: initialized scene "${name}"`);
@ -134,6 +128,10 @@ class SceneManager {
}
const target = this.getSceneOrThrow(name);
if (target === previous) {
await this.reloadCurrentScene(previous);
return;
}
let previousVisibleBefore = true;
let previousMountedBefore = false;
let targetVisibleBefore = true;
@ -179,17 +177,7 @@ class SceneManager {
}
// 生命周期
if (!target._assetsLoaded) {
await target.loadBundle?.();
target._assetsLoaded = true;
}
if (!target._layoutDone) {
await target.layout?.();
target._layoutDone = true;
}
await target.onLoad?.();
await this.runSceneEnterLifecycle(target);
// 触发回调
await this.emitStageChange(target, previous);
@ -236,6 +224,41 @@ class SceneManager {
await transaction.execute();
}
/**
*
* stage
*/
private async reloadCurrentScene(scene: IBaseScene): Promise<void> {
if (scene.type === SceneType.Normal) {
const oldStage = scene.stage;
if (this.game.stage.children.includes(oldStage)) {
this.game.stage.removeChild(oldStage);
}
oldStage.destroy({ children: true });
if (scene._assetsLoaded) {
await scene.unLoadBundle?.();
scene._assetsLoaded = false;
}
await scene.onUnLoad?.();
const nextStage = new Container();
nextStage.label = scene.name;
(scene as { stage: Container }).stage = nextStage;
scene._layoutDone = false;
this.game.stage.addChild(nextStage);
} else {
scene.stage.visible = false;
await scene.onUnLoad?.();
scene.stage.visible = true;
}
await this.runSceneEnterLifecycle(scene);
await this.emitStageChange(scene, scene);
logger.debug(`SceneManager: reloaded scene "${scene.name}"`);
}
/** 获取已注册场景 */
getScene(name: string): IBaseScene | undefined {
if (!name) {
@ -299,6 +322,43 @@ class SceneManager {
return this.scenes.has(name);
}
private async runSceneEnterLifecycle(scene: IBaseScene): Promise<void> {
const base = scene instanceof BaseScene ? scene : undefined;
const config = base?.getLoadingOverlayConfig?.();
const enableOverlay = config?.enabled ?? true;
if (enableOverlay) {
this.loadingOverlay.show(config);
base!._loadingReporter = (state) => {
this.loadingOverlay.update(state);
};
}
try {
if (!scene._assetsLoaded) {
await scene.loadBundle?.();
scene._assetsLoaded = true;
}
if (!scene._layoutDone) {
await scene.layout?.();
scene._layoutDone = true;
}
await scene.onLoad?.();
if (enableOverlay) {
await this.loadingOverlay.hide();
}
} catch (error) {
if (enableOverlay) {
await this.loadingOverlay.hide();
}
throw error;
} finally {
if (base) {
base._loadingReporter = undefined;
}
}
}
}
export default SceneManager;

35
src/stages/_global/page_00_global.ts

@ -10,6 +10,10 @@ const GLOBAL_BGM_URL = "/bg.mp3";
export default class Global extends BaseScene {
stage: Container = new Container();
private bgmStarted = false;
private readonly resumeBgmOnInteraction = (): void => {
this.tryStartGlobalBgm();
};
constructor() {
super("00_global", SceneType.Resident);
@ -49,12 +53,37 @@ export default class Global extends BaseScene {
// this.stage.addChild(this.btn.getView());
}
onLoad(): void {
async onLoad(): Promise<void> {
logger.info("Resident scene 00_global onLoad");
soundManager.play(GLOBAL_BGM_ALIAS, { loop: true, volume: 0.4 });
this.bindAutoPlayGuards();
this.tryStartGlobalBgm();
}
onUnLoad(): void {
async onUnLoad(): Promise<void> {
this.unbindAutoPlayGuards();
soundManager.stop(GLOBAL_BGM_ALIAS);
this.bgmStarted = false;
}
private tryStartGlobalBgm(): void {
if (this.bgmStarted) {
return;
}
soundManager.play(GLOBAL_BGM_ALIAS, { loop: true, volume: 0.4 });
this.bgmStarted = true;
this.unbindAutoPlayGuards();
}
private bindAutoPlayGuards(): void {
const opts: AddEventListenerOptions = { passive: true };
window.addEventListener("pointerdown", this.resumeBgmOnInteraction, opts);
window.addEventListener("keydown", this.resumeBgmOnInteraction);
window.addEventListener("touchstart", this.resumeBgmOnInteraction, opts);
}
private unbindAutoPlayGuards(): void {
window.removeEventListener("pointerdown", this.resumeBgmOnInteraction);
window.removeEventListener("keydown", this.resumeBgmOnInteraction);
window.removeEventListener("touchstart", this.resumeBgmOnInteraction);
}
}

15
src/stages/initSceneLayout.ts

@ -1,15 +0,0 @@
/**
* Init Game.getInfo designWidth
*
*/
export const initSceneLayout = {
titleYRatio: 0.12,
subtitleYRatio: 0.2,
heroYRatio: 0.46,
/** 主按钮相对底边的上移(逻辑像素,与 position.get 的 options.y 一致) */
startCtaOffsetY: -110,
/** 首屏进入欢迎页时的切场景策略(对齐 scene transaction 参数) */
nextSceneTransition: {
isHolderLast: false,
},
} as const;

64
src/stages/page_init.ts

@ -3,34 +3,35 @@ import { assetManager } from "@/core/AssetManager";
import Game from "@/core/Game";
import { BaseScene } from "@/scene/BaseScene";
import { SceneType } from "@/enums/SceneType";
import { initSceneLayout } from "@/stages/initSceneLayout";
import position from "@/utils/Position";
import {
AnimatedSprite,
Container,
Rectangle,
Sprite,
Text,
TextStyle,
Texture,
Ticker,
} from "pixi.js";
const Z_BG = 0;
const Z_HERO = 2;
const Z_TEXT = 3;
const INIT_LAYOUT = {
titleYRatio: 0.12,
subtitleYRatio: 0.2,
heroYRatio: 0.46,
startCtaOffsetY: -110,
} as const;
export default class InitScene extends BaseScene {
stage = new Container();
private game = Game.getInstance();
private assets: Record<string, Texture> = {} as Record<string, Texture>;
private bg?: Sprite;
private titleText?: Text;
private subtitleText?: Text;
private pixie?: AnimatedSprite;
private startBtn?: Button;
private loadingBtn?: Button;
private readonly onResize = (): void => {
this.placeHeroElements();
@ -41,29 +42,13 @@ export default class InitScene extends BaseScene {
}
protected async onSceneLoadBundle(): Promise<void> {
this.loadingBtn = new Button({
text: "加载中…",
position: () => position.get("center", "center"),
onClick: () => {},
fontSize: 22,
padding: { x: 48, y: 28 },
});
this.loadingBtn._comp.zIndex = 100;
this.stage.addChild(this.loadingBtn.getView());
const bundle = await assetManager.loadBundle("load-screen", (progress: number) => {
this.loadingBtn!.textObj.text =
progress >= 1 ? "即将就绪" : `加载中 ${Math.round(progress * 100)}%`;
this.reportSceneLoading({ progress });
});
// 延迟3秒
await new Promise(resolve => setTimeout(resolve, 3000));
if (bundle) {
this.assets = bundle as unknown as Record<string, Texture>;
}
this.stage.removeChild(this.loadingBtn._comp);
this.loadingBtn = undefined;
}
protected async onSceneUnloadBundle(): Promise<void> {
@ -74,13 +59,6 @@ export default class InitScene extends BaseScene {
this.stage.sortableChildren = true;
this.stage.eventMode = "passive";
const texBg = this.assets["bg"] ?? this.assets["cbg"];
if (texBg) {
this.bg = Sprite.from(texBg);
this.bg.zIndex = Z_BG;
this.stage.addChild(this.bg);
}
const titleStyle = new TextStyle({
fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif",
fontSize: 44,
@ -145,11 +123,11 @@ export default class InitScene extends BaseScene {
padding: { x: 72, y: 36 },
position: () =>
position.get("center", "bottom", {
y: initSceneLayout.startCtaOffsetY,
y: INIT_LAYOUT.startCtaOffsetY,
x: 0,
}),
onClick: () => {
this.changeScene("welcome", initSceneLayout.nextSceneTransition);
// 当前版本仅保留开始界面,先保留按钮交互占位。
},
});
this.startBtn._comp.zIndex = Z_TEXT;
@ -171,19 +149,19 @@ export default class InitScene extends BaseScene {
this.pixie?.update(ticker);
}
protected getSceneLoadingOverlayConfig() {
return {
title: "初始化资源中",
loadingText: "正在准备",
minDisplayMs: 420,
safeTop: 28,
safeBottom: 42,
};
}
private placeHeroElements(): void {
const { width: W, height: H } = this.game.getInfo();
const L = initSceneLayout;
/* 全屏层:背景 cover */
if (this.bg) {
const tw = this.bg.texture.width;
const th = this.bg.texture.height;
const scale = Math.max(W / tw, H / th);
this.bg.setSize(tw * scale, th * scale);
this.bg.anchor.set(0.5);
this.bg.position.set(W / 2, H / 2);
}
const { height: H } = this.game.getInfo();
const L = INIT_LAYOUT;
/* 上区:标题 / 副标题(锚点中心,与 position 语义一致) */
if (this.titleText) {

15
src/stages/welcome/circle.ts

@ -1,15 +0,0 @@
import { Graphics} from "pixi.js"
export default new (class Circle{
render(){
const circle = new Graphics();
circle.label = "circle";
circle.circle(0, 0, 32);
circle.fill(0xfb6a8f);
circle.x = 130;
circle.y = 130;
circle.interactive = true;
// circle.buttonMode = true;
return circle
}
})

34
src/stages/welcome/page_welcome.ts

@ -1,34 +0,0 @@
import Button from "@/components/Button";
import { Container } from "pixi.js";
import { BaseScene } from "@/scene/BaseScene";
import { SceneType } from "@/enums/SceneType";
import position from "@/utils/Position";
export default class WelcomeScene extends BaseScene {
stage: Container = new Container();
private startBtn?: Button;
constructor() {
super("welcome", SceneType.Normal);
}
protected onSceneLayout(): void {
this.startBtn = new Button({
text: "进入游戏",
onClick: () => {
this.changeScene("welcome2");
},
position: () => position.center(),
});
this.stage.addChild(this.startBtn.getView());
}
protected onSceneEnter(): void {
console.log("welcome onLoad");
}
protected onSceneExit(): void {
console.log("welcome onUnLoad");
}
}

46
src/stages/welcome2/page_welcome2.ts

@ -1,46 +0,0 @@
import { BaseScene } from "@/scene/BaseScene";
import { SceneType } from "@/enums/SceneType";
import { Container, FederatedPointerEvent, Graphics } from "pixi.js";
export default class Welcome2Scene extends BaseScene {
stage = new Container();
private circle?: Graphics;
private goWelcome = (_e: FederatedPointerEvent): void => {
if (!this.circle) {
return;
}
this.circle.x += 10;
this.changeScene("welcome");
};
constructor() {
super("welcome2", SceneType.Normal);
}
protected onSceneLayout(): void {
this.circle = new Graphics();
this.circle.label = "circle";
this.circle.circle(0, 0, 32);
this.circle.fill(0xfb6a8f);
this.circle.x = 130;
this.circle.y = 300;
this.circle.eventMode = "static";
this.circle.cursor = "pointer";
this.circle.on("touchend", this.goWelcome);
this.circle.on("mousedown", this.goWelcome);
this.stage.addChild(this.circle);
}
protected onSceneEnter(): void {
console.log("onLoad 2");
}
protected onSceneExit(): void {
if (this.circle) {
this.circle.off("touchend", this.goWelcome);
this.circle.off("mousedown", this.goWelcome);
}
console.log("onUnLoad 2");
}
}

32
tests/kernel/transition-transaction.test.ts

@ -152,4 +152,36 @@ describe("SceneManager rollback safety", () => {
expect(stage.children.includes(previous.stage as never)).toBe(true);
expect(stage.children.includes(target.stage as never)).toBe(false);
});
it("reloads current scene without leaving stage empty", async () => {
const module = await import("@/scene/SceneManager");
const SceneManager = module.default;
const manager = SceneManager.getInstance();
const current = createScene("same", SceneType.Normal, {
_assetsLoaded: true,
_layoutDone: true,
onUnLoad: vi.fn(async () => {}),
unLoadBundle: vi.fn(async () => {}),
loadBundle: vi.fn(async () => {}),
layout: vi.fn(async () => {}),
onLoad: vi.fn(async function (this: IBaseScene) {
(this.stage as unknown as MockContainer).addChild(new MockContainer());
}),
});
manager.registerScene(current);
await manager.initScene("same");
const beforeStage = current.stage;
await manager.changeScene("same");
const afterStage = current.stage as unknown as MockContainer;
const rootStage = manager["game"].stage as unknown as MockContainer;
expect(manager.currentScene).toBe(current);
expect(afterStage).not.toBe(beforeStage);
expect(afterStage.children.length).toBeGreaterThan(0);
expect(rootStage.children.includes(afterStage as never)).toBe(true);
});
});

Loading…
Cancel
Save