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