From 5977d399f4a4d548597dc1a72b3ba091c7f09a85 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sun, 26 Apr 2026 21:37:05 +0800 Subject: [PATCH] refactor(kernel): introduce app runtime and event bus plugin system Add AppRuntime as the central runtime orchestrator and migrate init wiring to runtime-managed game/scene/event instances. Introduce RuntimeEvents plus plugin hooks so scene-change and render lifecycle signals are extensible without breaking existing init exports. Made-with: Cursor --- src/core/Game.ts | 6 +++ src/init.ts | 29 +++++++++++--- src/kernel/AppRuntime.ts | 60 ++++++++++++++++++++++++++++ src/kernel/RuntimeEvents.ts | 59 +++++++++++++++++++++++++++ src/kernel/RuntimePlugin.ts | 14 +++++++ tests/kernel/runtime-events.test.ts | 80 +++++++++++++++++++++++++++++++++++++ 6 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 src/kernel/AppRuntime.ts create mode 100644 src/kernel/RuntimeEvents.ts create mode 100644 src/kernel/RuntimePlugin.ts create mode 100644 tests/kernel/runtime-events.test.ts diff --git a/src/core/Game.ts b/src/core/Game.ts index e399eed..ad3959c 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -31,6 +31,7 @@ class Game { /** 连续旋转时忽略过期的 setTimeout 回调 */ private _orientationLayoutGen = 0; private _rootResizeObserver: ResizeObserver | null = null; + private _afterRender: (() => void) | null = null; private constructor() {} @@ -282,6 +283,11 @@ class Game { render(): void { this.renderer.render(this.stage); + this._afterRender?.(); + } + + setAfterRenderHook(hook: (() => void) | null): void { + this._afterRender = hook; } } diff --git a/src/init.ts b/src/init.ts index 232329a..ea80a11 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1,19 +1,35 @@ -import Game from "./core/Game"; -import SceneManager from "./scene/SceneManager"; import { BaseScene } from "./scene/BaseScene"; import type { IBaseScene } from "./scene/types"; import { SceneType } from "./enums/SceneType"; import { assetManager } from "./core/AssetManager"; import { logger } from "./core/Logger"; -import eventBus from "./core/EventBus"; import { Container } from "pixi.js"; - -const game = Game.getInstance(); -const sceneManager = SceneManager.getInstance(); +import { appRuntime } from "./kernel/AppRuntime"; +import type { RuntimePlugin } from "./kernel/RuntimePlugin"; + +const game = appRuntime.game; +const sceneManager = appRuntime.sceneManager; +const eventBus = appRuntime.events; + +const runtimeSceneEventsPlugin: RuntimePlugin = { + name: "runtime-scene-events", + setup: ({ game: runtimeGame, sceneManager: runtimeSceneManager, events }) => { + runtimeGame.setAfterRenderHook(() => { + events.emit("game:rendered", undefined); + }); + runtimeSceneManager.onStageChange((current, previous) => { + events.emit("scene:changed", { + current: current.name, + previous: previous?.name, + }); + }); + }, +}; type Constructor = new (...args: unknown[]) => T; export async function initApp(): Promise { + appRuntime.use(runtimeSceneEventsPlugin); await assetManager.init(); await game.init(); @@ -121,6 +137,7 @@ export async function initApp(): Promise { }); logger.info("App initialized"); + appRuntime.markReady(); } export { game, sceneManager, eventBus, assetManager, logger }; diff --git a/src/kernel/AppRuntime.ts b/src/kernel/AppRuntime.ts new file mode 100644 index 0000000..b783301 --- /dev/null +++ b/src/kernel/AppRuntime.ts @@ -0,0 +1,60 @@ +import Game from "@/core/Game"; +import SceneManager from "@/scene/SceneManager"; +import { RuntimeEvents } from "./RuntimeEvents"; +import type { RuntimePlugin, RuntimePluginContext } from "./RuntimePlugin"; + +export class AppRuntime { + private static instance: AppRuntime; + + public readonly game: Game; + public readonly sceneManager: SceneManager; + public readonly events: RuntimeEvents; + + private readonly installedPlugins = new Set(); + private startedAt = 0; + + constructor( + game: Game = Game.getInstance(), + sceneManager: SceneManager = SceneManager.getInstance(), + events: RuntimeEvents = new RuntimeEvents() + ) { + this.game = game; + this.sceneManager = sceneManager; + this.events = events; + } + + static getInstance(): AppRuntime { + if (!AppRuntime.instance) { + AppRuntime.instance = new AppRuntime(); + } + return AppRuntime.instance; + } + + get pluginContext(): RuntimePluginContext { + return { + game: this.game, + sceneManager: this.sceneManager, + events: this.events, + }; + } + + use(plugin: RuntimePlugin): this { + if (this.installedPlugins.has(plugin.name)) { + return this; + } + + plugin.setup(this.pluginContext); + this.installedPlugins.add(plugin.name); + return this; + } + + markReady(): void { + if (this.startedAt > 0) { + return; + } + this.startedAt = Date.now(); + this.events.emit("runtime:ready", { startedAt: this.startedAt }); + } +} + +export const appRuntime = AppRuntime.getInstance(); diff --git a/src/kernel/RuntimeEvents.ts b/src/kernel/RuntimeEvents.ts new file mode 100644 index 0000000..1f83a73 --- /dev/null +++ b/src/kernel/RuntimeEvents.ts @@ -0,0 +1,59 @@ +type EventCallback = (payload: T) => void; + +export interface RuntimeEventMap { + "runtime:ready": { startedAt: number }; + "scene:changed": { current: string; previous?: string }; + "game:rendered": undefined; +} + +export class RuntimeEvents { + private events = new Map>(); + + on(event: string, callback: EventCallback): () => void { + if (!this.events.has(event)) { + this.events.set(event, new Set()); + } + + this.events.get(event)!.add(callback as EventCallback); + return () => this.off(event, callback); + } + + off(event: string, callback: EventCallback): void { + const callbacks = this.events.get(event); + if (!callbacks) { + return; + } + + callbacks.delete(callback as EventCallback); + if (callbacks.size === 0) { + this.events.delete(event); + } + } + + once(event: string, callback: EventCallback): () => void { + const wrapped: EventCallback = (payload) => { + callback(payload); + this.off(event, wrapped); + }; + return this.on(event, wrapped); + } + + emit(event: string, payload: T): void { + const callbacks = this.events.get(event); + if (!callbacks) { + return; + } + + callbacks.forEach((cb) => { + cb(payload); + }); + } + + clear(): void { + this.events.clear(); + } + + getEventCount(): number { + return this.events.size; + } +} diff --git a/src/kernel/RuntimePlugin.ts b/src/kernel/RuntimePlugin.ts new file mode 100644 index 0000000..137da1d --- /dev/null +++ b/src/kernel/RuntimePlugin.ts @@ -0,0 +1,14 @@ +import type Game from "@/core/Game"; +import type SceneManager from "@/scene/SceneManager"; +import type { RuntimeEvents } from "./RuntimeEvents"; + +export interface RuntimePluginContext { + game: Game; + sceneManager: SceneManager; + events: RuntimeEvents; +} + +export interface RuntimePlugin { + name: string; + setup: (context: RuntimePluginContext) => void | (() => void); +} diff --git a/tests/kernel/runtime-events.test.ts b/tests/kernel/runtime-events.test.ts new file mode 100644 index 0000000..36f9fe7 --- /dev/null +++ b/tests/kernel/runtime-events.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; +import { RuntimeEvents } from "@/kernel/RuntimeEvents"; +import { AppRuntime } from "@/kernel/AppRuntime"; +import type { RuntimePlugin } from "@/kernel/RuntimePlugin"; + +vi.mock("@/core/Game", () => { + class MockGame { + private static instance: MockGame; + + static getInstance(): MockGame { + if (!MockGame.instance) { + MockGame.instance = new MockGame(); + } + return MockGame.instance; + } + } + + return { default: MockGame }; +}); + +vi.mock("@/scene/SceneManager", () => { + class MockSceneManager { + private static instance: MockSceneManager; + + static getInstance(): MockSceneManager { + if (!MockSceneManager.instance) { + MockSceneManager.instance = new MockSceneManager(); + } + return MockSceneManager.instance; + } + } + + return { default: MockSceneManager }; +}); + +describe("RuntimeEvents", () => { + it("supports on/off/emit", () => { + const events = new RuntimeEvents(); + const handler = vi.fn(); + + events.on("runtime:ready", handler); + events.emit("runtime:ready", { startedAt: 1 }); + events.off("runtime:ready", handler); + events.emit("runtime:ready", { startedAt: 2 }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ startedAt: 1 }); + }); + + it("supports once subscriptions", () => { + const events = new RuntimeEvents(); + const handler = vi.fn(); + + events.once("scene:changed", handler); + events.emit("scene:changed", { current: "a", previous: undefined }); + events.emit("scene:changed", { current: "b", previous: "a" }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ current: "a", previous: undefined }); + }); +}); + +describe("AppRuntime plugin lifecycle", () => { + it("installs plugin and keeps compatibility exports", () => { + const runtime = new AppRuntime(); + const setup = vi.fn(); + const plugin: RuntimePlugin = { + name: "test-plugin", + setup, + }; + + runtime.use(plugin); + runtime.use(plugin); + + expect(setup).toHaveBeenCalledTimes(1); + expect(runtime.game).toBeDefined(); + expect(runtime.sceneManager).toBeDefined(); + expect(runtime.events).toBeDefined(); + }); +});