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