import * as THREE from "three"; import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, ZOOM_DISTANCE, } from "./viewerConstants"; import { createViewerHud } from "./viewerHud"; import { classifyZoomLevel, computeZoomBlend, formatBytes, inventoryAmount, smoothBand, } from "./viewerMath"; import { updatePanFromKeyboard } from "./viewerCamera"; import { createCirclePoints, shipLength, shipPresentationColor, shipSize, spatialNodeColor, } from "./viewerSceneAppearance"; import { createBackdropStars, createNebulaClouds, createNebulaTexture, } from "./viewerSceneFactory"; import { setShellReticleOpacity, } from "./viewerControls"; import { recordPerformanceStats, updateNetworkPanel as renderNetworkPanel, updatePerformancePanel as renderPerformancePanel, } from "./viewerTelemetry"; import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop"; import { updatePlanetPresentation } from "./viewerPresentation"; import { renderRecentEvents, updateGameStatus, updateSystemSummaries, updateWorldPresentation, } from "./viewerWorldPresentation"; import { resolveFocusedBubbleId, } from "./viewerSelection"; import { describeSelectionParent, updateSystemPanel } 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 type { SceneNode } from "./viewerScenePrimitives"; import type { FactionSnapshot, ShipSnapshot } from "./contracts"; import type { BubbleVisual, CameraMode, ClaimVisual, ConstructionSiteVisual, DragMode, HistoryWindowState, MoonVisual, NetworkStats, NodeVisual, OrbitLineVisual, OrbitalAnchor, PerformanceStats, PlanetVisual, PresentationEntry, Selectable, ShipVisual, SpatialNodeVisual, StructureVisual, SystemSummaryVisual, SystemVisual, WorldState, ZoomLevel, } from "./viewerTypes"; export class ViewerAppController { private readonly container: HTMLElement; private readonly renderer = new THREE.WebGLRenderer({ antialias: true }); private readonly scene = new THREE.Scene(); private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 160000); private readonly clock = new THREE.Clock(); private readonly raycaster = new THREE.Raycaster(); private readonly mouse = new THREE.Vector2(); private readonly galaxyFocus = new THREE.Vector3(2200, 0, 300); private readonly systemFocusLocal = new THREE.Vector3(); private readonly cameraOffset = new THREE.Vector3(); private readonly keyState = new Set(); private readonly systemGroup = new THREE.Group(); private readonly spatialNodeGroup = new THREE.Group(); private readonly bubbleGroup = new THREE.Group(); private readonly nodeGroup = new THREE.Group(); private readonly stationGroup = new THREE.Group(); private readonly claimGroup = new THREE.Group(); private readonly constructionSiteGroup = new THREE.Group(); private readonly shipGroup = new THREE.Group(); private readonly ambienceGroup = new THREE.Group(); private readonly gamePanelEl: HTMLDivElement; private readonly selectableTargets = new Map(); private readonly presentationEntries: PresentationEntry[] = []; private readonly nodeVisuals = new Map(); private readonly spatialNodeVisuals = new Map(); private readonly bubbleVisuals = new Map(); private readonly stationVisuals = new Map(); private readonly claimVisuals = new Map(); private readonly constructionSiteVisuals = new Map(); private readonly shipVisuals = new Map(); private readonly systemVisuals = new Map(); private readonly systemSummaryVisuals = new Map(); private readonly planetVisuals: PlanetVisual[] = []; private readonly orbitLines: OrbitLineVisual[] = []; 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 factionStripEl: 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 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 zoomLevel: ZoomLevel = "system"; private currentDistance = ZOOM_DISTANCE.system; private desiredDistance = ZOOM_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; this.scene.background = new THREE.Color(0x040912); this.scene.fog = new THREE.FogExp2(0x040912, 0.00011); this.scene.add(new THREE.AmbientLight(0x90a6c0, 0.55)); const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3); keyLight.position.set(1000, 1200, 800); this.scene.add(keyLight); this.scene.add( this.ambienceGroup, this.systemGroup, this.spatialNodeGroup, this.bubbleGroup, this.nodeGroup, this.stationGroup, this.claimGroup, this.constructionSiteGroup, this.shipGroup, ); 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.factionStripEl = hud.factionStripEl; 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; ({ 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, zoomLevel: this.zoomLevel, orbitYaw: this.orbitYaw, camera: this.camera, systemFocusLocal: this.systemFocusLocal, toDisplayLocalPosition: this.toDisplayLocalPosition.bind(this), updateSystemDetailVisibility: () => this.navigationController.updateSystemDetailVisibility(), 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, scene: this.scene, camera: this.camera, 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 updateAmbience(delta: number) { this.ambienceGroup.position.copy(this.camera.position); this.ambienceGroup.rotation.y += delta * 0.005; this.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015; } private updateCamera(delta: number) { const nextState = stepCamera({ currentDistance: this.currentDistance, desiredDistance: this.desiredDistance, orbitPitch: this.orbitPitch, delta, }); this.currentDistance = nextState.currentDistance; this.zoomLevel = nextState.zoomLevel; this.orbitPitch = nextState.orbitPitch; this.navigationController.updateActiveSystem(); if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) { return; } this.updatePanFromKeyboard(delta); this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3); const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch); const focus = this.navigationController.getCameraFocusWorldPosition(); this.cameraOffset.set( Math.cos(this.orbitYaw) * horizontalDistance, this.currentDistance * Math.sin(this.orbitPitch), Math.sin(this.orbitYaw) * horizontalDistance, ); this.camera.position.copy(focus).add(this.cameraOffset); this.camera.lookAt(focus); } private updatePanFromKeyboard(delta: number) { updatePanFromKeyboard( this.keyState, this.orbitYaw, this.currentDistance, this.zoomLevel, this.activeSystemId, this.systemFocusLocal, this.galaxyFocus, delta, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE, ); } private updateSystemSummaries() { this.presentationController.updateSystemSummaries(); } private registerPresentation( detail: SceneNode, icon: SceneNode, hideDetailInUniverse: boolean, hideIconInUniverse = false, systemId?: string, ) { this.presentationEntries.push({ detail, icon, systemId, hideDetailInUniverse, hideIconInUniverse }); } 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 resolveFocusedBubbleId() { return resolveFocusedBubbleId(this.world, this.selectedItems); } private onResize = () => { resizeViewer({ renderer: this.renderer, camera: this.camera, }); }; private setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) { setShellReticleOpacity(sprite, opacity); } private describeSelectionParent(selection: Selectable) { return describeSelectionParent(this.world, selection, this.stationVisuals, this.nodeVisuals); } private toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) { return this.navigationController.toDisplayLocalPosition(localPosition, systemId); } private updateSystemPanel() { this.presentationController.updateSystemPanel(); } }