Files
space-game/apps/viewer/src/ViewerAppController.ts
Jonathan Bourdon 5df5111463 feat: migrate simulation to physically-based unit system
Replace arbitrary game units with real-world measurements throughout
the simulation and viewer: planet orbits in AU, sizes in km, galaxy
positions in light-years. Add SimulationUnits helpers for conversions,
separate WarpSpeed from FtlSpeed for ships, fix FTL transit progress
to use galaxy-space distances, overhaul Lagrange point placement with
Hill sphere approximation, and update the viewer to scale and format
all distances correctly. Ships in FTL transit now render in galaxy view.

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

408 lines
15 KiB
TypeScript

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<string>();
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<THREE.Object3D, Selectable>();
private readonly presentationEntries: PresentationEntry[] = [];
private readonly nodeVisuals = new Map<string, NodeVisual>();
private readonly spatialNodeVisuals = new Map<string, SpatialNodeVisual>();
private readonly bubbleVisuals = new Map<string, BubbleVisual>();
private readonly stationVisuals = new Map<string, StructureVisual>();
private readonly claimVisuals = new Map<string, ClaimVisual>();
private readonly constructionSiteVisuals = new Map<string, ConstructionSiteVisual>();
private readonly shipVisuals = new Map<string, ShipVisual>();
private readonly systemVisuals = new Map<string, SystemVisual>();
private readonly systemSummaryVisuals = new Map<string, SystemSummaryVisual>();
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();
}
}