feat(viewer): add Vue-based HUD, ops strip, and history window
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user