Browse Source

feat(devtools): add debug overlay and runtime command palette

Made-with: Cursor
master
npmrun 1 month ago
parent
commit
5d70b25620
  1. 128
      src/devtools/CommandPalette.ts
  2. 108
      src/devtools/overlay/DebugOverlay.ts
  3. 29
      src/devtools/overlay/widgets/AssetWidget.ts
  4. 46
      src/devtools/overlay/widgets/EventWidget.ts
  5. 28
      src/devtools/overlay/widgets/SceneWidget.ts
  6. 9
      src/main.ts
  7. 17
      src/ui-core/UiButton.ts
  8. 24
      src/ui-core/UiList.ts
  9. 29
      src/ui-core/UiPanel.ts
  10. 15
      src/ui-core/UiText.ts

128
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<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();
}
}

108
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();
}
}

29
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);
}
}

46
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<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);
}
}

28
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);
}
}

9
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,
});
}

17
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);
}
}

24
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);
}
}
}

29
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);
}
}
}

15
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;
}
}
Loading…
Cancel
Save