import * as THREE from "three"; import { MAX_CAMERA_DISTANCE, 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"; 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 { toDisplayLocalPosition, getSystemCameraFocus } from "./viewerCamera"; import { UniverseLayer } from "./viewerUniverseLayer"; import { GalaxyLayer } from "./viewerGalaxyLayer"; import { SystemLayer } from "./viewerSystemLayer"; import { LocalLayer } from "./viewerLocalLayer"; import type { FactionSnapshot } from "./contracts"; import type { CameraMode, DragMode, HistoryWindowState, NetworkStats, PerformanceStats, Selectable, SystemVisual, WorldState, PovLevel, } from "./viewerTypes"; export class ViewerAppController { private readonly container: HTMLElement; private readonly renderer = new THREE.WebGLRenderer({ antialias: true }); // ── 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(); 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; 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; 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 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; constructor(container: HTMLElement) { 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.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; 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.container.append(this.renderer.domElement, hud.root); this.initializePanelToggles(); wireViewerEvents(this); this.onResize(); 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() { await this.worldLifecycle.bootstrapWorld(); this.renderer.setAnimationLoop(() => this.render()); } 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 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 = () => { resizeViewer({ renderer: this.renderer, galaxyLayer: this.galaxyLayer, systemLayer: this.systemLayer, localLayer: this.localLayer, }); }; 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(); } }