Browse Source
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: Cursormaster
6 changed files with 242 additions and 6 deletions
@ -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<string>(); |
||||
|
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(); |
||||
@ -0,0 +1,59 @@ |
|||||
|
type EventCallback<T = unknown> = (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<string, Set<EventCallback>>(); |
||||
|
|
||||
|
on<T = unknown>(event: string, callback: EventCallback<T>): () => 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<T = unknown>(event: string, callback: EventCallback<T>): void { |
||||
|
const callbacks = this.events.get(event); |
||||
|
if (!callbacks) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
callbacks.delete(callback as EventCallback); |
||||
|
if (callbacks.size === 0) { |
||||
|
this.events.delete(event); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
once<T = unknown>(event: string, callback: EventCallback<T>): () => void { |
||||
|
const wrapped: EventCallback<T> = (payload) => { |
||||
|
callback(payload); |
||||
|
this.off(event, wrapped); |
||||
|
}; |
||||
|
return this.on(event, wrapped); |
||||
|
} |
||||
|
|
||||
|
emit<T = unknown>(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; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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(); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue