Files
space-game/apps/viewer/src/ViewerAppController.ts
Jonathan Bourdon 892d069b92 feat(viewer): add GM Ops Console window replacing ops strip
Introduces a floating, draggable, resizable Game Master console as the
first of a planned series of GM/debug windows. Replaces the horizontal
ops-strip card layout with proper data tables using TanStack Table v8.

- GmWindow.vue: reusable draggable+resizable floating window base;
  snapshots offsetWidth/Height on drag start so resize is preserved
- GmOpsWindow.vue: Ships / Stations / Factions tabs with global filter,
  column sorting, and drag-to-reorder columns (useColumnOrder composable)
- gmStore.ts: Pinia store fed from ViewerWorldLifecycle.rebuildFactions
  with raw world arrays (ships, stations, factions)
- Removes opsStripEl binding (was stored but never read by controller)
- GM Console toggle button replaces the bottom ops strip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 00:24:32 -04:00

370 lines
14 KiB
TypeScript

import * as THREE from "three";
import {
MAX_CAMERA_DISTANCE,
MIN_CAMERA_DISTANCE,
NAV_DISTANCE,
} from "./viewerConstants";
import { updatePanFromKeyboard } from "./viewerCamera";
import { setShellReticleOpacity } from "./viewerControls";
import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop";
import { updateSystemStarPresentation } from "./viewerPresentation";
import { resolveFocusedCelestialId } from "./viewerSelection";
import { describeSelectionParent } from "./viewerPanels";
import {
createInitialNetworkStats,
createInitialPerformanceStats,
} from "./viewerState";
import { ViewerWorldLifecycle } from "./viewerWorldLifecycle";
import { ViewerInteractionController } from "./viewerInteractionController";
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,
NetworkStats,
PerformanceStats,
Selectable,
SystemVisual,
WorldState,
PovLevel,
} from "./viewerTypes";
export class ViewerAppController {
private readonly container: HTMLElement;
private readonly renderer = createViewerRenderer();
private readonly renderSurface: ViewerRenderSurface;
// ── Three independent rendering layers ───────────────────────────────────
readonly universeLayer = new UniverseLayer();
readonly galaxyLayer = new GalaxyLayer();
readonly systemLayer = new SystemLayer();
readonly localLayer = new LocalLayer();
private readonly clock = new THREE.Clock();
private readonly raycaster = new THREE.Raycaster();
private readonly mouse = new THREE.Vector2();
// ── Galaxy-space anchor ───────────────────────────────────────────────────
private readonly galaxyAnchor = new THREE.Vector3(2200, 0, 300);
// ── System-space anchor ───────────────────────────────────────────────────
private readonly systemAnchor = new THREE.Vector3();
private readonly cameraOffset = new THREE.Vector3();
private readonly keyState = new Set<string>();
readonly hudState: ViewerHudState;
readonly selectionStore: ViewerSelectionStore;
private readonly historyLayerEl: HTMLDivElement;
private readonly marqueeEl: HTMLDivElement;
private readonly hoverLabelEl: HTMLDivElement;
private readonly hoverConnectorLineEl: SVGLineElement;
private world?: WorldState;
private worldTimeSyncMs = performance.now();
private stream?: EventSource;
private currentStreamScopeKey = "";
private readonly networkStats: NetworkStats = createInitialNetworkStats();
private readonly performanceStats: PerformanceStats = createInitialPerformanceStats();
private selectedItems: Selectable[] = [];
private worldSignature = "";
private povLevel: PovLevel = "system";
private currentDistance = NAV_DISTANCE.system;
private desiredDistance = NAV_DISTANCE.system;
private orbitYaw = -2.3;
private orbitPitch = 0.62;
private cameraMode: CameraMode = "tactical";
private dragMode?: DragMode;
private dragPointerId?: number;
private dragStart = new THREE.Vector2();
private dragLast = new THREE.Vector2();
private marqueeActive = false;
private suppressClickSelection = false;
private activeSystemId?: string;
private cameraTargetShipId?: string;
private readonly followCameraPosition = new THREE.Vector3();
private readonly followCameraFocus = new THREE.Vector3();
private readonly followCameraDirection = new THREE.Vector3(0, 0.16, 1);
private readonly followCameraDesiredDirection = new THREE.Vector3(0, 0.16, 1);
private readonly followCameraOffset = new THREE.Vector3();
private followOrbitYaw = 0;
private followOrbitPitch = 0.2;
private readonly historyWindows: HistoryWindowState[] = [];
private historyWindowCounter = 0;
private historyWindowZCounter = 10;
private historyWindowDragId?: string;
private historyWindowDragPointerId?: number;
private historyWindowDragOffset = new THREE.Vector2();
private readonly worldLifecycle: ViewerWorldLifecycle;
private readonly interactionController: ViewerInteractionController;
private readonly navigationController: ViewerNavigationController;
private readonly sceneDataController: ViewerSceneDataController;
private readonly presentationController: ViewerPresentationController;
private readonly disposeEventBindings: () => void;
private readonly unsubscribeSelectionStore: () => void;
constructor(container: HTMLElement, hud: ViewerHudBindings) {
this.container = container;
this.hudState = hud.state;
this.selectionStore = hud.selectionStore;
this.historyLayerEl = hud.historyLayerEl;
this.marqueeEl = hud.marqueeEl;
this.hoverLabelEl = hud.hoverLabelEl;
this.hoverConnectorLineEl = hud.hoverConnectorLineEl;
({
sceneDataController: this.sceneDataController,
navigationController: this.navigationController,
presentationController: this.presentationController,
worldLifecycle: this.worldLifecycle,
interactionController: this.interactionController,
} = createViewerControllers(this));
this.presentationController.initializeAmbience();
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);
}
async start() {
this.selectionStore.clearSelection();
await this.worldLifecycle.bootstrapWorld();
this.renderSurface.start();
}
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() {
this.worldLifecycle.refreshStreamScopeIfNeeded();
}
private createWorldPresentationContext() {
return this.sceneDataController.createWorldPresentationContext({
world: this.world,
activeSystemId: this.activeSystemId,
povLevel: this.povLevel,
orbitYaw: this.orbitYaw,
systemCamera: this.systemLayer.camera,
systemAnchor: this.systemAnchor,
toDisplayLocalPosition: (localPosition) => toDisplayLocalPosition(localPosition),
setShellReticleOpacity: (sprite, opacity) => this.setShellReticleOpacity(sprite, opacity),
});
}
private rebuildFactions(_factions: FactionSnapshot[]) {
this.worldLifecycle.rebuildFactions(_factions);
}
private updatePanels() {
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,
renderer: this.renderer,
universeLayer: this.universeLayer,
galaxyLayer: this.galaxyLayer,
systemLayer: this.systemLayer,
localLayer: this.localLayer,
getPovLevel: () => this.povLevel,
updateCamera: (delta) => this.updateCamera(delta),
updateAmbience: (delta) => this.presentationController.updateAmbience(delta),
updatePlanetPresentation: () => this.presentationController.updatePlanetPresentation(),
updateShipPresentation: () => this.presentationController.updateShipPresentation(),
updateNetworkPanel: () => this.presentationController.updateNetworkPanel(),
applyZoomPresentation: () => this.presentationController.applyZoomPresentation(),
recordPerformanceStats: (frameMs) => this.presentationController.recordPerformanceStats(frameMs),
updatePerformancePanel: () => this.presentationController.updatePerformancePanel(),
});
}
private computeOrbitOffset(): THREE.Vector3 {
const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch);
return new THREE.Vector3(
Math.cos(this.orbitYaw) * horizontalDistance,
this.currentDistance * Math.sin(this.orbitPitch),
Math.sin(this.orbitYaw) * horizontalDistance,
);
}
private updateCamera(delta: number) {
const nextState = stepCamera({
currentDistance: this.currentDistance,
desiredDistance: this.desiredDistance,
orbitPitch: this.orbitPitch,
delta,
});
this.currentDistance = nextState.currentDistance;
this.povLevel = nextState.povLevel;
this.orbitPitch = nextState.orbitPitch;
this.navigationController.updateActiveSystem();
if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) {
// Follow camera directly controls systemLayer.camera in updateFollowCamera.
// Still update galaxy camera independently.
const orbitOffset = this.computeOrbitOffset();
this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset);
return;
}
this.updatePanFromKeyboard(delta);
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3);
const orbitOffset = this.computeOrbitOffset();
this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset);
if (this.activeSystemId) {
this.systemLayer.updateCamera(getSystemCameraFocus(this.systemAnchor), orbitOffset);
}
this.localLayer.updateCamera(orbitOffset);
// Update star dot scales in galaxy scene
updateSystemStarPresentation(
this.galaxyLayer.systemVisuals,
this.activeSystemId,
this.galaxyLayer.camera,
(sprite, opacity) => this.setShellReticleOpacity(sprite, opacity),
);
}
private updatePanFromKeyboard(delta: number) {
updatePanFromKeyboard(
this.keyState,
this.orbitYaw,
this.currentDistance,
this.povLevel,
this.activeSystemId,
this.systemAnchor,
this.galaxyAnchor,
delta,
MIN_CAMERA_DISTANCE,
MAX_CAMERA_DISTANCE,
);
}
private updateSystemSummaries() {
this.presentationController.updateSystemSummaries();
}
private renderRecentEvents(entityKind: string, entityId: string) {
return this.presentationController.renderRecentEvents(entityKind, entityId);
}
private updateGamePanel(mode: string) {
this.presentationController.updateGamePanel(mode);
}
private screenPointFromClient(clientX: number, clientY: number) {
return this.presentationController.screenPointFromClient(clientX, clientY);
}
private refreshHistoryWindows() {
this.interactionController.refreshHistoryWindows();
}
private resolveFocusedCelestialId() {
return resolveFocusedCelestialId(this.world, this.selectedItems);
}
private onResize(width: number, height: number) {
resizeViewer({
galaxyLayer: this.galaxyLayer,
systemLayer: this.systemLayer,
localLayer: this.localLayer,
width,
height,
});
}
private setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
setShellReticleOpacity(sprite, opacity);
}
private describeSelectionParent(selection: Selectable) {
return describeSelectionParent(this.world, selection, this.systemLayer.stationVisuals, this.systemLayer.nodeVisuals);
}
private toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) {
return toDisplayLocalPosition(localPosition);
}
private updateSystemPanel() {
this.presentationController.updateSystemPanel();
}
}