From 5d70b2562012ef99c1b5237c21e7c505da57fef6 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sun, 26 Apr 2026 21:42:22 +0800 Subject: [PATCH] feat(devtools): add debug overlay and runtime command palette Made-with: Cursor --- src/devtools/CommandPalette.ts | 128 ++++++++++++++++++++++++++++ src/devtools/overlay/DebugOverlay.ts | 108 +++++++++++++++++++++++ src/devtools/overlay/widgets/AssetWidget.ts | 29 +++++++ src/devtools/overlay/widgets/EventWidget.ts | 46 ++++++++++ src/devtools/overlay/widgets/SceneWidget.ts | 28 ++++++ src/main.ts | 9 +- src/ui-core/UiButton.ts | 17 ++++ src/ui-core/UiList.ts | 24 ++++++ src/ui-core/UiPanel.ts | 29 +++++++ src/ui-core/UiText.ts | 15 ++++ 10 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 src/devtools/CommandPalette.ts create mode 100644 src/devtools/overlay/DebugOverlay.ts create mode 100644 src/devtools/overlay/widgets/AssetWidget.ts create mode 100644 src/devtools/overlay/widgets/EventWidget.ts create mode 100644 src/devtools/overlay/widgets/SceneWidget.ts create mode 100644 src/ui-core/UiButton.ts create mode 100644 src/ui-core/UiList.ts create mode 100644 src/ui-core/UiPanel.ts create mode 100644 src/ui-core/UiText.ts diff --git a/src/devtools/CommandPalette.ts b/src/devtools/CommandPalette.ts new file mode 100644 index 0000000..e137d47 --- /dev/null +++ b/src/devtools/CommandPalette.ts @@ -0,0 +1,128 @@ +import { UiList } from "@/ui-core/UiList"; +import { UiPanel } from "@/ui-core/UiPanel"; +import { UiText } from "@/ui-core/UiText"; + +export interface RuntimeCommand { + id: string; + title: string; + run: () => void; +} + +export class CommandPalette { + readonly element: HTMLDivElement; + private readonly root = new UiPanel("Command Palette"); + private readonly hintText = new UiText("按 Ctrl/Cmd + K 打开,Enter 执行"); + private readonly list = new UiList(); + private readonly input = document.createElement("input"); + private readonly commands = new Map(); + private filtered: RuntimeCommand[] = []; + private selectedIndex = 0; + private isOpen = false; + + constructor() { + this.element = this.root.element; + this.element.style.position = "fixed"; + this.element.style.left = "50%"; + this.element.style.top = "72px"; + this.element.style.transform = "translateX(-50%)"; + this.element.style.minWidth = "360px"; + this.element.style.maxWidth = "560px"; + this.element.style.zIndex = "2147483647"; + this.element.style.display = "none"; + this.element.style.fontFamily = "monospace"; + this.element.style.color = "#f1f5f9"; + + this.input.placeholder = "输入命令..."; + this.input.style.width = "100%"; + this.input.style.background = "rgba(15,23,42,0.85)"; + this.input.style.border = "1px solid rgba(148,163,184,0.5)"; + this.input.style.color = "#f8fafc"; + this.input.style.padding = "6px"; + this.input.style.borderRadius = "4px"; + + this.input.addEventListener("input", () => this.refreshFiltered()); + this.input.addEventListener("keydown", (event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + this.selectedIndex = Math.min(this.selectedIndex + 1, this.filtered.length - 1); + this.renderList(); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + this.selectedIndex = Math.max(this.selectedIndex - 1, 0); + this.renderList(); + } else if (event.key === "Enter") { + event.preventDefault(); + this.executeSelected(); + } else if (event.key === "Escape") { + this.hide(); + } + }); + + this.root.append(this.hintText.element, this.input, this.list.element); + document.body.appendChild(this.element); + this.bindKeyboard(); + } + + registerCommand(command: RuntimeCommand): void { + this.commands.set(command.id, command); + this.refreshFiltered(); + } + + toggle(): void { + if (this.isOpen) { + this.hide(); + return; + } + this.show(); + } + + show(): void { + this.isOpen = true; + this.element.style.display = "flex"; + this.input.focus(); + this.input.select(); + this.refreshFiltered(); + } + + hide(): void { + this.isOpen = false; + this.element.style.display = "none"; + this.input.blur(); + } + + private bindKeyboard(): void { + window.addEventListener("keydown", (event) => { + const isToggle = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k"; + if (!isToggle) { + return; + } + event.preventDefault(); + this.toggle(); + }); + } + + private refreshFiltered(): void { + const query = this.input.value.trim().toLowerCase(); + this.filtered = Array.from(this.commands.values()).filter((command) => + command.title.toLowerCase().includes(query) + ); + this.selectedIndex = 0; + this.renderList(); + } + + private renderList(): void { + const items = this.filtered.map((command, index) => + index === this.selectedIndex ? `> ${command.title}` : ` ${command.title}` + ); + this.list.setItems(items.length > 0 ? items : ["无匹配命令"]); + } + + private executeSelected(): void { + const command = this.filtered[this.selectedIndex]; + if (!command) { + return; + } + command.run(); + this.hide(); + } +} diff --git a/src/devtools/overlay/DebugOverlay.ts b/src/devtools/overlay/DebugOverlay.ts new file mode 100644 index 0000000..0558d68 --- /dev/null +++ b/src/devtools/overlay/DebugOverlay.ts @@ -0,0 +1,108 @@ +import type { AssetManager } from "@/core/AssetManager"; +import type { RuntimeEvents } from "@/kernel/RuntimeEvents"; +import type SceneManager from "@/scene/SceneManager"; +import { CommandPalette } from "@/devtools/CommandPalette"; +import { UiButton } from "@/ui-core/UiButton"; +import { SceneWidget } from "./widgets/SceneWidget"; +import { AssetWidget } from "./widgets/AssetWidget"; +import { EventWidget } from "./widgets/EventWidget"; + +export interface DebugOverlayDeps { + sceneManager: SceneManager; + assetManager: AssetManager; + events: RuntimeEvents; +} + +export class DebugOverlay { + private readonly root: HTMLDivElement; + private readonly sceneWidget: SceneWidget; + private readonly assetWidget: AssetWidget; + private readonly eventWidget: EventWidget; + private readonly commandPalette = new CommandPalette(); + private visible = false; + + constructor(private readonly deps: DebugOverlayDeps) { + this.root = document.createElement("div"); + this.root.style.position = "fixed"; + this.root.style.top = "8px"; + this.root.style.left = "8px"; + this.root.style.maxWidth = "320px"; + this.root.style.zIndex = "2147483646"; + this.root.style.display = "none"; + this.root.style.flexDirection = "column"; + this.root.style.gap = "8px"; + this.root.style.fontFamily = "monospace"; + this.root.style.color = "#f8fafc"; + document.body.appendChild(this.root); + + this.sceneWidget = new SceneWidget(deps.sceneManager); + this.assetWidget = new AssetWidget(deps.assetManager); + this.eventWidget = new EventWidget(deps.events); + this.root.append(this.sceneWidget.element, this.assetWidget.element, this.eventWidget.element); + + const refreshBtn = new UiButton("Refresh", () => this.refresh()); + this.root.appendChild(refreshBtn.element); + + this.registerCommands(); + this.bindKeys(); + this.bindEvents(); + } + + toggle(): void { + this.visible = !this.visible; + this.root.style.display = this.visible ? "flex" : "none"; + if (this.visible) { + this.refresh(); + } + } + + private bindKeys(): void { + window.addEventListener("keydown", (event) => { + if (event.key !== "`" && event.code !== "Backquote") { + return; + } + event.preventDefault(); + this.toggle(); + }); + } + + private bindEvents(): void { + this.deps.sceneManager.onStageChange(() => this.refresh()); + this.deps.events.on("scene:changed", () => this.refresh()); + this.deps.events.on("runtime:ready", () => this.refresh()); + this.deps.events.on("game:frame-rendered", () => { + if (this.visible) { + this.eventWidget.render(); + } + }); + } + + private registerCommands(): void { + this.commandPalette.registerCommand({ + id: "overlay.toggle", + title: "Toggle Debug Overlay", + run: () => this.toggle(), + }); + this.commandPalette.registerCommand({ + id: "overlay.refresh", + title: "Refresh Overlay Data", + run: () => this.refresh(), + }); + this.commandPalette.registerCommand({ + id: "scene.reload.current", + title: "Reload Current Scene", + run: () => { + const current = this.deps.sceneManager.currentScene; + if (current) { + void this.deps.sceneManager.changeScene(current.name); + } + }, + }); + } + + private refresh(): void { + this.sceneWidget.render(); + this.assetWidget.render(); + this.eventWidget.render(); + } +} diff --git a/src/devtools/overlay/widgets/AssetWidget.ts b/src/devtools/overlay/widgets/AssetWidget.ts new file mode 100644 index 0000000..ee68276 --- /dev/null +++ b/src/devtools/overlay/widgets/AssetWidget.ts @@ -0,0 +1,29 @@ +import type { AssetManager } from "@/core/AssetManager"; +import { UiList } from "@/ui-core/UiList"; +import { UiPanel } from "@/ui-core/UiPanel"; + +export class AssetWidget { + readonly element: HTMLDivElement; + private readonly list = new UiList(); + private readonly assetManager: AssetManager; + + constructor(assetManager: AssetManager) { + this.assetManager = assetManager; + const panel = new UiPanel("Assets"); + panel.append(this.list.element); + this.element = panel.element; + this.render(); + } + + render(): void { + const snapshot = this.assetManager.getInspectorSnapshot(); + const bundles = Object.entries(snapshot.bundles); + const sessions = Object.keys(snapshot.sessions); + const items = [ + `sessions: ${sessions.length}`, + `bundles: ${bundles.length}`, + ...bundles.slice(0, 5).map(([name, meta]) => `${name}: ref=${meta.refCount}`), + ]; + this.list.setItems(items); + } +} diff --git a/src/devtools/overlay/widgets/EventWidget.ts b/src/devtools/overlay/widgets/EventWidget.ts new file mode 100644 index 0000000..a4d77b1 --- /dev/null +++ b/src/devtools/overlay/widgets/EventWidget.ts @@ -0,0 +1,46 @@ +import type { RuntimeEvents } from "@/kernel/RuntimeEvents"; +import { UiList } from "@/ui-core/UiList"; +import { UiPanel } from "@/ui-core/UiPanel"; + +type RuntimeEventsWithEmit = RuntimeEvents & { + emit: (event: string, ...args: unknown[]) => void; +}; + +export class EventWidget { + readonly element: HTMLDivElement; + private readonly list = new UiList(); + private readonly eventCounts = new Map(); + private readonly maxEvents = 6; + private readonly runtimeEvents: RuntimeEventsWithEmit; + + constructor(runtimeEvents: RuntimeEvents) { + this.runtimeEvents = runtimeEvents as RuntimeEventsWithEmit; + const panel = new UiPanel("Events"); + panel.append(this.list.element); + this.element = panel.element; + this.hookEmit(); + this.render(); + } + + private hookEmit(): void { + const originalEmit = this.runtimeEvents.emit.bind(this.runtimeEvents); + this.runtimeEvents.emit = (event: string, ...args: unknown[]) => { + const count = this.eventCounts.get(event) ?? 0; + this.eventCounts.set(event, count + 1); + this.render(); + originalEmit(event, ...args); + }; + } + + render(): void { + const entries = Array.from(this.eventCounts.entries()).sort((a, b) => b[1] - a[1]); + const items = [ + `event types: ${this.runtimeEvents.getEventCount()}`, + ...entries.slice(0, this.maxEvents).map(([name, count]) => `${name}: ${count}`), + ]; + if (items.length === 1) { + items.push("no events yet"); + } + this.list.setItems(items); + } +} diff --git a/src/devtools/overlay/widgets/SceneWidget.ts b/src/devtools/overlay/widgets/SceneWidget.ts new file mode 100644 index 0000000..f70cc0a --- /dev/null +++ b/src/devtools/overlay/widgets/SceneWidget.ts @@ -0,0 +1,28 @@ +import type SceneManager from "@/scene/SceneManager"; +import { UiList } from "@/ui-core/UiList"; +import { UiPanel } from "@/ui-core/UiPanel"; + +export class SceneWidget { + readonly element: HTMLDivElement; + private readonly list = new UiList(); + private readonly sceneManager: SceneManager; + + constructor(sceneManager: SceneManager) { + this.sceneManager = sceneManager; + const panel = new UiPanel("Scene"); + panel.append(this.list.element); + this.element = panel.element; + this.render(); + } + + render(): void { + const current = this.sceneManager.currentScene?.name ?? "(none)"; + const scenes = this.sceneManager.getAllScenes(); + const items = [ + `current: ${current}`, + `total: ${scenes.length}`, + ...scenes.slice(0, 5).map((scene) => `- ${scene.name}`), + ]; + this.list.setItems(items); + } +} diff --git a/src/main.ts b/src/main.ts index fb5f2d7..3f42e80 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,7 @@ import { Orientation } from "./enums/Orientation"; -import { initApp, game, sceneManager } from "./init"; +import { initApp, game, sceneManager, assetManager } from "./init"; +import { appRuntime } from "./kernel/AppRuntime"; +import { DebugOverlay } from "./devtools/overlay/DebugOverlay"; void (async () => { try { @@ -14,4 +16,9 @@ if (import.meta.env.DEV) { (window as unknown as { game: typeof game; sceneManager: typeof sceneManager }).game = game; (window as unknown as { game: typeof game; sceneManager: typeof sceneManager }).sceneManager = sceneManager; + new DebugOverlay({ + sceneManager: appRuntime.sceneManager, + assetManager, + events: appRuntime.events, + }); } diff --git a/src/ui-core/UiButton.ts b/src/ui-core/UiButton.ts new file mode 100644 index 0000000..3822832 --- /dev/null +++ b/src/ui-core/UiButton.ts @@ -0,0 +1,17 @@ +export class UiButton { + readonly element: HTMLButtonElement; + + constructor(label: string, onClick: () => void) { + this.element = document.createElement("button"); + this.element.type = "button"; + this.element.textContent = label; + this.element.style.border = "1px solid rgba(255,255,255,0.3)"; + this.element.style.background = "rgba(2,6,23,0.85)"; + this.element.style.color = "#f8fafc"; + this.element.style.padding = "4px 8px"; + this.element.style.borderRadius = "4px"; + this.element.style.cursor = "pointer"; + this.element.style.fontSize = "12px"; + this.element.addEventListener("click", onClick); + } +} diff --git a/src/ui-core/UiList.ts b/src/ui-core/UiList.ts new file mode 100644 index 0000000..b3f0bba --- /dev/null +++ b/src/ui-core/UiList.ts @@ -0,0 +1,24 @@ +export class UiList { + readonly element: HTMLUListElement; + + constructor() { + this.element = document.createElement("ul"); + this.element.style.listStyle = "none"; + this.element.style.margin = "0"; + this.element.style.padding = "0"; + this.element.style.display = "flex"; + this.element.style.flexDirection = "column"; + this.element.style.gap = "2px"; + } + + setItems(items: string[]): void { + this.element.replaceChildren(); + for (const item of items) { + const li = document.createElement("li"); + li.textContent = item; + li.style.fontSize = "12px"; + li.style.color = "#d1d5db"; + this.element.appendChild(li); + } + } +} diff --git a/src/ui-core/UiPanel.ts b/src/ui-core/UiPanel.ts new file mode 100644 index 0000000..d9a1108 --- /dev/null +++ b/src/ui-core/UiPanel.ts @@ -0,0 +1,29 @@ +export class UiPanel { + readonly element: HTMLDivElement; + + constructor(title?: string) { + this.element = document.createElement("div"); + this.element.style.border = "1px solid rgba(255,255,255,0.2)"; + this.element.style.background = "rgba(0,0,0,0.45)"; + this.element.style.padding = "8px"; + this.element.style.borderRadius = "6px"; + this.element.style.display = "flex"; + this.element.style.flexDirection = "column"; + this.element.style.gap = "6px"; + + if (title) { + const heading = document.createElement("div"); + heading.textContent = title; + heading.style.fontWeight = "700"; + heading.style.fontSize = "12px"; + heading.style.color = "#7dd3fc"; + this.element.appendChild(heading); + } + } + + append(...children: HTMLElement[]): void { + for (const child of children) { + this.element.appendChild(child); + } + } +} diff --git a/src/ui-core/UiText.ts b/src/ui-core/UiText.ts new file mode 100644 index 0000000..cdbbaf0 --- /dev/null +++ b/src/ui-core/UiText.ts @@ -0,0 +1,15 @@ +export class UiText { + readonly element: HTMLDivElement; + + constructor(text = "") { + this.element = document.createElement("div"); + this.element.style.fontSize = "12px"; + this.element.style.lineHeight = "1.4"; + this.element.style.color = "#e5e7eb"; + this.setText(text); + } + + setText(text: string): void { + this.element.textContent = text; + } +}