10 changed files with 432 additions and 1 deletions
@ -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<string, RuntimeCommand>(); |
||||
|
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(); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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<string, number>(); |
||||
|
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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue