feat(viewer): add Vue-based HUD, ops strip, and history window

This commit is contained in:
2026-03-19 13:49:56 -04:00
parent 710addf1f5
commit 3ca568c05d
36 changed files with 2648 additions and 1017 deletions

View File

@@ -4,7 +4,6 @@ import {
MIN_CAMERA_DISTANCE,
NAV_DISTANCE,
} from "./viewerConstants";
import { createViewerHud } from "./viewerHud";
import { updatePanFromKeyboard } from "./viewerCamera";
import { setShellReticleOpacity } from "./viewerControls";
import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop";
@@ -21,16 +20,21 @@ import { ViewerNavigationController } from "./viewerNavigationController";
import { ViewerSceneDataController } from "./viewerSceneDataController";
import { ViewerPresentationController } from "./viewerPresentationController";
import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory";
import { createViewerRenderer } from "./runtime/rendering/createViewerRenderer";
import { disposeSceneResources } from "./runtime/rendering/disposeThreeResources";
import { ViewerRenderSurface } from "./runtime/rendering/ViewerRenderSurface";
import { toDisplayLocalPosition, getSystemCameraFocus } from "./viewerCamera";
import { UniverseLayer } from "./viewerUniverseLayer";
import { GalaxyLayer } from "./viewerGalaxyLayer";
import { SystemLayer } from "./viewerSystemLayer";
import { LocalLayer } from "./viewerLocalLayer";
import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState";
import { describeSelectable } from "./viewerSelection";
import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection";
import type { FactionSnapshot } from "./contracts";
import type {
CameraMode,
DragMode,
HistoryWindowState,
NetworkStats,
PerformanceStats,
Selectable,
@@ -41,7 +45,8 @@ import type {
export class ViewerAppController {
private readonly container: HTMLElement;
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
private readonly renderer = createViewerRenderer();
private readonly renderSurface: ViewerRenderSurface;
// ── Three independent rendering layers ───────────────────────────────────
readonly universeLayer = new UniverseLayer();
@@ -61,23 +66,9 @@ export class ViewerAppController {
private readonly cameraOffset = new THREE.Vector3();
private readonly keyState = new Set<string>();
private readonly gamePanelEl: HTMLDivElement;
private readonly statusEl: HTMLDivElement;
private readonly gameSummaryEl: HTMLSpanElement;
private readonly systemPanelEl: HTMLDivElement;
private readonly systemTitleEl: HTMLHeadingElement;
private readonly systemBodyEl: HTMLDivElement;
private readonly detailTitleEl: HTMLHeadingElement;
private readonly detailBodyEl: HTMLDivElement;
readonly hudState: ViewerHudState;
readonly selectionStore: ViewerSelectionStore;
private readonly opsStripEl: HTMLDivElement;
private readonly networkSectionEl: HTMLDivElement;
private readonly networkSummaryEl: HTMLSpanElement;
private readonly networkPanelEl: HTMLDivElement;
private readonly performanceSectionEl: HTMLDivElement;
private readonly performanceSummaryEl: HTMLSpanElement;
private readonly performancePanelEl: HTMLDivElement;
private readonly errorEl: HTMLDivElement;
private readonly historyLayerEl: HTMLDivElement;
private readonly marqueeEl: HTMLDivElement;
private readonly hoverLabelEl: HTMLDivElement;
@@ -124,30 +115,14 @@ export class ViewerAppController {
private readonly navigationController: ViewerNavigationController;
private readonly sceneDataController: ViewerSceneDataController;
private readonly presentationController: ViewerPresentationController;
private readonly disposeEventBindings: () => void;
private readonly unsubscribeSelectionStore: () => void;
constructor(container: HTMLElement) {
constructor(container: HTMLElement, hud: ViewerHudBindings) {
this.container = container;
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
const hud = createViewerHud(document);
this.gamePanelEl = hud.gamePanelEl;
this.statusEl = hud.statusEl;
this.gameSummaryEl = hud.gameSummaryEl;
this.networkSectionEl = hud.networkSectionEl;
this.systemPanelEl = hud.systemPanelEl;
this.systemTitleEl = hud.systemTitleEl;
this.systemBodyEl = hud.systemBodyEl;
this.detailTitleEl = hud.detailTitleEl;
this.detailBodyEl = hud.detailBodyEl;
this.hudState = hud.state;
this.selectionStore = hud.selectionStore;
this.opsStripEl = hud.opsStripEl;
this.networkSummaryEl = hud.networkSummaryEl;
this.networkPanelEl = hud.networkPanelEl;
this.performanceSectionEl = hud.performanceSectionEl;
this.performanceSummaryEl = hud.performanceSummaryEl;
this.performancePanelEl = hud.performancePanelEl;
this.errorEl = hud.errorEl;
this.historyLayerEl = hud.historyLayerEl;
this.marqueeEl = hud.marqueeEl;
this.hoverLabelEl = hud.hoverLabelEl;
@@ -160,33 +135,51 @@ export class ViewerAppController {
interactionController: this.interactionController,
} = createViewerControllers(this));
this.presentationController.initializeAmbience();
this.container.append(this.renderer.domElement, hud.root);
this.initializePanelToggles();
wireViewerEvents(this);
this.onResize();
this.unsubscribeSelectionStore = this.selectionStore.$subscribe((_mutation, state) => {
this.syncSelectionFromStore(state.selectedEntityKind, state.selectedEntityId);
});
this.renderSurface = new ViewerRenderSurface({
container: this.container,
renderer: this.renderer,
onFrame: () => this.render(),
onResize: (width, height) => this.onResize(width, height),
});
this.disposeEventBindings = wireViewerEvents(this);
this.updateCamera(0);
}
private initializePanelToggles() {
for (const panel of [this.gamePanelEl, this.networkSectionEl, this.performanceSectionEl]) {
const toggle = panel.querySelector(".panel-toggle");
if (!(toggle instanceof HTMLButtonElement)) {
continue;
}
toggle.addEventListener("click", () => {
const collapsed = panel.classList.toggle("is-collapsed");
toggle.textContent = collapsed ? "+" : "-";
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
toggle.setAttribute("aria-label", `${collapsed ? "Expand" : "Collapse"} ${panel.dataset.panelName ?? "panel"}`);
});
}
async start() {
this.selectionStore.clearSelection();
await this.worldLifecycle.bootstrapWorld();
this.renderSurface.start();
}
async start() {
await this.worldLifecycle.bootstrapWorld();
this.renderer.setAnimationLoop(() => this.render());
dispose() {
this.disposeEventBindings();
this.unsubscribeSelectionStore();
this.stream?.close();
this.renderSurface.dispose();
disposeSceneResources(this.universeLayer.scene);
disposeSceneResources(this.galaxyLayer.scene);
disposeSceneResources(this.systemLayer.scene);
disposeSceneResources(this.localLayer.scene);
}
focusSelection(selection: Selectable, cameraMode?: CameraMode) {
this.applySelectedItems([selection], "ui");
this.navigationController.focusOnSelection(selection);
if (cameraMode) {
this.interactionController.toggleCameraMode(cameraMode);
if (selection.kind === "ship" && cameraMode === "follow") {
this.desiredDistance = 0.00018;
}
}
this.updatePanels();
this.updateGamePanel("Live");
}
openHistoryWindow(selection: Selectable) {
this.interactionController.openHistoryWindow(selection);
}
private refreshStreamScopeIfNeeded() {
@@ -214,6 +207,32 @@ export class ViewerAppController {
this.worldLifecycle.updatePanels();
}
private applySelectedItems(items: Selectable[], source: "viewer" | "ui") {
this.selectedItems = items;
if (items.length === 1) {
const selection = items[0];
this.selectionStore.selectSelection({
id: selectionToEntityId(selection),
kind: selection.kind,
label: describeSelectable(this.world, selection),
}, source);
return;
}
this.selectionStore.clearSelection(source);
}
private syncSelectionFromStore(
kind: Selectable["kind"] | null,
entityId: string | null,
) {
const selection = entityIdToSelectable(kind, entityId);
this.selectedItems = selection ? [selection] : [];
this.navigationController.syncFollowStateFromSelection();
this.updatePanels();
this.updateGamePanel("Live");
}
private render() {
renderFrame({
clock: this.clock,
@@ -324,14 +343,15 @@ export class ViewerAppController {
return resolveFocusedCelestialId(this.world, this.selectedItems);
}
private onResize = () => {
private onResize(width: number, height: number) {
resizeViewer({
renderer: this.renderer,
galaxyLayer: this.galaxyLayer,
systemLayer: this.systemLayer,
localLayer: this.localLayer,
width,
height,
});
};
}
private setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
setShellReticleOpacity(sprite, opacity);