diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9030888 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "frontend-design@claude-plugins-official": true + } +} diff --git a/apps/viewer/src/ViewerAppController.ts b/apps/viewer/src/ViewerAppController.ts index 7373407..fe83135 100644 --- a/apps/viewer/src/ViewerAppController.ts +++ b/apps/viewer/src/ViewerAppController.ts @@ -2,49 +2,15 @@ import * as THREE from "three"; import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, - ZOOM_DISTANCE, + NAV_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 { setShellReticleOpacity } from "./viewerControls"; 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 { updateSystemStarPresentation } from "./viewerPresentation"; +import { resolveFocusedCelestialId } from "./viewerSelection"; +import { describeSelectionParent } from "./viewerPanels"; import { createInitialNetworkStats, createInitialPerformanceStats, @@ -55,68 +21,65 @@ 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 { 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 { - BubbleVisual, + CelestialVisual, CameraMode, ClaimVisual, ConstructionSiteVisual, DragMode, HistoryWindowState, - MoonVisual, NetworkStats, NodeVisual, OrbitLineVisual, - OrbitalAnchor, PerformanceStats, - PlanetVisual, - PresentationEntry, Selectable, ShipVisual, - SpatialNodeVisual, StructureVisual, - SystemSummaryVisual, SystemVisual, WorldState, - ZoomLevel, + PovLevel, } 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); + + // ── Three independent rendering layers ─────────────────────────────────── + private readonly universeLayer = new UniverseLayer(); + private readonly galaxyLayer = new GalaxyLayer(); + private readonly systemLayer = new SystemLayer(); + private readonly localLayer = new LocalLayer(); + 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(); + + // ── 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 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 celestialVisuals = 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 nodeVisuals = new Map(); + private readonly planetVisuals: any[] = []; private readonly orbitLines: OrbitLineVisual[] = []; + private readonly statusEl: HTMLDivElement; private readonly gameSummaryEl: HTMLSpanElement; private readonly systemPanelEl: HTMLDivElement; @@ -145,9 +108,9 @@ export class ViewerAppController { private selectedItems: Selectable[] = []; private worldSignature = ""; - private zoomLevel: ZoomLevel = "system"; - private currentDistance = ZOOM_DISTANCE.system; - private desiredDistance = ZOOM_DISTANCE.system; + 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"; @@ -181,23 +144,7 @@ export class ViewerAppController { 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; @@ -263,12 +210,11 @@ export class ViewerAppController { return this.sceneDataController.createWorldPresentationContext({ world: this.world, activeSystemId: this.activeSystemId, - zoomLevel: this.zoomLevel, + povLevel: this.povLevel, orbitYaw: this.orbitYaw, - camera: this.camera, - systemFocusLocal: this.systemFocusLocal, - toDisplayLocalPosition: this.toDisplayLocalPosition.bind(this), - updateSystemDetailVisibility: () => this.navigationController.updateSystemDetailVisibility(), + systemCamera: this.systemLayer.camera, + systemAnchor: this.systemAnchor, + toDisplayLocalPosition: (localPosition) => toDisplayLocalPosition(localPosition), setShellReticleOpacity: (sprite, opacity) => this.setShellReticleOpacity(sprite, opacity), }); } @@ -285,8 +231,14 @@ export class ViewerAppController { renderFrame({ clock: this.clock, renderer: this.renderer, - scene: this.scene, - camera: this.camera, + universeScene: this.universeLayer.scene, + galaxyScene: this.galaxyLayer.scene, + galaxyCamera: this.galaxyLayer.camera, + systemScene: this.systemLayer.scene, + systemCamera: this.systemLayer.camera, + localScene: this.localLayer.scene, + localCamera: this.localLayer.camera, + getPovLevel: () => this.povLevel, updateCamera: (delta) => this.updateCamera(delta), updateAmbience: (delta) => this.presentationController.updateAmbience(delta), updatePlanetPresentation: () => this.presentationController.updatePlanetPresentation(), @@ -298,10 +250,13 @@ export class ViewerAppController { }); } - 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 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) { @@ -312,25 +267,38 @@ export class ViewerAppController { delta, }); this.currentDistance = nextState.currentDistance; - this.zoomLevel = nextState.zoomLevel; + 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 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, + 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.systemVisuals, + this.activeSystemId, + this.galaxyLayer.camera, + (sprite, opacity) => this.setShellReticleOpacity(sprite, opacity), ); - this.camera.position.copy(focus).add(this.cameraOffset); - this.camera.lookAt(focus); } private updatePanFromKeyboard(delta: number) { @@ -338,10 +306,10 @@ export class ViewerAppController { this.keyState, this.orbitYaw, this.currentDistance, - this.zoomLevel, + this.povLevel, this.activeSystemId, - this.systemFocusLocal, - this.galaxyFocus, + this.systemAnchor, + this.galaxyAnchor, delta, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE, @@ -352,16 +320,6 @@ export class ViewerAppController { 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); } @@ -378,14 +336,16 @@ export class ViewerAppController { this.interactionController.refreshHistoryWindows(); } - private resolveFocusedBubbleId() { - return resolveFocusedBubbleId(this.world, this.selectedItems); + private resolveFocusedCelestialId() { + return resolveFocusedCelestialId(this.world, this.selectedItems); } private onResize = () => { resizeViewer({ renderer: this.renderer, - camera: this.camera, + galaxyCamera: this.galaxyLayer.camera, + systemCamera: this.systemLayer.camera, + localCamera: this.localLayer.camera, }); }; @@ -398,7 +358,7 @@ export class ViewerAppController { } private toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) { - return this.navigationController.toDisplayLocalPosition(localPosition, systemId); + return toDisplayLocalPosition(localPosition); } private updateSystemPanel() { diff --git a/apps/viewer/src/contracts.ts b/apps/viewer/src/contracts.ts index a670ccf..36d8a2d 100644 --- a/apps/viewer/src/contracts.ts +++ b/apps/viewer/src/contracts.ts @@ -11,10 +11,8 @@ export type { PlanetSnapshot, ResourceNodeSnapshot, ResourceNodeDelta, - SpatialNodeSnapshot, - SpatialNodeDelta, - LocalBubbleSnapshot, - LocalBubbleDelta, + CelestialSnapshot, + CelestialDelta, } from "./contractsCelestial"; export type { StationSnapshot, diff --git a/apps/viewer/src/contractsCelestial.ts b/apps/viewer/src/contractsCelestial.ts index c1e23b1..7346322 100644 --- a/apps/viewer/src/contractsCelestial.ts +++ b/apps/viewer/src/contractsCelestial.ts @@ -32,7 +32,7 @@ export interface ResourceNodeSnapshot { id: string; systemId: string; localPosition: Vector3Dto; - anchorNodeId?: string | null; + celestialId?: string | null; sourceKind: string; oreRemaining: number; maxOre: number; @@ -41,28 +41,15 @@ export interface ResourceNodeSnapshot { export interface ResourceNodeDelta extends ResourceNodeSnapshot {} -export interface SpatialNodeSnapshot { +export interface CelestialSnapshot { id: string; systemId: string; kind: string; - localPosition: Vector3Dto; - bubbleId: string; + orbitalAnchor: Vector3Dto; + localSpaceRadius: number; parentNodeId?: string | null; occupyingStructureId?: string | null; orbitReferenceId?: string | null; } -export interface SpatialNodeDelta extends SpatialNodeSnapshot {} - -export interface LocalBubbleSnapshot { - id: string; - nodeId: string; - systemId: string; - radius: number; - occupantShipIds: string[]; - occupantStationIds: string[]; - occupantClaimIds: string[]; - occupantConstructionSiteIds: string[]; -} - -export interface LocalBubbleDelta extends LocalBubbleSnapshot {} +export interface CelestialDelta extends CelestialSnapshot {} diff --git a/apps/viewer/src/contractsInfrastructure.ts b/apps/viewer/src/contractsInfrastructure.ts index 8e9da09..4e6a581 100644 --- a/apps/viewer/src/contractsInfrastructure.ts +++ b/apps/viewer/src/contractsInfrastructure.ts @@ -18,9 +18,7 @@ export interface StationSnapshot { category: string; systemId: string; localPosition: Vector3Dto; - nodeId?: string | null; - bubbleId?: string | null; - anchorNodeId?: string | null; + celestialId?: string | null; color: string; dockedShips: number; dockedShipIds: string[]; @@ -45,8 +43,7 @@ export interface ClaimSnapshot { id: string; factionId: string; systemId: string; - nodeId: string; - bubbleId: string; + celestialId: string; state: string; health: number; placedAtUtc: string; @@ -59,8 +56,7 @@ export interface ConstructionSiteSnapshot { id: string; factionId: string; systemId: string; - nodeId: string; - bubbleId: string; + celestialId: string; targetKind: string; targetDefinitionId: string; blueprintId?: string | null; diff --git a/apps/viewer/src/contractsShips.ts b/apps/viewer/src/contractsShips.ts index 174fb6e..707a725 100644 --- a/apps/viewer/src/contractsShips.ts +++ b/apps/viewer/src/contractsShips.ts @@ -15,8 +15,7 @@ export interface ShipSnapshot { behaviorPhase: string | null; controllerTaskKind: string; commanderObjective: string | null; - nodeId?: string | null; - bubbleId?: string | null; + celestialId?: string | null; dockedStationId?: string | null; commanderId?: string | null; policySetId?: string | null; @@ -42,8 +41,7 @@ export interface ShipActionProgressSnapshot { export interface ShipSpatialStateSnapshot { spaceLayer: string; currentSystemId: string; - currentNodeId?: string | null; - currentBubbleId?: string | null; + currentCelestialId?: string | null; localPosition?: Vector3Dto | null; systemPosition?: Vector3Dto | null; movementRegime: string; diff --git a/apps/viewer/src/contractsWorld.ts b/apps/viewer/src/contractsWorld.ts index f7cc678..54061b3 100644 --- a/apps/viewer/src/contractsWorld.ts +++ b/apps/viewer/src/contractsWorld.ts @@ -9,12 +9,10 @@ import type { FactionSnapshot, } from "./contractsFactions"; import type { - LocalBubbleDelta, - LocalBubbleSnapshot, + CelestialDelta, + CelestialSnapshot, ResourceNodeDelta, ResourceNodeSnapshot, - SpatialNodeDelta, - SpatialNodeSnapshot, SystemSnapshot, } from "./contractsCelestial"; import type { @@ -37,8 +35,7 @@ export interface WorldSnapshot { orbitalSimulation: OrbitalSimulationSnapshot; generatedAtUtc: string; systems: SystemSnapshot[]; - spatialNodes: SpatialNodeSnapshot[]; - localBubbles: LocalBubbleSnapshot[]; + celestials: CelestialSnapshot[]; nodes: ResourceNodeSnapshot[]; stations: import("./contractsInfrastructure").StationSnapshot[]; claims: ClaimSnapshot[]; @@ -57,8 +54,7 @@ export interface WorldDelta { generatedAtUtc: string; requiresSnapshotRefresh: boolean; events: SimulationEventRecord[]; - spatialNodes: SpatialNodeDelta[]; - localBubbles: LocalBubbleDelta[]; + celestials: CelestialDelta[]; nodes: ResourceNodeDelta[]; stations: import("./contractsInfrastructure").StationDelta[]; claims: ClaimDelta[]; @@ -85,7 +81,7 @@ export interface SimulationEventRecord { export interface ObserverScope { scopeKind: string; systemId?: string | null; - bubbleId?: string | null; + celestialId?: string | null; } export interface OrbitalSimulationSnapshot { diff --git a/apps/viewer/src/viewerCamera.ts b/apps/viewer/src/viewerCamera.ts index 1be3f2d..fc4e380 100644 --- a/apps/viewer/src/viewerCamera.ts +++ b/apps/viewer/src/viewerCamera.ts @@ -1,19 +1,14 @@ import * as THREE from "three"; -import { ACTIVE_SYSTEM_CAPTURE_RADIUS, ACTIVE_SYSTEM_DETAIL_SCALE, GALAXY_PARALLAX_FACTOR } from "./viewerConstants"; -import { KILOMETERS_PER_AU, computePlanetLocalPosition, currentWorldTimeSeconds, scaleGalaxyVector, scaleLocalVector, toThreeVector } from "./viewerMath"; +import { ACTIVE_SYSTEM_CAPTURE_RADIUS, ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants"; +import { DISPLAY_UNITS_PER_KILOMETER, KILOMETERS_PER_AU, computePlanetLocalPosition, currentWorldTimeSeconds, scaleGalaxyVector, scaleLocalVector, toThreeVector } from "./viewerMath"; import { resolveSelectableSystemId } from "./viewerSelection"; import type { - BubbleVisual, - ClaimVisual, - ConstructionSiteVisual, NodeVisual, PlanetVisual, Selectable, ShipVisual, - SpatialNodeVisual, - StructureVisual, WorldState, - ZoomLevel, + PovLevel, } from "./viewerTypes"; interface ResolveSelectionPositionParams { @@ -23,14 +18,13 @@ interface ResolveSelectionPositionParams { nodeVisuals: Map; planetVisuals: PlanetVisual[]; computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3; - resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined; - resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3; + resolvePointPosition: (systemId: string, celestialId?: string | null) => THREE.Vector3; } interface FocusOnSelectionParams extends ResolveSelectionPositionParams { activeSystemId?: string; - galaxyFocus: THREE.Vector3; - systemFocusLocal: THREE.Vector3; + galaxyAnchor: THREE.Vector3; + systemAnchor: THREE.Vector3; } interface DetermineActiveSystemParams { @@ -39,7 +33,7 @@ interface DetermineActiveSystemParams { cameraTargetShipId?: string; currentDistance: number; selectedItems: Selectable[]; - galaxyFocus: THREE.Vector3; + galaxyAnchor: THREE.Vector3; } interface SeedSystemFocusParams { @@ -48,38 +42,30 @@ interface SeedSystemFocusParams { cameraMode: "tactical" | "follow"; cameraTargetShipId?: string; selectedItems: Selectable[]; - systemFocusLocal: THREE.Vector3; + systemAnchor: THREE.Vector3; worldTimeSyncMs: number; nodeVisuals: Map; planetVisuals: PlanetVisual[]; computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3; - resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined; - resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3; + resolvePointPosition: (systemId: string, celestialId?: string | null) => THREE.Vector3; } interface CameraFocusParams { - world: WorldState | undefined; - activeSystemId?: string; - galaxyFocus: THREE.Vector3; - systemFocusLocal: THREE.Vector3; + galaxyAnchor: THREE.Vector3; } -interface DisplayLocalPositionParams { - world: WorldState | undefined; - systemId?: string; - activeSystemId?: string; - localPosition: THREE.Vector3; - systemFocusLocal: THREE.Vector3; +export function getSystemCameraFocus(systemAnchor: THREE.Vector3): THREE.Vector3 { + return systemAnchor.clone().multiplyScalar(DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE); } export function updatePanFromKeyboard( keyState: Set, orbitYaw: number, currentDistance: number, - zoomLevel: ZoomLevel, + povLevel: PovLevel, activeSystemId: string | undefined, - systemFocusLocal: THREE.Vector3, - galaxyFocus: THREE.Vector3, + systemAnchor: THREE.Vector3, + galaxyAnchor: THREE.Vector3, delta: number, minimumDistance: number, maximumDistance: number, @@ -106,15 +92,15 @@ export function updatePanFromKeyboard( const right = new THREE.Vector3(-forward.z, 0, forward.x); const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z)); if (activeSystemId) { - const speedKilometers = zoomLevel === "system" + const speedKilometers = povLevel === "system" ? THREE.MathUtils.mapLinear(currentDistance, 80, 4000, KILOMETERS_PER_AU * 0.002, KILOMETERS_PER_AU * 0.35) : THREE.MathUtils.mapLinear(currentDistance, minimumDistance, 120, 40, 180000); - systemFocusLocal.addScaledVector(pan, speedKilometers * delta); + systemAnchor.addScaledVector(pan, speedKilometers * delta); return; } const speed = THREE.MathUtils.mapLinear(currentDistance, minimumDistance, maximumDistance, 320, 6800); - galaxyFocus.addScaledVector(pan, speed * delta); + galaxyAnchor.addScaledVector(pan, speed * delta); } export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined { @@ -124,7 +110,7 @@ export function determineActiveSystemId(params: DetermineActiveSystemParams): st cameraTargetShipId, currentDistance, selectedItems, - galaxyFocus, + galaxyAnchor, } = params; if (!world) { @@ -165,7 +151,7 @@ export function determineActiveSystemId(params: DetermineActiveSystemParams): st let nearestDistance = Number.POSITIVE_INFINITY; for (const system of world.systems.values()) { const center = scaleGalaxyVector(toThreeVector(system.galaxyPosition)); - const distance = center.distanceTo(galaxyFocus); + const distance = center.distanceTo(galaxyAnchor); if (distance < nearestDistance) { nearestDistance = distance; nearestSystemId = system.id; @@ -185,7 +171,6 @@ export function resolveSelectionPosition(params: ResolveSelectionPositionParams) nodeVisuals, planetVisuals, computeNodeLocalPosition, - resolveBubblePosition, resolvePointPosition, } = params; @@ -208,20 +193,17 @@ export function resolveSelectionPosition(params: ResolveSelectionPositionParams) ? computeNodeLocalPosition(visual, currentWorldTimeSeconds(world, worldTimeSyncMs)) : (node ? toThreeVector(node.localPosition) : undefined); } - if (selection.kind === "spatial-node") { - const node = world.spatialNodes.get(selection.id); - return node ? toThreeVector(node.localPosition) : undefined; - } - if (selection.kind === "bubble") { - return resolveBubblePosition(selection.id); + if (selection.kind === "celestial") { + const celestial = world.celestials.get(selection.id); + return celestial ? toThreeVector(celestial.orbitalAnchor) : undefined; } if (selection.kind === "claim") { const claim = world.claims.get(selection.id); - return claim ? resolvePointPosition(claim.systemId, claim.nodeId) : undefined; + return claim ? resolvePointPosition(claim.systemId, claim.celestialId) : undefined; } if (selection.kind === "construction-site") { const site = world.constructionSites.get(selection.id); - return site ? resolvePointPosition(site.systemId, site.nodeId) : undefined; + return site ? resolvePointPosition(site.systemId, site.celestialId) : undefined; } if (selection.kind === "planet") { const system = world.systems.get(selection.systemId); @@ -242,8 +224,8 @@ export function focusOnSelection(params: FocusOnSelectionParams) { world, selection, activeSystemId, - galaxyFocus, - systemFocusLocal, + galaxyAnchor, + systemAnchor, } = params; const nextFocus = resolveSelectionPosition(params); @@ -252,8 +234,8 @@ export function focusOnSelection(params: FocusOnSelectionParams) { } if (selection.kind === "system") { - galaxyFocus.copy(nextFocus); - systemFocusLocal.set(0, 0, 0); + galaxyAnchor.copy(nextFocus); + systemAnchor.set(0, 0, 0); return; } @@ -261,18 +243,18 @@ export function focusOnSelection(params: FocusOnSelectionParams) { if (selectionSystemId && world) { const system = world.systems.get(selectionSystemId); if (system) { - galaxyFocus.copy(scaleGalaxyVector(toThreeVector(system.galaxyPosition))); - systemFocusLocal.copy(nextFocus); + galaxyAnchor.copy(scaleGalaxyVector(toThreeVector(system.galaxyPosition))); + systemAnchor.copy(nextFocus); return; } } if (activeSystemId && resolveSelectableSystemId(world, selection) === activeSystemId) { - systemFocusLocal.copy(nextFocus); + systemAnchor.copy(nextFocus); return; } - galaxyFocus.copy(nextFocus); + galaxyAnchor.copy(nextFocus); } export function seedSystemFocusLocal(params: SeedSystemFocusParams) { @@ -282,7 +264,7 @@ export function seedSystemFocusLocal(params: SeedSystemFocusParams) { cameraMode, cameraTargetShipId, selectedItems, - systemFocusLocal, + systemAnchor, } = params; if (!world) { @@ -292,7 +274,7 @@ export function seedSystemFocusLocal(params: SeedSystemFocusParams) { if (cameraMode === "follow" && cameraTargetShipId) { const followedShip = world.ships.get(cameraTargetShipId); if (followedShip?.systemId === systemId) { - systemFocusLocal.copy(toThreeVector(followedShip.localPosition)); + systemAnchor.copy(toThreeVector(followedShip.localPosition)); return; } } @@ -300,7 +282,7 @@ export function seedSystemFocusLocal(params: SeedSystemFocusParams) { const selected = selectedItems[0]; if (selected && resolveSelectableSystemId(world, selected) === systemId) { if (selected.kind === "system") { - systemFocusLocal.set(0, 0, 0); + systemAnchor.set(0, 0, 0); return; } @@ -311,62 +293,26 @@ export function seedSystemFocusLocal(params: SeedSystemFocusParams) { nodeVisuals: params.nodeVisuals, planetVisuals: params.planetVisuals, computeNodeLocalPosition: params.computeNodeLocalPosition, - resolveBubblePosition: params.resolveBubblePosition, resolvePointPosition: params.resolvePointPosition, }); if (selectedPosition) { - systemFocusLocal.copy(selectedPosition); + systemAnchor.copy(selectedPosition); return; } } - systemFocusLocal.set(0, 0, 0); + systemAnchor.set(0, 0, 0); } export function getCameraFocusWorldPosition(params: CameraFocusParams): THREE.Vector3 { - const { - world, - activeSystemId, - galaxyFocus, - systemFocusLocal, - } = params; - - if (!activeSystemId || !world) { - return galaxyFocus; - } - - const system = world.systems.get(activeSystemId); - return system - ? scaleGalaxyVector(toThreeVector(system.galaxyPosition)).add( - scaleLocalVector(systemFocusLocal).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE * GALAXY_PARALLAX_FACTOR), - ) - : galaxyFocus; + return params.galaxyAnchor; } -export function toDisplayLocalPosition(params: DisplayLocalPositionParams): THREE.Vector3 { - const { - world, - systemId, - activeSystemId, - localPosition, - systemFocusLocal, - } = params; - - if (!world || !systemId) { - return scaleLocalVector(localPosition); - } - - const system = world.systems.get(systemId); - if (!system) { - return scaleLocalVector(localPosition); - } - - const center = scaleGalaxyVector(toThreeVector(system.galaxyPosition)); - const scaledLocalPosition = scaleLocalVector(localPosition); - const scaledSystemFocus = scaleLocalVector(systemFocusLocal); - if (systemId !== activeSystemId) { - return center.clone().add(scaledLocalPosition); - } - - return center.clone().add(scaledLocalPosition.sub(scaledSystemFocus).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE)); +/** + * Convert a local km position to system-scene display coordinates. + * System scene coordinate system: star at origin, all positions scaled by + * DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE. + */ +export function toDisplayLocalPosition(localPosition: THREE.Vector3): THREE.Vector3 { + return localPosition.clone().multiplyScalar(DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE); } diff --git a/apps/viewer/src/viewerConstants.ts b/apps/viewer/src/viewerConstants.ts index f4bd66c..c012999 100644 --- a/apps/viewer/src/viewerConstants.ts +++ b/apps/viewer/src/viewerConstants.ts @@ -1,9 +1,9 @@ -import type { ZoomLevel } from "./viewerTypes"; +import type { PovLevel } from "./viewerTypes"; -export const ZOOM_DISTANCE: Record = { +export const NAV_DISTANCE: Record = { local: 18, system: 3200, - universe: 32000, + galaxy: 32000, }; export const ACTIVE_SYSTEM_DETAIL_SCALE = 10; @@ -14,10 +14,10 @@ export const STAR_RENDER_SCALE = 0.18; export const PLANET_RENDER_SCALE = 0.95; export const MOON_RENDER_SCALE = 1.1; export const MIN_CAMERA_DISTANCE = 2; -export const MAX_CAMERA_DISTANCE = 52000; +export const MAX_CAMERA_DISTANCE = 150000; export interface ZoomBlend { localWeight: number; systemWeight: number; - universeWeight: number; + galaxyWeight: number; } diff --git a/apps/viewer/src/viewerControllerFactory.ts b/apps/viewer/src/viewerControllerFactory.ts index 5942255..6d2c0a8 100644 --- a/apps/viewer/src/viewerControllerFactory.ts +++ b/apps/viewer/src/viewerControllerFactory.ts @@ -14,28 +14,26 @@ export function createViewerControllers(host: any) { getWorldSeed: () => host.world?.seed ?? 1, getWorldTimeSyncMs: () => host.worldTimeSyncMs, getWorldPresentationContext: () => host.createWorldPresentationContext(), - systemGroup: host.systemGroup, - spatialNodeGroup: host.spatialNodeGroup, - bubbleGroup: host.bubbleGroup, - nodeGroup: host.nodeGroup, - stationGroup: host.stationGroup, - claimGroup: host.claimGroup, - constructionSiteGroup: host.constructionSiteGroup, - shipGroup: host.shipGroup, - selectableTargets: host.selectableTargets, - presentationEntries: host.presentationEntries, + getActiveSystemId: () => host.activeSystemId, + galaxySystemGroup: host.galaxyLayer.systemGroup, + systemScene: host.systemLayer.scene, + celestialGroup: host.systemLayer.celestialGroup, + nodeGroup: host.systemLayer.nodeGroup, + stationGroup: host.systemLayer.stationGroup, + claimGroup: host.systemLayer.claimGroup, + constructionSiteGroup: host.systemLayer.constructionSiteGroup, + shipGroup: host.systemLayer.shipGroup, + galaxySelectableTargets: host.galaxyLayer.selectableTargets, + systemSelectableTargets: host.systemLayer.selectableTargets, systemVisuals: host.systemVisuals, - systemSummaryVisuals: host.systemSummaryVisuals, planetVisuals: host.planetVisuals, orbitLines: host.orbitLines, - spatialNodeVisuals: host.spatialNodeVisuals, - bubbleVisuals: host.bubbleVisuals, + celestialVisuals: host.celestialVisuals, nodeVisuals: host.nodeVisuals, stationVisuals: host.stationVisuals, claimVisuals: host.claimVisuals, constructionSiteVisuals: host.constructionSiteVisuals, shipVisuals: host.shipVisuals, - registerPresentation: host.registerPresentation.bind(host), }); const navigationController = new ViewerNavigationController({ @@ -45,6 +43,9 @@ export function createViewerControllers(host: any) { setActiveSystemId: (value) => { host.activeSystemId = value; }, + onActiveSystemChanged: (oldId, newId) => { + sceneDataController.onActiveSystemChanged(oldId, newId); + }, getCameraMode: () => host.cameraMode, setCameraMode: (value) => { host.cameraMode = value; @@ -54,12 +55,13 @@ export function createViewerControllers(host: any) { host.cameraTargetShipId = value; }, getCurrentDistance: () => host.currentDistance, - getZoomLevel: () => host.zoomLevel, + getPovLevel: () => host.povLevel, getSelectedItems: () => host.selectedItems, getOrbitYaw: () => host.orbitYaw, - galaxyFocus: host.galaxyFocus, - systemFocusLocal: host.systemFocusLocal, - camera: host.camera, + galaxyAnchor: host.galaxyAnchor, + systemAnchor: host.systemAnchor, + galaxyCamera: host.galaxyLayer.camera, + systemCamera: host.systemLayer.camera, shipVisuals: host.shipVisuals, nodeVisuals: host.nodeVisuals, planetVisuals: host.planetVisuals, @@ -76,9 +78,12 @@ export function createViewerControllers(host: any) { const presentationController = new ViewerPresentationController({ renderer: host.renderer, - scene: host.scene, - camera: host.camera, - ambienceGroup: host.ambienceGroup, + galaxyScene: host.galaxyLayer.scene, + galaxyCamera: host.galaxyLayer.camera, + systemCamera: host.systemLayer.camera, + galaxyAnchor: host.galaxyAnchor, + systemAnchor: host.systemAnchor, + ambienceGroup: host.universeLayer.ambienceGroup, gameSummaryEl: host.gameSummaryEl, networkSummaryEl: host.networkSummaryEl, performanceSummaryEl: host.performanceSummaryEl, @@ -94,14 +99,11 @@ export function createViewerControllers(host: any) { getActiveSystemId: () => host.activeSystemId, getCameraMode: () => host.cameraMode, getCameraTargetShipId: () => host.cameraTargetShipId, - getZoomLevel: () => host.zoomLevel, + getPovLevel: () => host.povLevel, getSelectedItems: () => host.selectedItems, getWorldTimeSyncMs: () => host.worldTimeSyncMs, getCurrentDistance: () => host.currentDistance, - systemFocusLocal: host.systemFocusLocal, planetVisuals: host.planetVisuals, - systemSummaryVisuals: host.systemSummaryVisuals, - presentationEntries: host.presentationEntries, orbitLines: host.orbitLines, systemVisuals: host.systemVisuals, createWorldPresentationContext: () => host.createWorldPresentationContext(), @@ -128,35 +130,33 @@ export function createViewerControllers(host: any) { setCurrentStreamScopeKey: (value) => { host.currentStreamScopeKey = value; }, - getZoomLevel: () => host.zoomLevel, + getPovLevel: () => host.povLevel, getActiveSystemId: () => host.activeSystemId, getSelectedItems: () => host.selectedItems, getCameraMode: () => host.cameraMode, getCameraTargetShipId: () => host.cameraTargetShipId, getNetworkStats: () => host.networkStats, - getSystemSummaryVisuals: () => host.systemSummaryVisuals, + getSystemSummaryVisuals: () => new Map(), errorEl: host.errorEl, opsStripEl: host.opsStripEl, detailTitleEl: host.detailTitleEl, detailBodyEl: host.detailBodyEl, worldLabel: () => host.world?.label ?? "", rebuildSystems: (systems) => sceneDataController.rebuildSystems(systems), - syncSpatialNodes: (nodes) => sceneDataController.syncSpatialNodes(nodes), - syncLocalBubbles: (bubbles) => sceneDataController.syncLocalBubbles(bubbles), + syncCelestials: (celestials) => sceneDataController.syncCelestials(celestials), syncNodes: (nodes) => sceneDataController.syncNodes(nodes), syncStations: (stations) => sceneDataController.syncStations(stations), syncClaims: (claims) => sceneDataController.syncClaims(claims), syncConstructionSites: (sites) => sceneDataController.syncConstructionSites(sites), syncShips: (ships, tickIntervalMs) => sceneDataController.syncShips(ships, tickIntervalMs), - applySpatialNodeDeltas: (nodes) => sceneDataController.applySpatialNodeDeltas(nodes), - applyLocalBubbleDeltas: (bubbles) => sceneDataController.applyLocalBubbleDeltas(bubbles), + applyCelestialDeltas: (celestials) => sceneDataController.applyCelestialDeltas(celestials), applyNodeDeltas: (nodes) => sceneDataController.applyNodeDeltas(nodes), applyStationDeltas: (stations) => sceneDataController.applyStationDeltas(stations), applyClaimDeltas: (claims) => sceneDataController.applyClaimDeltas(claims), applyConstructionSiteDeltas: (sites) => sceneDataController.applyConstructionSiteDeltas(sites), applyShipDeltas: (ships, tickIntervalMs) => sceneDataController.applyShipDeltas(ships, tickIntervalMs), refreshHistoryWindows: () => host.refreshHistoryWindows(), - resolveFocusedBubbleId: () => host.resolveFocusedBubbleId(), + resolveFocusedCelestialId: () => host.resolveFocusedCelestialId(), updateSystemSummaries: () => host.updateSystemSummaries(), applyZoomPresentation: () => presentationController.applyZoomPresentation(), updateNetworkPanel: () => presentationController.updateNetworkPanel(), @@ -193,14 +193,16 @@ export function createViewerControllers(host: any) { renderer: host.renderer, raycaster: host.raycaster, mouse: host.mouse, - camera: host.camera, - selectableTargets: host.selectableTargets, + galaxyCamera: host.galaxyLayer.camera, + systemCamera: host.systemLayer.camera, + galaxySelectableTargets: host.galaxyLayer.selectableTargets, + systemSelectableTargets: host.systemLayer.selectableTargets, hoverLabelEl: host.hoverLabelEl, marqueeEl: host.marqueeEl, keyState: host.keyState, getWorld: () => host.world, getActiveSystemId: () => host.activeSystemId, - getZoomLevel: () => host.zoomLevel, + getPovLevel: () => host.povLevel, getSelectedItems: () => host.selectedItems, setSelectedItems: (items) => { host.selectedItems = items; diff --git a/apps/viewer/src/viewerControls.ts b/apps/viewer/src/viewerControls.ts index 6c5bb3c..65bce1d 100644 --- a/apps/viewer/src/viewerControls.ts +++ b/apps/viewer/src/viewerControls.ts @@ -1,6 +1,6 @@ import * as THREE from "three"; -import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, ZOOM_DISTANCE } from "./viewerConstants"; -import { scaleGalaxyVector, toDisplayGalaxyVector, toThreeVector } from "./viewerMath"; +import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE } from "./viewerConstants"; +import { scaleGalaxyVector, toThreeVector } from "./viewerMath"; import { rawObject } from "./viewerScenePrimitives"; import { resolveShipWorldPosition } from "./viewerWorldPresentation"; import type { @@ -9,7 +9,6 @@ import type { ShipVisual, SystemVisual, WorldState, - ZoomLevel, } from "./viewerTypes"; export function syncFollowStateFromSelection( @@ -89,7 +88,7 @@ export function updateFollowCamera(params: { followCameraDirection: THREE.Vector3; followCameraDesiredDirection: THREE.Vector3; followCameraOffset: THREE.Vector3; - systemFocusLocal: THREE.Vector3; + systemAnchor: THREE.Vector3; delta: number; getAnimatedShipLocalPosition: (visual: ShipVisual) => THREE.Vector3; toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3; @@ -106,7 +105,7 @@ export function updateFollowCamera(params: { followCameraDirection, followCameraDesiredDirection, followCameraOffset, - systemFocusLocal, + systemAnchor, delta, getAnimatedShipLocalPosition, toDisplayLocalPosition, @@ -143,10 +142,10 @@ export function updateFollowCamera(params: { ); if (ship.spatialState.movementRegime === "ftl-transit") { - systemFocusLocal.set(0, 0, 0); + systemAnchor.set(0, 0, 0); const destinationNodeId = ship.spatialState.transit?.destinationNodeId; - const destinationNode = destinationNodeId ? world.spatialNodes.get(destinationNodeId) : undefined; - const destinationSystem = destinationNode ? world.systems.get(destinationNode.systemId) : undefined; + const destinationCelestial = destinationNodeId ? world.celestials.get(destinationNodeId) : undefined; + const destinationSystem = destinationCelestial ? world.systems.get(destinationCelestial.systemId) : undefined; const originSystem = world.systems.get(ship.systemId); if (originSystem && destinationSystem) { followCameraDesiredDirection @@ -154,7 +153,7 @@ export function updateFollowCamera(params: { .normalize(); } } else { - systemFocusLocal.lerp(shipLocalPosition, 1 - Math.exp(-delta * 8)); + systemAnchor.lerp(shipLocalPosition, 1 - Math.exp(-delta * 8)); followCameraDesiredDirection.copy(resolveShipHeading(visual, shipLocalPosition)).normalize(); } @@ -190,13 +189,6 @@ export function updateFollowCamera(params: { }; } -export function updateSystemDetailVisibility(systemVisuals: Map, activeSystemId?: string, zoomLevel?: ZoomLevel) { - const detailVisible = !!activeSystemId && zoomLevel !== "universe"; - for (const [systemId, visual] of systemVisuals.entries()) { - visual.detailGroup.setVisible(detailVisible && systemId === activeSystemId); - } -} - export function setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) { sprite.setVisible(opacity > 0.02); const material = (rawObject(sprite) as THREE.Sprite).material; @@ -204,7 +196,7 @@ export function setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opa material.needsUpdate = true; } -export function zoomFromWheel(desiredDistance: number, deltaY: number) { +export function navigateFromWheel(desiredDistance: number, deltaY: number) { const clampedDelta = THREE.MathUtils.clamp(deltaY, -180, 180); const zoomFactor = Math.exp(clampedDelta * 0.00135); return THREE.MathUtils.clamp(desiredDistance * zoomFactor, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE); @@ -226,21 +218,21 @@ export function applyKeyboardControl(params: { cameraMode = "tactical"; } if (key === "1") { - desiredDistance = ZOOM_DISTANCE.local; + desiredDistance = NAV_DISTANCE.local; } else if (key === "2") { - desiredDistance = ZOOM_DISTANCE.system; + desiredDistance = NAV_DISTANCE.system; } else if (key === "3") { - desiredDistance = ZOOM_DISTANCE.universe; + desiredDistance = NAV_DISTANCE.galaxy; } else if (key === "=") { - desiredDistance = desiredDistance <= ZOOM_DISTANCE.system - ? ZOOM_DISTANCE.local - : ZOOM_DISTANCE.system; + desiredDistance = desiredDistance <= NAV_DISTANCE.system + ? NAV_DISTANCE.local + : NAV_DISTANCE.system; } else if (key === "-") { - desiredDistance = desiredDistance >= ZOOM_DISTANCE.system - ? ZOOM_DISTANCE.universe - : ZOOM_DISTANCE.system; + desiredDistance = desiredDistance >= NAV_DISTANCE.system + ? NAV_DISTANCE.galaxy + : NAV_DISTANCE.system; } else if (key === "/") { - desiredDistance = ZOOM_DISTANCE.system; + desiredDistance = NAV_DISTANCE.system; } return { cameraMode, desiredDistance }; diff --git a/apps/viewer/src/viewerGalaxyLayer.ts b/apps/viewer/src/viewerGalaxyLayer.ts new file mode 100644 index 0000000..5a5d64a --- /dev/null +++ b/apps/viewer/src/viewerGalaxyLayer.ts @@ -0,0 +1,37 @@ +import * as THREE from "three"; +import type { Selectable } from "./viewerTypes"; + +/** + * Galaxy rendering layer — the galaxy map. + * Scene coordinate unit: display-unit (light-year scale). + * Only visible in galaxy POV, rendered on top of the universe backdrop. + * Contains star dots and shell reticles for each system. + */ +export class GalaxyLayer { + readonly scene = new THREE.Scene(); + readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 600000); + + /** Star dots and shell reticles, one per system. */ + readonly systemGroup = new THREE.Group(); + + readonly selectableTargets = new Map(); + + constructor() { + this.scene.fog = new THREE.FogExp2(0x040912, 0.000035); + 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.systemGroup); + } + + updateCamera(focus: THREE.Vector3, orbitOffset: THREE.Vector3) { + this.camera.position.copy(focus).add(orbitOffset); + this.camera.lookAt(focus); + } + + onResize(aspect: number) { + this.camera.aspect = aspect; + this.camera.updateProjectionMatrix(); + } +} diff --git a/apps/viewer/src/viewerInteraction.ts b/apps/viewer/src/viewerInteraction.ts index 515652b..c9a1f28 100644 --- a/apps/viewer/src/viewerInteraction.ts +++ b/apps/viewer/src/viewerInteraction.ts @@ -2,27 +2,16 @@ import * as THREE from "three"; import { describeHoverLabel, getSelectionGroup } from "./viewerSelection"; import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants"; import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, formatAdaptiveDistanceFromKilometers, formatSystemDistance } from "./viewerMath"; -import type { Selectable, SelectionGroup, WorldState, ZoomLevel } from "./viewerTypes"; +import type { Selectable, SelectionGroup, WorldState, PovLevel } from "./viewerTypes"; export interface HoverPickResult { selection: Selectable; object: THREE.Object3D; + /** Which camera was used for this pick (for distance calculation) */ + camera: THREE.Camera; } -export function pickSelectableAtClientPosition( - renderer: THREE.WebGLRenderer, - raycaster: THREE.Raycaster, - mouse: THREE.Vector2, - camera: THREE.Camera, - selectableTargets: Map, - clientX: number, - clientY: number, -) { - const hit = pickSelectableHitAtClientPosition(renderer, raycaster, mouse, camera, selectableTargets, clientX, clientY); - return hit?.selection; -} - -export function pickSelectableHitAtClientPosition( +function pickOneCamera( renderer: THREE.WebGLRenderer, raycaster: THREE.Raycaster, mouse: THREE.Vector2, @@ -38,29 +27,61 @@ export function pickSelectableHitAtClientPosition( const hit = raycaster.intersectObjects([...selectableTargets.keys()], false)[0]; const selection = hit ? selectableTargets.get(hit.object) : undefined; return hit && selection - ? { selection, object: hit.object } + ? { selection, object: hit.object, camera } : undefined; } +export function pickSelectableAtClientPosition( + renderer: THREE.WebGLRenderer, + raycaster: THREE.Raycaster, + mouse: THREE.Vector2, + galaxyCamera: THREE.Camera, + galaxySelectableTargets: Map, + systemCamera: THREE.Camera, + systemSelectableTargets: Map, + clientX: number, + clientY: number, +) { + const hit = pickSelectableHitAtClientPosition(renderer, raycaster, mouse, galaxyCamera, galaxySelectableTargets, systemCamera, systemSelectableTargets, clientX, clientY); + return hit?.selection; +} + +export function pickSelectableHitAtClientPosition( + renderer: THREE.WebGLRenderer, + raycaster: THREE.Raycaster, + mouse: THREE.Vector2, + galaxyCamera: THREE.Camera, + galaxySelectableTargets: Map, + systemCamera: THREE.Camera, + systemSelectableTargets: Map, + clientX: number, + clientY: number, +): HoverPickResult | undefined { + // Try system camera first (higher priority when in a system) + const systemHit = pickOneCamera(renderer, raycaster, mouse, systemCamera, systemSelectableTargets, clientX, clientY); + if (systemHit) { + return systemHit; + } + return pickOneCamera(renderer, raycaster, mouse, galaxyCamera, galaxySelectableTargets, clientX, clientY); +} + export function updateHoverLabel(params: { dragMode?: string; hoverLabelEl: HTMLDivElement; hoverPick: HoverPickResult | undefined; activeSystemId?: string; - zoomLevel: ZoomLevel; + povLevel: PovLevel; world?: WorldState; point: THREE.Vector2; - camera: THREE.Camera; }) { const { dragMode, hoverLabelEl, hoverPick, activeSystemId, - zoomLevel, + povLevel, world, point, - camera, } = params; if (dragMode) { @@ -73,14 +94,14 @@ export function updateHoverLabel(params: { return; } - const { selection, object } = hoverPick; + const { selection, object, camera } = hoverPick; const label = describeHoverLabel(world, selection); if (!label) { hoverLabelEl.hidden = true; return; } - const distance = formatHoverDistance(camera, object, selection, zoomLevel, activeSystemId); + const distance = formatHoverDistance(camera, object, selection, povLevel, activeSystemId); hoverLabelEl.hidden = false; hoverLabelEl.textContent = `${label}\n${distance}`; @@ -92,7 +113,7 @@ function formatHoverDistance( camera: THREE.Camera, object: THREE.Object3D, selection: Selectable, - zoomLevel: ZoomLevel, + povLevel: PovLevel, activeSystemId?: string, ) { const worldPosition = object.getWorldPosition(new THREE.Vector3()); @@ -107,14 +128,13 @@ function formatHoverDistance( : selection.kind === "ship" || selection.kind === "station" || selection.kind === "node" - || selection.kind === "spatial-node" - || selection.kind === "bubble" + || selection.kind === "celestial" || selection.kind === "claim" || selection.kind === "construction-site"; if (inActiveSystem && activeSystemId) { const kilometers = displayDistance / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE); - return zoomLevel === "system" + return povLevel === "system" ? formatSystemDistance(kilometers / KILOMETERS_PER_AU) : formatAdaptiveDistanceFromKilometers(kilometers); } @@ -145,17 +165,17 @@ export function hideMarqueeBox(marqueeEl: HTMLDivElement) { export function completeMarqueeSelection(params: { renderer: THREE.WebGLRenderer; - camera: THREE.Camera; + systemCamera: THREE.Camera; dragStart: THREE.Vector2; dragLast: THREE.Vector2; - selectableTargets: Map; + systemSelectableTargets: Map; }) { const { renderer, - camera, + systemCamera, dragStart, dragLast, - selectableTargets, + systemSelectableTargets, } = params; const bounds = renderer.domElement.getBoundingClientRect(); @@ -165,7 +185,7 @@ export function completeMarqueeSelection(params: { const maxY = Math.max(dragStart.y, dragLast.y); const grouped = new Map(); - for (const [object, selectable] of selectableTargets.entries()) { + for (const [object, selectable] of systemSelectableTargets.entries()) { if (object instanceof THREE.Sprite && !object.visible) { continue; } @@ -175,7 +195,7 @@ export function completeMarqueeSelection(params: { const worldPosition = new THREE.Vector3(); object.getWorldPosition(worldPosition); - worldPosition.project(camera); + worldPosition.project(systemCamera); const screenX = ((worldPosition.x + 1) * 0.5) * bounds.width; const screenY = ((1 - worldPosition.y) * 0.5) * bounds.height; if (screenX < minX || screenX > maxX || screenY < minY || screenY > maxY) { diff --git a/apps/viewer/src/viewerInteractionController.ts b/apps/viewer/src/viewerInteractionController.ts index 1c0bfbf..b6e18de 100644 --- a/apps/viewer/src/viewerInteractionController.ts +++ b/apps/viewer/src/viewerInteractionController.ts @@ -10,7 +10,7 @@ import { import { applyKeyboardControl, toggleCameraMode, - zoomFromWheel, + navigateFromWheel, } from "./viewerControls"; import { ViewerHistoryWindowController } from "./viewerHistoryWindowController"; import type { @@ -18,21 +18,23 @@ import type { DragMode, Selectable, WorldState, - ZoomLevel, + PovLevel, } from "./viewerTypes"; export interface ViewerInteractionContext { renderer: THREE.WebGLRenderer; raycaster: THREE.Raycaster; mouse: THREE.Vector2; - camera: THREE.PerspectiveCamera; - selectableTargets: Map; + galaxyCamera: THREE.PerspectiveCamera; + systemCamera: THREE.PerspectiveCamera; + galaxySelectableTargets: Map; + systemSelectableTargets: Map; hoverLabelEl: HTMLDivElement; marqueeEl: HTMLDivElement; keyState: Set; getWorld: () => WorldState | undefined; getActiveSystemId: () => string | undefined; - getZoomLevel: () => ZoomLevel; + getPovLevel: () => PovLevel; getSelectedItems: () => Selectable[]; setSelectedItems: (items: Selectable[]) => void; getDragMode: () => DragMode | undefined; @@ -235,7 +237,7 @@ export class ViewerInteractionController { readonly onWheel = (event: WheelEvent) => { event.preventDefault(); - this.context.setDesiredDistance(zoomFromWheel(this.context.getDesiredDistance(), event.deltaY)); + this.context.setDesiredDistance(navigateFromWheel(this.context.getDesiredDistance(), event.deltaY)); this.context.updateGamePanel("Live"); }; @@ -269,10 +271,9 @@ export class ViewerInteractionController { hoverLabelEl: this.context.hoverLabelEl, hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY), activeSystemId: this.context.getActiveSystemId(), - zoomLevel: this.context.getZoomLevel(), + povLevel: this.context.getPovLevel(), world: this.context.getWorld(), point: this.context.screenPointFromClient(event.clientX, event.clientY), - camera: this.context.camera, }); } @@ -300,8 +301,10 @@ export class ViewerInteractionController { this.context.renderer, this.context.raycaster, this.context.mouse, - this.context.camera, - this.context.selectableTargets, + this.context.galaxyCamera, + this.context.galaxySelectableTargets, + this.context.systemCamera, + this.context.systemSelectableTargets, clientX, clientY, ); @@ -312,8 +315,10 @@ export class ViewerInteractionController { this.context.renderer, this.context.raycaster, this.context.mouse, - this.context.camera, - this.context.selectableTargets, + this.context.galaxyCamera, + this.context.galaxySelectableTargets, + this.context.systemCamera, + this.context.systemSelectableTargets, clientX, clientY, ); @@ -322,10 +327,10 @@ export class ViewerInteractionController { private completeMarqueeSelection() { const selection = completeMarqueeSelection({ renderer: this.context.renderer, - camera: this.context.camera, + systemCamera: this.context.systemCamera, dragStart: this.context.dragStart, dragLast: this.context.dragLast, - selectableTargets: this.context.selectableTargets, + systemSelectableTargets: this.context.systemSelectableTargets, }); this.context.setSelectedItems(selection); this.context.syncFollowStateFromSelection(); diff --git a/apps/viewer/src/viewerLocalLayer.ts b/apps/viewer/src/viewerLocalLayer.ts new file mode 100644 index 0000000..2b49ed7 --- /dev/null +++ b/apps/viewer/src/viewerLocalLayer.ts @@ -0,0 +1,24 @@ +import * as THREE from "three"; + +/** + * Local rendering layer. + * Scene coordinate unit: reserved for future close-up detail. + * Camera far plane covers immediate surroundings. + * Currently empty — populated when local-space objects are introduced. + */ +export class LocalLayer { + readonly scene = new THREE.Scene(); + readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 2000); + + private static readonly ORIGIN = new THREE.Vector3(0, 0, 0); + + updateCamera(orbitOffset: THREE.Vector3) { + this.camera.position.copy(orbitOffset); + this.camera.lookAt(LocalLayer.ORIGIN); + } + + onResize(aspect: number) { + this.camera.aspect = aspect; + this.camera.updateProjectionMatrix(); + } +} diff --git a/apps/viewer/src/viewerMath.ts b/apps/viewer/src/viewerMath.ts index d6edc84..d6aa90c 100644 --- a/apps/viewer/src/viewerMath.ts +++ b/apps/viewer/src/viewerMath.ts @@ -9,7 +9,7 @@ import type { import type { OrbitalAnchor, WorldState, - ZoomLevel, + PovLevel, } from "./viewerTypes"; import type { ZoomBlend } from "./viewerConstants"; @@ -112,19 +112,19 @@ export function computeZoomBlend(distance: number): ZoomBlend { return { localWeight: 1 - localToSystem, systemWeight: Math.min(localToSystem, 1 - systemToUniverse), - universeWeight: systemToUniverse, + galaxyWeight: systemToUniverse, }; } -export function classifyZoomLevel(distance: number): ZoomLevel { +export function classifyPovLevel(distance: number): PovLevel { const blend = computeZoomBlend(distance); - if (blend.localWeight >= blend.systemWeight && blend.localWeight >= blend.universeWeight) { + if (blend.localWeight >= blend.systemWeight && blend.localWeight >= blend.galaxyWeight) { return "local"; } - if (blend.systemWeight >= blend.universeWeight) { + if (blend.systemWeight >= blend.galaxyWeight) { return "system"; } - return "universe"; + return "galaxy"; } export function toThreeVector(vector: Vector3Dto): THREE.Vector3 { diff --git a/apps/viewer/src/viewerNavigationController.ts b/apps/viewer/src/viewerNavigationController.ts index 2e223da..dd6c6a3 100644 --- a/apps/viewer/src/viewerNavigationController.ts +++ b/apps/viewer/src/viewerNavigationController.ts @@ -3,6 +3,7 @@ import { determineActiveSystemId, focusOnSelection, getCameraFocusWorldPosition, + getSystemCameraFocus, resolveSelectionPosition, seedSystemFocusLocal, toDisplayLocalPosition, @@ -10,9 +11,8 @@ import { import { syncFollowStateFromSelection, updateFollowCamera, - updateSystemDetailVisibility, } from "./viewerControls"; -import { computeNodeLocalPosition, resolveBubblePosition, resolvePointPosition } from "./viewerWorldPresentation"; +import { computeNodeLocalPosition, resolvePointPosition } from "./viewerWorldPresentation"; import { getAnimatedShipLocalPosition, resolveShipHeading } from "./viewerPresentation"; import type { CameraMode, @@ -22,7 +22,7 @@ import type { ShipVisual, SystemVisual, WorldState, - ZoomLevel, + PovLevel, } from "./viewerTypes"; export interface ViewerNavigationContext { @@ -30,17 +30,19 @@ export interface ViewerNavigationContext { getWorldTimeSyncMs: () => number; getActiveSystemId: () => string | undefined; setActiveSystemId: (value: string | undefined) => void; + onActiveSystemChanged: (oldId: string | undefined, newId: string | undefined) => void; getCameraMode: () => CameraMode; setCameraMode: (value: CameraMode) => void; getCameraTargetShipId: () => string | undefined; setCameraTargetShipId: (value: string | undefined) => void; getCurrentDistance: () => number; - getZoomLevel: () => ZoomLevel; + getPovLevel: () => PovLevel; getSelectedItems: () => Selectable[]; getOrbitYaw: () => number; - galaxyFocus: THREE.Vector3; - systemFocusLocal: THREE.Vector3; - camera: THREE.PerspectiveCamera; + galaxyAnchor: THREE.Vector3; + systemAnchor: THREE.Vector3; + galaxyCamera: THREE.PerspectiveCamera; + systemCamera: THREE.PerspectiveCamera; shipVisuals: Map; nodeVisuals: Map; planetVisuals: PlanetVisual[]; @@ -66,14 +68,10 @@ export class ViewerNavigationController { nodeVisuals: this.context.nodeVisuals, planetVisuals: this.context.planetVisuals, computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds), - resolveBubblePosition: (bubbleId) => { - const bubble = this.context.getWorld()?.localBubbles.get(bubbleId); - return bubble ? resolveBubblePosition(this.context.createWorldPresentationContext(), bubble) : undefined; - }, - resolvePointPosition: (systemId, nodeId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, nodeId), + resolvePointPosition: (systemId, celestialId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, celestialId), activeSystemId: this.context.getActiveSystemId(), - galaxyFocus: this.context.galaxyFocus, - systemFocusLocal: this.context.systemFocusLocal, + galaxyAnchor: this.context.galaxyAnchor, + systemAnchor: this.context.systemAnchor, }); } @@ -85,11 +83,7 @@ export class ViewerNavigationController { nodeVisuals: this.context.nodeVisuals, planetVisuals: this.context.planetVisuals, computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds), - resolveBubblePosition: (bubbleId) => { - const bubble = this.context.getWorld()?.localBubbles.get(bubbleId); - return bubble ? resolveBubblePosition(this.context.createWorldPresentationContext(), bubble) : undefined; - }, - resolvePointPosition: (systemId, nodeId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, nodeId), + resolvePointPosition: (systemId, celestialId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, celestialId), }); } @@ -100,9 +94,10 @@ export class ViewerNavigationController { cameraTargetShipId: this.context.getCameraTargetShipId(), currentDistance: this.context.getCurrentDistance(), selectedItems: this.context.getSelectedItems(), - galaxyFocus: this.context.galaxyFocus, + galaxyAnchor: this.context.galaxyAnchor, }); - if (nextActiveSystemId === this.context.getActiveSystemId()) { + const previousSystemId = this.context.getActiveSystemId(); + if (nextActiveSystemId === previousSystemId) { return; } @@ -111,7 +106,7 @@ export class ViewerNavigationController { } this.context.setActiveSystemId(nextActiveSystemId); - this.updateSystemDetailVisibility(); + this.context.onActiveSystemChanged(previousSystemId, nextActiveSystemId); this.context.updatePanels(); this.context.updateGamePanel("Live"); } @@ -123,16 +118,16 @@ export class ViewerNavigationController { cameraTargetShipId: this.context.getCameraTargetShipId(), shipVisuals: this.context.shipVisuals, currentDistance: this.context.getCurrentDistance(), - camera: this.context.camera, + camera: this.context.systemCamera, followCameraPosition: this.context.followCameraPosition, followCameraFocus: this.context.followCameraFocus, followCameraDirection: this.context.followCameraDirection, followCameraDesiredDirection: this.context.followCameraDesiredDirection, followCameraOffset: this.context.followCameraOffset, - systemFocusLocal: this.context.systemFocusLocal, + systemAnchor: this.context.systemAnchor, delta, getAnimatedShipLocalPosition, - toDisplayLocalPosition: (localPosition, systemId) => this.toDisplayLocalPosition(localPosition, systemId), + toDisplayLocalPosition: (localPosition) => toDisplayLocalPosition(localPosition), resolveShipHeading: (visual, worldPosition) => resolveShipHeading(visual, worldPosition, this.context.getOrbitYaw()), }); this.context.setCameraMode(nextState.cameraMode); @@ -150,19 +145,16 @@ export class ViewerNavigationController { this.context.setCameraTargetShipId(nextState.cameraTargetShipId); } - updateSystemDetailVisibility() { - updateSystemDetailVisibility(this.context.systemVisuals, this.context.getActiveSystemId(), this.context.getZoomLevel()); - } - getCameraFocusWorldPosition() { return getCameraFocusWorldPosition({ - world: this.context.getWorld(), - activeSystemId: this.context.getActiveSystemId(), - galaxyFocus: this.context.galaxyFocus, - systemFocusLocal: this.context.systemFocusLocal, + galaxyAnchor: this.context.galaxyAnchor, }); } + getSystemCameraFocus() { + return getSystemCameraFocus(this.context.systemAnchor); + } + seedSystemFocusLocal(systemId: string) { seedSystemFocusLocal({ world: this.context.getWorld(), @@ -170,26 +162,21 @@ export class ViewerNavigationController { cameraMode: this.context.getCameraMode(), cameraTargetShipId: this.context.getCameraTargetShipId(), selectedItems: this.context.getSelectedItems(), - systemFocusLocal: this.context.systemFocusLocal, + systemAnchor: this.context.systemAnchor, worldTimeSyncMs: this.context.getWorldTimeSyncMs(), nodeVisuals: this.context.nodeVisuals, planetVisuals: this.context.planetVisuals, computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds), - resolveBubblePosition: (bubbleId) => { - const bubble = this.context.getWorld()?.localBubbles.get(bubbleId); - return bubble ? resolveBubblePosition(this.context.createWorldPresentationContext(), bubble) : undefined; - }, - resolvePointPosition: (systemIdValue, nodeId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemIdValue, nodeId), + resolvePointPosition: (systemIdValue, celestialId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemIdValue, celestialId), }); } - toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) { - return toDisplayLocalPosition({ - world: this.context.getWorld(), - systemId, - activeSystemId: this.context.getActiveSystemId(), - localPosition, - systemFocusLocal: this.context.systemFocusLocal, - }); + toDisplayLocalPosition(localPosition: THREE.Vector3) { + return toDisplayLocalPosition(localPosition); + } + + /** Returns a display position for the system camera, derived from a raw local position in km. */ + toSystemDisplayPosition(localPosition: THREE.Vector3) { + return toDisplayLocalPosition(localPosition); } } diff --git a/apps/viewer/src/viewerOpsStrip.ts b/apps/viewer/src/viewerOpsStrip.ts index 0b086d5..53d6248 100644 --- a/apps/viewer/src/viewerOpsStrip.ts +++ b/apps/viewer/src/viewerOpsStrip.ts @@ -2,7 +2,7 @@ import type { StationSnapshot } from "./contractsInfrastructure"; import type { FactionSnapshot } from "./contractsFactions"; import { inventoryAmount } from "./viewerMath"; import { describeShipCurrentAction, describeShipLocation, describeShipObjective, describeShipState } from "./viewerSelection"; -import type { CameraMode, Selectable, WorldState, ZoomLevel } from "./viewerTypes"; +import type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes"; function renderFactionCard(faction: FactionSnapshot): string { const state = faction.goapState; @@ -71,14 +71,14 @@ export function renderOpsStrip( selectedItems: Selectable[], cameraMode: CameraMode, cameraTargetShipId?: string, - zoomLevel?: ZoomLevel, + povLevel?: PovLevel, activeSystemId?: string, ) { if (!world) { return ""; } - const isSystemFiltered = zoomLevel !== "universe" && activeSystemId != null; + const isSystemFiltered = povLevel !== "galaxy" && activeSystemId != null; const factionCards = [...world.factions.values()] .sort((a, b) => a.label.localeCompare(b.label)) diff --git a/apps/viewer/src/viewerPanels.ts b/apps/viewer/src/viewerPanels.ts index 112c90f..3f51487 100644 --- a/apps/viewer/src/viewerPanels.ts +++ b/apps/viewer/src/viewerPanels.ts @@ -5,7 +5,7 @@ import { formatSystemDistance, inventoryAmount, } from "./viewerMath"; -import { describeOrbitalParent, describeSelectable, describeShipCurrentAction, describeShipObjective, describeShipState, describeSpatialNodePathWithinSystem, getSelectionGroup, renderSystemDetails } from "./viewerSelection"; +import { describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipCurrentAction, describeShipObjective, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection"; import type { CameraMode, HistoryWindowState, @@ -20,7 +20,7 @@ import type { interface DetailPanelParams { world: WorldState; selectedItems: Selectable[]; - zoomLevel: string; + povLevel: string; cameraMode: CameraMode; cameraTargetShipId?: string; worldLabel: string; @@ -156,7 +156,7 @@ export function updateDetailPanel( const { world, selectedItems, - zoomLevel, + povLevel, cameraMode, cameraTargetShipId, worldLabel, @@ -166,10 +166,9 @@ export function updateDetailPanel( if (selectedItems.length === 0) { detailTitleEl.textContent = worldLabel; detailBodyEl.innerHTML = ` - Zoom ${zoomLevel}
+ Zoom ${povLevel}
Systems ${world.systems.size}
- Spatial nodes ${world.spatialNodes.size}
- Bubbles ${world.localBubbles.size}
+ Celestials ${world.celestials.size}
Stations ${world.stations.size}
Claims ${world.claims.size}
Construction ${world.constructionSites.size}
@@ -294,34 +293,17 @@ export function updateDetailPanel( return; } - if (selected.kind === "spatial-node") { - const node = world.spatialNodes.get(selected.id); - if (!node) { + if (selected.kind === "celestial") { + const celestial = world.celestials.get(selected.id); + if (!celestial) { return; } - const bubble = world.localBubbles.get(node.bubbleId); - detailTitleEl.textContent = `${node.kind} node`; + detailTitleEl.textContent = `${celestial.kind} celestial`; detailBodyEl.innerHTML = ` -

${node.systemId}

-

Bubble ${node.bubbleId}

-

Parent ${node.parentNodeId ?? "none"}
Orbit ref ${node.orbitReferenceId ?? "none"}

-

Occupying structure ${node.occupyingStructureId ?? "none"}

-

Bubble occupants ${bubble ? bubble.occupantShipIds.length + bubble.occupantStationIds.length : 0}

- `; - return; - } - - if (selected.kind === "bubble") { - const bubble = world.localBubbles.get(selected.id); - if (!bubble) { - return; - } - detailTitleEl.textContent = `Bubble ${bubble.id}`; - detailBodyEl.innerHTML = ` -

${bubble.systemId}

-

Anchor node ${bubble.nodeId}
Radius ${formatLocalDistance(bubble.radius)}

-

Ships ${bubble.occupantShipIds.length}
Stations ${bubble.occupantStationIds.length}

-

Claims ${bubble.occupantClaimIds.length}
Construction sites ${bubble.occupantConstructionSiteIds.length}

+

${celestial.systemId}

+

Parent ${celestial.parentNodeId ?? "none"}
Orbit ref ${celestial.orbitReferenceId ?? "none"}

+

Occupying structure ${celestial.occupyingStructureId ?? "none"}

+

Local space radius ${celestial.localSpaceRadius.toFixed(0)} km

`; return; } @@ -334,7 +316,7 @@ export function updateDetailPanel( detailTitleEl.textContent = `Claim ${claim.id}`; detailBodyEl.innerHTML = `

${claim.systemId}

-

Node ${claim.nodeId}
Bubble ${claim.bubbleId}

+

Celestial ${claim.celestialId}

State ${claim.state}
Health ${claim.health.toFixed(0)}

Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}

`; @@ -350,7 +332,7 @@ export function updateDetailPanel( detailTitleEl.textContent = `Construction ${site.id}`; detailBodyEl.innerHTML = `

${site.systemId}

-

Node ${site.nodeId}
Bubble ${site.bubbleId}

+

Celestial ${site.celestialId}

${site.targetKind} ${site.targetDefinitionId}

State ${site.state}
Progress ${(site.progress * 100).toFixed(0)}%

Orders ${orderCount}
Assigned constructors ${site.assignedConstructorShipIds.length}

@@ -445,8 +427,8 @@ export function describeSelectionParent( return "unknown"; } - return station.anchorNodeId - ? describeSpatialNodePathWithinSystem(world, station.systemId, station.anchorNodeId) ?? `${station.systemId} network` + return station.celestialId + ? describeCelestialPathWithinSystem(world, station.systemId, station.celestialId) ?? `${station.systemId} network` : "unknown"; } if (selection.kind === "node") { @@ -454,18 +436,15 @@ export function describeSelectionParent( const visual = node ? nodeVisuals.get(selection.id) : undefined; return describeOrbitalParent(world, node?.systemId, visual?.anchor); } - if (selection.kind === "spatial-node") { - const node = world.spatialNodes.get(selection.id); - return node?.parentNodeId ?? `${node?.systemId ?? "unknown"} network`; - } - if (selection.kind === "bubble") { - return `${world.localBubbles.get(selection.id)?.nodeId ?? "unknown"} node`; + if (selection.kind === "celestial") { + const celestial = world.celestials.get(selection.id); + return celestial?.parentNodeId ?? `${celestial?.systemId ?? "unknown"} network`; } if (selection.kind === "claim") { - return world.claims.get(selection.id)?.nodeId ?? "unknown"; + return world.claims.get(selection.id)?.celestialId ?? "unknown"; } if (selection.kind === "construction-site") { - return world.constructionSites.get(selection.id)?.nodeId ?? "unknown"; + return world.constructionSites.get(selection.id)?.celestialId ?? "unknown"; } return "unknown"; diff --git a/apps/viewer/src/viewerPresentation.ts b/apps/viewer/src/viewerPresentation.ts index 213cd8c..fc4c058 100644 --- a/apps/viewer/src/viewerPresentation.ts +++ b/apps/viewer/src/viewerPresentation.ts @@ -1,8 +1,7 @@ import * as THREE from "three"; import { ACTIVE_SYSTEM_DETAIL_SCALE, PROJECTED_GALAXY_RADIUS } from "./viewerConstants"; import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds, scaleLocalVector } from "./viewerMath"; -import { rawObject } from "./viewerScenePrimitives"; -import type { PlanetVisual, ShipVisual, SystemSummaryVisual, SystemVisual, WorldState } from "./viewerTypes"; +import type { PlanetVisual, ShipVisual, SystemVisual, WorldState } from "./viewerTypes"; export function getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) { const elapsedMs = now - visual.receivedAtMs; @@ -26,23 +25,17 @@ export function resolveShipHeading(visual: ShipVisual, worldPosition: THREE.Vect export function updatePlanetPresentation( world: WorldState | undefined, worldTimeSyncMs: number, - activeSystemId: string | undefined, - systemFocusLocal: THREE.Vector3, planetVisuals: PlanetVisual[], ) { const nowSeconds = currentWorldTimeSeconds(world, worldTimeSyncMs); + // In systemScene all positions use scaleLocalVector * ACTIVE_SYSTEM_DETAIL_SCALE. + // Star is always at origin (0,0,0); orbits are centered there. for (const visual of planetVisuals) { - const scale = visual.systemId === activeSystemId ? ACTIVE_SYSTEM_DETAIL_SCALE : 1; - const localPosition = scaleLocalVector(computePlanetLocalPosition(visual.planet, nowSeconds)); - const orbitOffset = visual.systemId === activeSystemId - ? systemFocusLocal.clone().multiplyScalar(-scale) - : new THREE.Vector3(); - const position = visual.systemId === activeSystemId - ? localPosition.clone().sub(systemFocusLocal).multiplyScalar(scale) - : localPosition.multiplyScalar(scale); + const position = scaleLocalVector(computePlanetLocalPosition(visual.planet, nowSeconds)) + .multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE); - visual.orbit.setScaleScalar(scale); - visual.orbit.setPosition(orbitOffset); + visual.orbit.setScaleScalar(ACTIVE_SYSTEM_DETAIL_SCALE); + visual.orbit.setPosition(new THREE.Vector3(0, 0, 0)); visual.mesh.setPosition(position); visual.icon.setPosition(position); if (visual.ring) { @@ -51,56 +44,45 @@ export function updatePlanetPresentation( for (const [moonIndex, moon] of visual.moons.entries()) { moon.orbit.setPosition(position); - moon.orbit.setScaleScalar(scale); + moon.orbit.setScaleScalar(ACTIVE_SYSTEM_DETAIL_SCALE); moon.mesh.setPosition( position.clone().add( - scaleLocalVector(computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds, world?.seed ?? 1)).multiplyScalar(scale), + scaleLocalVector(computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds, world?.seed ?? 1)) + .multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE), ), ); } } } -export function updateSystemSummaryPresentation( - systemSummaryVisuals: Map, - camera: THREE.PerspectiveCamera, - activeSystemId?: string, -) { - const distanceScale = activeSystemId ? 0.05 : 0.085; - for (const [systemId, visual] of systemSummaryVisuals.entries()) { - const worldPosition = visual.sprite.getWorldPosition(new THREE.Vector3()); - const distance = camera.position.distanceTo(worldPosition); - const minimumScale = activeSystemId && systemId !== activeSystemId ? 1200 : 1400; - const scale = Math.max(minimumScale, distance * distanceScale); - rawObject(visual.sprite).scale.set(scale, scale * 0.3125, 1); - } -} export function updateSystemStarPresentation( systemVisuals: Map, activeSystemId: string | undefined, - systemFocusLocal: THREE.Vector3, - camera: THREE.PerspectiveCamera, + galaxyCamera: THREE.PerspectiveCamera, setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], opacity: number) => void, ) { const activeSystem = activeSystemId ? systemVisuals.get(activeSystemId) : undefined; for (const [systemId, visual] of systemVisuals.entries()) { - visual.root.setPosition(visual.galaxyPosition); + // galaxyRoot is always at the galaxy position of this system + visual.galaxyRoot.setPosition(visual.galaxyPosition); visual.shellReticle.setScaleScalar(visual.shellReticleBaseScale); if (!activeSystem) { - visual.starCluster.setPosition(new THREE.Vector3(0, 0, 0)); + // Galaxy view: show star dot, hide shell reticle visual.icon.setPosition(new THREE.Vector3(0, 0, 0)); visual.icon.setVisible(true); visual.shellReticle.setPosition(new THREE.Vector3(0, 0, 0)); visual.shellReticle.setVisible(false); setShellReticleOpacity(visual.shellReticle, 0); + const dotWorldPos = visual.icon.getWorldPosition(new THREE.Vector3()); + visual.icon.setScaleScalar(galaxyCamera.position.distanceTo(dotWorldPos) * 0.01); continue; } if (systemId !== activeSystemId) { - visual.starCluster.setPosition(new THREE.Vector3(0, 0, 0)); + // Other systems in galaxy view while a system is active: show shell reticle projected to edge visual.icon.setPosition(new THREE.Vector3(0, 0, 0)); visual.icon.setVisible(false); visual.shellReticle.setPosition(new THREE.Vector3(0, 0, 0)); @@ -108,20 +90,19 @@ export function updateSystemStarPresentation( setShellReticleOpacity(visual.shellReticle, 1); const direction = visual.galaxyPosition.clone().sub(activeSystem.galaxyPosition); if (direction.lengthSq() > 0.0001) { - visual.root.setPosition( + visual.galaxyRoot.setPosition( activeSystem.galaxyPosition.clone().add(direction.normalize().multiplyScalar(PROJECTED_GALAXY_RADIUS)), ); } - const reticleWorldPosition = visual.root.getWorldPosition(new THREE.Vector3()); - const reticleDistance = camera.position.distanceTo(reticleWorldPosition); + const reticleWorldPosition = visual.galaxyRoot.getWorldPosition(new THREE.Vector3()); + const reticleDistance = galaxyCamera.position.distanceTo(reticleWorldPosition); const reticleScale = Math.max(900, reticleDistance * 0.032); visual.shellReticle.setScaleScalar(reticleScale); continue; } - const offset = systemFocusLocal.clone().multiplyScalar(-ACTIVE_SYSTEM_DETAIL_SCALE); - visual.starCluster.setPosition(offset); - visual.icon.setPosition(offset); + // Active system in galaxy view: show star dot, hide shell reticle + visual.icon.setPosition(new THREE.Vector3(0, 0, 0)); visual.icon.setVisible(true); visual.shellReticle.setVisible(false); setShellReticleOpacity(visual.shellReticle, 0); diff --git a/apps/viewer/src/viewerPresentationController.ts b/apps/viewer/src/viewerPresentationController.ts index e8a78e7..68311f8 100644 --- a/apps/viewer/src/viewerPresentationController.ts +++ b/apps/viewer/src/viewerPresentationController.ts @@ -14,8 +14,11 @@ import type { OrbitLineVisual, Selectable } from "./viewerTypes"; export interface ViewerPresentationContext { renderer: THREE.WebGLRenderer; - scene: THREE.Scene; - camera: THREE.PerspectiveCamera; + galaxyScene: THREE.Scene; + galaxyCamera: THREE.PerspectiveCamera; + systemCamera: THREE.PerspectiveCamera; + galaxyAnchor: THREE.Vector3; + systemAnchor: THREE.Vector3; ambienceGroup: THREE.Group; gameSummaryEl: HTMLSpanElement; networkSummaryEl: HTMLSpanElement; @@ -32,14 +35,11 @@ export interface ViewerPresentationContext { getActiveSystemId: () => string | undefined; getCameraMode: () => any; getCameraTargetShipId: () => string | undefined; - getZoomLevel: () => any; + getPovLevel: () => any; getSelectedItems: () => Selectable[]; getWorldTimeSyncMs: () => number; getCurrentDistance: () => number; - systemFocusLocal: THREE.Vector3; planetVisuals: any[]; - systemSummaryVisuals: Map; - presentationEntries: any[]; orbitLines: OrbitLineVisual[]; systemVisuals: Map; createWorldPresentationContext: () => any; @@ -55,43 +55,25 @@ export class ViewerPresentationController { } updateAmbience(delta: number) { - this.context.ambienceGroup.position.copy(this.context.camera.position); + const activeCamera = this.context.getPovLevel() === "galaxy" + ? this.context.galaxyCamera + : this.context.systemCamera; + this.context.ambienceGroup.position.copy(activeCamera.position); this.context.ambienceGroup.rotation.y += delta * 0.005; this.context.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015; } applyZoomPresentation() { const activeSystemId = this.context.getActiveSystemId(); - const zoomLevel = this.context.getZoomLevel(); - const isUniverse = zoomLevel === "universe"; - - for (const entry of this.context.presentationEntries) { - const systemId = entry.systemId; - const isActiveDetail = !systemId || systemId === activeSystemId; - const detailAlpha = entry.hideDetailInUniverse - ? (!isUniverse && isActiveDetail ? 1 : 0) - : 1; - const iconAlpha = entry.hideIconInUniverse - ? (isUniverse ? 1 : 0) - : (isUniverse ? 1 : 0); - - entry.detail.setOpacity(detailAlpha); - entry.icon.setOpacity(iconAlpha); - } + const povLevel = this.context.getPovLevel(); + // Orbit lines: only show for active system in system/local zoom for (const orbitLine of this.context.orbitLines) { - const alpha = this.resolveOrbitLineOpacity(orbitLine, zoomLevel, activeSystemId); + const alpha = this.resolveOrbitLineOpacity(orbitLine, povLevel, activeSystemId); orbitLine.line.setOpacity(alpha); } - for (const [systemId, summaryVisual] of this.context.systemSummaryVisuals.entries()) { - const summaryOpacity = isUniverse - ? 0.96 - : 0; - summaryVisual.sprite.setOpacity(summaryOpacity); - } - - this.context.scene.fog = new THREE.FogExp2(0x040912, 0.000035); + this.context.galaxyScene.fog = new THREE.FogExp2(0x040912, 0.000035); } updateNetworkPanel() { @@ -117,14 +99,12 @@ export class ViewerPresentationController { updatePlanetPresentation( world, this.context.getWorldTimeSyncMs(), - this.context.getActiveSystemId(), - this.context.systemFocusLocal, this.context.planetVisuals, ); } updateSystemSummaries() { - updateSystemSummaries(this.context.getWorld(), this.context.systemSummaryVisuals); + updateSystemSummaries(this.context.getWorld(), new Map()); } renderRecentEvents(entityKind: string, entityId: string) { @@ -138,9 +118,11 @@ export class ViewerPresentationController { world: this.context.getWorld(), activeSystemId: this.context.getActiveSystemId(), cameraMode: this.context.getCameraMode(), - zoomLevel: this.context.getZoomLevel(), + povLevel: this.context.getPovLevel(), selectedItems: this.context.getSelectedItems(), mode, + galaxyAnchor: this.context.galaxyAnchor, + systemAnchor: this.context.systemAnchor, }); } @@ -166,14 +148,14 @@ export class ViewerPresentationController { return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top); } - private resolveOrbitLineOpacity(orbitLine: OrbitLineVisual, zoomLevel: "local" | "system" | "universe", activeSystemId?: string) { - if (zoomLevel === "universe" || !activeSystemId || orbitLine.systemId !== activeSystemId) { + private resolveOrbitLineOpacity(orbitLine: OrbitLineVisual, povLevel: "local" | "system" | "galaxy", activeSystemId?: string) { + if (povLevel === "galaxy" || !activeSystemId || orbitLine.systemId !== activeSystemId) { return 0; } const selected = this.context.getSelectedItems(); const selectedItem = selected.length === 1 ? selected[0] : undefined; - const baseAlpha = zoomLevel === "local" ? 0.55 : 0.9; + const baseAlpha = povLevel === "local" ? 0.55 : 0.9; if (selectedItem?.kind === "planet" && selectedItem.systemId === activeSystemId) { return orbitLine.kind === "moon" && orbitLine.planetIndex === selectedItem.planetIndex diff --git a/apps/viewer/src/viewerRenderLoop.ts b/apps/viewer/src/viewerRenderLoop.ts index 25c0dd9..5f84823 100644 --- a/apps/viewer/src/viewerRenderLoop.ts +++ b/apps/viewer/src/viewerRenderLoop.ts @@ -1,12 +1,18 @@ import * as THREE from "three"; -import { classifyZoomLevel } from "./viewerMath"; -import type { PerformanceStats } from "./viewerTypes"; +import { classifyPovLevel } from "./viewerMath"; +import type { PovLevel, PerformanceStats } from "./viewerTypes"; export interface RenderFrameParams { clock: THREE.Clock; renderer: THREE.WebGLRenderer; - scene: THREE.Scene; - camera: THREE.PerspectiveCamera; + universeScene: THREE.Scene; + galaxyScene: THREE.Scene; + galaxyCamera: THREE.PerspectiveCamera; + systemScene: THREE.Scene; + systemCamera: THREE.PerspectiveCamera; + localScene: THREE.Scene; + localCamera: THREE.PerspectiveCamera; + getPovLevel: () => PovLevel; updateCamera: (delta: number) => void; updateAmbience: (delta: number) => void; updatePlanetPresentation: () => void; @@ -19,7 +25,9 @@ export interface RenderFrameParams { export interface ResizeParams { renderer: THREE.WebGLRenderer; - camera: THREE.PerspectiveCamera; + galaxyCamera: THREE.PerspectiveCamera; + systemCamera: THREE.PerspectiveCamera; + localCamera: THREE.PerspectiveCamera; } export interface CameraStepParams { @@ -38,7 +46,26 @@ export function renderFrame(params: RenderFrameParams) { params.updateShipPresentation(); params.updateNetworkPanel(); params.applyZoomPresentation(); - params.renderer.render(params.scene, params.camera); + + const povLevel = params.getPovLevel(); + const activeCamera = povLevel === "galaxy" ? params.galaxyCamera : params.systemCamera; + params.renderer.autoClear = false; + params.renderer.clear(); + // Universe backdrop — always first, rendered with the active camera so it aligns with the foreground + params.renderer.render(params.universeScene, activeCamera); + params.renderer.clearDepth(); + if (povLevel === "galaxy") { + // Galaxy map on top of universe backdrop + params.renderer.render(params.galaxyScene, params.galaxyCamera); + } else if (povLevel === "system") { + params.renderer.render(params.systemScene, params.systemCamera); + } else { + // local: system as mid-ground backdrop, then local on top + params.renderer.render(params.systemScene, params.systemCamera); + params.renderer.clearDepth(); + params.renderer.render(params.localScene, params.localCamera); + } + params.recordPerformanceStats(performance.now() - frameStartedAtMs); params.updatePerformancePanel(); } @@ -46,14 +73,16 @@ export function renderFrame(params: RenderFrameParams) { export function resizeViewer(params: ResizeParams) { const width = window.innerWidth; const height = window.innerHeight; - params.camera.aspect = width / height; - params.camera.updateProjectionMatrix(); + for (const camera of [params.galaxyCamera, params.systemCamera, params.localCamera]) { + camera.aspect = width / height; + camera.updateProjectionMatrix(); + } params.renderer.setSize(width, height); } export function stepCamera(params: CameraStepParams) { const currentDistance = THREE.MathUtils.damp(params.currentDistance, params.desiredDistance, 7.5, params.delta); - const zoomLevel = classifyZoomLevel(currentDistance); + const povLevel = classifyPovLevel(currentDistance); const orbitPitch = THREE.MathUtils.clamp(params.orbitPitch, 0.18, 1.3); - return { currentDistance, zoomLevel, orbitPitch }; + return { currentDistance, povLevel, orbitPitch }; } diff --git a/apps/viewer/src/viewerSceneAppearance.ts b/apps/viewer/src/viewerSceneAppearance.ts index e710e97..e1e3f5a 100644 --- a/apps/viewer/src/viewerSceneAppearance.ts +++ b/apps/viewer/src/viewerSceneAppearance.ts @@ -43,7 +43,7 @@ export function shipPresentationColor(ship: ShipSnapshot) { return shipColor(ship.kind); } -export function spatialNodeColor(kind: string) { +export function celestialColor(kind: string) { if (kind.includes("lagrange")) { return "#7fe8ff"; } diff --git a/apps/viewer/src/viewerSceneDataController.ts b/apps/viewer/src/viewerSceneDataController.ts index 5a5988c..2a4f743 100644 --- a/apps/viewer/src/viewerSceneDataController.ts +++ b/apps/viewer/src/viewerSceneDataController.ts @@ -1,55 +1,49 @@ import * as THREE from "three"; import { + applyCelestialDeltas as applyCelestialDeltaUpdates, applyClaimDeltas as applyClaimDeltaUpdates, applyConstructionSiteDeltas as applyConstructionSiteDeltaUpdates, - applyLocalBubbleDeltas as applyLocalBubbleDeltaUpdates, applyNodeDeltas as applyNodeDeltaUpdates, applyShipDeltas as applyShipDeltaUpdates, - applySpatialNodeDeltas as applySpatialNodeDeltaUpdates, applyStationDeltas as applyStationDeltaUpdates, rebuildSystems as rebuildSystemScene, + syncCelestials as syncCelestialScene, syncClaims as syncClaimScene, syncConstructionSites as syncConstructionSiteScene, - syncLocalBubbles as syncBubbleScene, syncNodes as syncNodeScene, syncShips as syncShipScene, - syncSpatialNodes as syncSpatialNodeScene, syncStations as syncStationScene, } from "./viewerSceneSync"; import { deriveNodeOrbital, deriveOrbitalFromLocalPosition, - resolveBubblePosition, resolveOrbitalAnchor, resolvePointPosition, - setBubbleVisualState, } from "./viewerWorldPresentation"; import { createCirclePoints, shipLength, shipPresentationColor, shipSize, - spatialNodeColor, + celestialColor, } from "./viewerSceneAppearance"; import type { + CelestialDelta, + CelestialSnapshot, ClaimDelta, ClaimSnapshot, ConstructionSiteDelta, ConstructionSiteSnapshot, - LocalBubbleDelta, - LocalBubbleSnapshot, ResourceNodeDelta, ResourceNodeSnapshot, ShipDelta, ShipSnapshot, - SpatialNodeDelta, - SpatialNodeSnapshot, StationDelta, StationSnapshot, SystemSnapshot, } from "./contracts"; -import type { OrbitLineVisual, OrbitalAnchor } from "./viewerTypes"; -import type { SceneNode } from "./viewerScenePrimitives"; +import type { OrbitLineVisual, OrbitalAnchor, Selectable } from "./viewerTypes"; +import { rawObject } from "./viewerScenePrimitives"; export interface ViewerSceneDataContext { documentRef: Document; @@ -58,102 +52,162 @@ export interface ViewerSceneDataContext { getWorldSeed: () => number; getWorldTimeSyncMs: () => number; getWorldPresentationContext: () => any; - systemGroup: THREE.Group; - spatialNodeGroup: THREE.Group; - bubbleGroup: THREE.Group; + getActiveSystemId: () => string | undefined; + galaxySystemGroup: THREE.Group; + systemScene: THREE.Scene; + celestialGroup: THREE.Group; nodeGroup: THREE.Group; stationGroup: THREE.Group; claimGroup: THREE.Group; constructionSiteGroup: THREE.Group; shipGroup: THREE.Group; - selectableTargets: Map; - presentationEntries: any[]; + galaxySelectableTargets: Map; + systemSelectableTargets: Map; systemVisuals: Map; - systemSummaryVisuals: Map; planetVisuals: any[]; orbitLines: OrbitLineVisual[]; - spatialNodeVisuals: Map; - bubbleVisuals: Map; + celestialVisuals: Map; nodeVisuals: Map; stationVisuals: Map; claimVisuals: Map; constructionSiteVisuals: Map; shipVisuals: Map; - registerPresentation: (detail: SceneNode, icon: SceneNode, hideDetailInUniverse: boolean, hideIconInUniverse?: boolean, systemId?: string) => void; } export class ViewerSceneDataController { + private activeSystemRootInScene: THREE.Object3D | undefined; + constructor(private readonly context: ViewerSceneDataContext) {} rebuildSystems(systems: SystemSnapshot[]) { + this.activeSystemRootInScene = undefined; rebuildSystemScene(this.createSceneSyncContext(), systems); + // Re-activate the current active system if any + const activeId = this.context.getActiveSystemId(); + if (activeId) { + this.activateSystemRoot(activeId); + } } - syncSpatialNodes(nodes: SpatialNodeSnapshot[]) { - syncSpatialNodeScene(this.createSceneSyncContext(), nodes); - } - - syncLocalBubbles(bubbles: LocalBubbleSnapshot[]) { - syncBubbleScene(this.createSceneSyncContext(), bubbles); + syncCelestials(celestials: CelestialSnapshot[]) { + syncCelestialScene(this.createSceneSyncContext(), celestials, this.context.getActiveSystemId()); } syncNodes(nodes: ResourceNodeSnapshot[]) { - syncNodeScene(this.createSceneSyncContext(), nodes); + syncNodeScene(this.createSceneSyncContext(), nodes, this.context.getActiveSystemId()); } syncStations(stations: StationSnapshot[]) { - syncStationScene(this.createSceneSyncContext(), stations); + syncStationScene(this.createSceneSyncContext(), stations, this.context.getActiveSystemId()); } syncClaims(claims: ClaimSnapshot[]) { - syncClaimScene(this.createSceneSyncContext(), claims); + syncClaimScene(this.createSceneSyncContext(), claims, this.context.getActiveSystemId()); } syncConstructionSites(sites: ConstructionSiteSnapshot[]) { - syncConstructionSiteScene(this.createSceneSyncContext(), sites); + syncConstructionSiteScene(this.createSceneSyncContext(), sites, this.context.getActiveSystemId()); } syncShips(ships: ShipSnapshot[], tickIntervalMs: number) { - syncShipScene(this.createSceneSyncContext(), ships, tickIntervalMs); + syncShipScene(this.createSceneSyncContext(), ships, tickIntervalMs, this.context.getActiveSystemId()); } - applySpatialNodeDeltas(nodes: SpatialNodeDelta[]) { - applySpatialNodeDeltaUpdates(this.createSceneSyncContext(), nodes); - } - - applyLocalBubbleDeltas(bubbles: LocalBubbleDelta[]) { - applyLocalBubbleDeltaUpdates(this.createSceneSyncContext(), bubbles); + applyCelestialDeltas(celestials: CelestialDelta[]) { + applyCelestialDeltaUpdates(this.createSceneSyncContext(), celestials, this.context.getActiveSystemId()); } applyNodeDeltas(nodes: ResourceNodeDelta[]) { - applyNodeDeltaUpdates(this.createSceneSyncContext(), nodes); + applyNodeDeltaUpdates(this.createSceneSyncContext(), nodes, this.context.getActiveSystemId()); } applyStationDeltas(stations: StationDelta[]) { - applyStationDeltaUpdates(this.createSceneSyncContext(), stations); + applyStationDeltaUpdates(this.createSceneSyncContext(), stations, this.context.getActiveSystemId()); } applyClaimDeltas(claims: ClaimDelta[]) { - applyClaimDeltaUpdates(this.createSceneSyncContext(), claims); + applyClaimDeltaUpdates(this.createSceneSyncContext(), claims, this.context.getActiveSystemId()); } applyConstructionSiteDeltas(sites: ConstructionSiteDelta[]) { - applyConstructionSiteDeltaUpdates(this.createSceneSyncContext(), sites); + applyConstructionSiteDeltaUpdates(this.createSceneSyncContext(), sites, this.context.getActiveSystemId()); } applyShipDeltas(ships: ShipDelta[], tickIntervalMs: number) { - applyShipDeltaUpdates(this.createSceneSyncContext(), ships, tickIntervalMs); + applyShipDeltaUpdates(this.createSceneSyncContext(), ships, tickIntervalMs, this.context.getActiveSystemId()); + } + + /** + * Called when the active system changes. Swaps which system's root is in systemScene + * and updates visibility of all system-filtered objects. + */ + onActiveSystemChanged(oldSystemId: string | undefined, newSystemId: string | undefined) { + // Remove old system's root from systemScene + if (this.activeSystemRootInScene) { + this.context.systemScene.remove(this.activeSystemRootInScene); + this.activeSystemRootInScene = undefined; + } + + // Add new system's root to systemScene + if (newSystemId) { + this.activateSystemRoot(newSystemId); + } + + // Update visibility of all system-filtered objects + this.updateSystemObjectVisibility(newSystemId); + } + + private activateSystemRoot(systemId: string) { + const visual = this.context.systemVisuals.get(systemId); + if (!visual) { + return; + } + const threeObj = rawObject(visual.systemRoot); + this.context.systemScene.add(threeObj); + this.activeSystemRootInScene = threeObj; + } + + private updateSystemObjectVisibility(activeSystemId: string | undefined) { + for (const visual of this.context.celestialVisuals.values()) { + const isActive = visual.systemId === activeSystemId; + visual.mesh.setVisible(isActive); + visual.icon.setVisible(isActive); + } + for (const visual of this.context.nodeVisuals.values()) { + const isActive = visual.systemId === activeSystemId; + visual.mesh.setVisible(isActive); + visual.icon.setVisible(isActive); + } + for (const visual of this.context.stationVisuals.values()) { + const isActive = visual.systemId === activeSystemId; + visual.mesh.setVisible(isActive); + visual.icon.setVisible(isActive); + } + for (const visual of this.context.claimVisuals.values()) { + const isActive = visual.systemId === activeSystemId; + visual.mesh.setVisible(isActive); + visual.icon.setVisible(isActive); + } + for (const visual of this.context.constructionSiteVisuals.values()) { + const isActive = visual.systemId === activeSystemId; + visual.mesh.setVisible(isActive); + visual.icon.setVisible(isActive); + } + for (const visual of this.context.shipVisuals.values()) { + const isActive = visual.systemId === activeSystemId; + visual.mesh.setVisible(isActive); + visual.icon.setVisible(isActive); + } } createWorldPresentationContext(overrides: { world: any; activeSystemId?: string; - zoomLevel: any; + povLevel: any; orbitYaw: number; - camera: THREE.PerspectiveCamera; - systemFocusLocal: THREE.Vector3; - toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3; - updateSystemDetailVisibility: () => void; + systemCamera: THREE.PerspectiveCamera; + systemAnchor: THREE.Vector3; + toDisplayLocalPosition: (localPosition: THREE.Vector3) => THREE.Vector3; setShellReticleOpacity: (sprite: any, opacity: number) => void; }) { return { @@ -161,21 +215,20 @@ export class ViewerSceneDataController { worldTimeSyncMs: this.context.getWorldTimeSyncMs(), worldSeed: this.context.getWorldSeed(), activeSystemId: overrides.activeSystemId, - zoomLevel: overrides.zoomLevel, + povLevel: overrides.povLevel, orbitYaw: overrides.orbitYaw, - camera: overrides.camera, - systemFocusLocal: overrides.systemFocusLocal, + camera: overrides.systemCamera, + systemAnchor: overrides.systemAnchor, shipVisuals: this.context.shipVisuals, nodeVisuals: this.context.nodeVisuals, - spatialNodeVisuals: this.context.spatialNodeVisuals, - bubbleVisuals: this.context.bubbleVisuals, + celestialVisuals: this.context.celestialVisuals, stationVisuals: this.context.stationVisuals, claimVisuals: this.context.claimVisuals, constructionSiteVisuals: this.context.constructionSiteVisuals, systemVisuals: this.context.systemVisuals, - systemSummaryVisuals: this.context.systemSummaryVisuals, + systemSummaryVisuals: new Map(), toDisplayLocalPosition: overrides.toDisplayLocalPosition, - updateSystemDetailVisibility: overrides.updateSystemDetailVisibility, + updateSystemDetailVisibility: () => {}, setShellReticleOpacity: overrides.setShellReticleOpacity, }; } @@ -187,39 +240,33 @@ export class ViewerSceneDataController { orbitalSimulationSpeed: this.context.getOrbitalSimulationSpeed(), worldSeed: this.context.getWorldSeed(), worldTimeSyncMs: this.context.getWorldTimeSyncMs(), - systemGroup: this.context.systemGroup, - spatialNodeGroup: this.context.spatialNodeGroup, - bubbleGroup: this.context.bubbleGroup, + galaxySystemGroup: this.context.galaxySystemGroup, + celestialGroup: this.context.celestialGroup, nodeGroup: this.context.nodeGroup, stationGroup: this.context.stationGroup, claimGroup: this.context.claimGroup, constructionSiteGroup: this.context.constructionSiteGroup, shipGroup: this.context.shipGroup, - selectableTargets: this.context.selectableTargets, - presentationEntries: this.context.presentationEntries, + galaxySelectableTargets: this.context.galaxySelectableTargets, + systemSelectableTargets: this.context.systemSelectableTargets, systemVisuals: this.context.systemVisuals, - systemSummaryVisuals: this.context.systemSummaryVisuals, planetVisuals: this.context.planetVisuals, orbitLines: this.context.orbitLines, - spatialNodeVisuals: this.context.spatialNodeVisuals, - bubbleVisuals: this.context.bubbleVisuals, + celestialVisuals: this.context.celestialVisuals, nodeVisuals: this.context.nodeVisuals, stationVisuals: this.context.stationVisuals, claimVisuals: this.context.claimVisuals, constructionSiteVisuals: this.context.constructionSiteVisuals, shipVisuals: this.context.shipVisuals, - registerPresentation: this.context.registerPresentation, shipSize, shipLength, shipPresentationColor, - spatialNodeColor, + celestialColor, createCirclePoints, - resolveBubblePosition: (bubble: LocalBubbleSnapshot | LocalBubbleDelta) => resolveBubblePosition(this.context.getWorldPresentationContext(), bubble), - resolvePointPosition: (systemId: string, nodeId?: string | null) => resolvePointPosition(this.context.getWorldPresentationContext(), systemId, nodeId), + resolvePointPosition: (systemId: string, celestialId?: string | null) => resolvePointPosition(this.context.getWorldPresentationContext(), systemId, celestialId), resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => resolveOrbitalAnchor(this.context.getWorldPresentationContext(), systemId, localPosition), deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: OrbitalAnchor) => deriveNodeOrbital(this.context.getWorldPresentationContext(), node, anchor), deriveOrbitalFromLocalPosition: (localPosition: THREE.Vector3, systemId: string, anchor: OrbitalAnchor) => deriveOrbitalFromLocalPosition(this.context.getWorldPresentationContext(), localPosition, systemId, anchor), - setBubbleVisualState, }; } } diff --git a/apps/viewer/src/viewerSceneFactory.ts b/apps/viewer/src/viewerSceneFactory.ts index ab566bc..b87eb9a 100644 --- a/apps/viewer/src/viewerSceneFactory.ts +++ b/apps/viewer/src/viewerSceneFactory.ts @@ -5,17 +5,16 @@ import { STAR_RENDER_SCALE, } from "./viewerConstants"; import type { + CelestialSnapshot, ClaimSnapshot, ConstructionSiteSnapshot, - LocalBubbleSnapshot, PlanetSnapshot, ResourceNodeSnapshot, ShipSnapshot, - SpatialNodeSnapshot, StationSnapshot, SystemSnapshot, } from "./contracts"; -import type { MoonVisual, SystemSummaryVisual } from "./viewerTypes"; +import type { MoonVisual } from "./viewerTypes"; import { celestialRenderRadius, computeMoonOrbitRadius, @@ -46,10 +45,10 @@ export function createNodeMesh(node: ResourceNodeSnapshot): SceneNode { return createSceneNode(mesh); } -export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColor: (kind: string) => string): SceneNode { - const color = spatialNodeColor(node.kind); +export function createCelestialMesh(node: CelestialSnapshot, celestialColor: (kind: string) => string): SceneNode { + const color = celestialColor(node.kind); return createSceneNode(new THREE.Mesh( - new THREE.OctahedronGeometry(10, 0), + new THREE.OctahedronGeometry(0.08, 0), new THREE.MeshStandardMaterial({ color, emissive: new THREE.Color(color).multiplyScalar(0.16), @@ -59,23 +58,6 @@ export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColo )); } -export function createBubbleRing( - bubble: LocalBubbleSnapshot, - localPosition: THREE.Vector3, - createCirclePoints: (radius: number, segments: number) => THREE.Vector3[], -): SceneNode { - const ring = new THREE.LineLoop( - new THREE.BufferGeometry().setFromPoints(createCirclePoints(Math.max(bubble.radius, 60), 64)), - new THREE.LineBasicMaterial({ - color: 0x6ed6ff, - transparent: true, - opacity: 0.32, - }), - ); - ring.position.copy(localPosition); - return createSceneNode(ring); -} - export function createClaimMesh(claim: ClaimSnapshot): SceneNode { return createSceneNode(new THREE.Mesh( new THREE.ConeGeometry(9, 20, 4), @@ -363,20 +345,34 @@ export function createTacticalIcon(documentRef: Document, color: string, size: n return createSceneNode(sprite); } -export function createSystemSummaryVisual(documentRef: Document, anchor: THREE.Vector3): SystemSummaryVisual { + +export function createStarDot(documentRef: Document, color: string): SceneNode { const canvas = documentRef.createElement("canvas"); - canvas.width = 512; - canvas.height = 160; + canvas.width = 32; + canvas.height = 32; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Unable to create star dot canvas"); + } + + context.clearRect(0, 0, 32, 32); + context.fillStyle = color; + context.beginPath(); + context.arc(16, 16, 12, 0, Math.PI * 2); + context.fill(); + const texture = new THREE.CanvasTexture(canvas); - const sprite = createSceneNode(new THREE.Sprite(new THREE.SpriteMaterial({ + const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false, depthTest: false, - }))); - sprite.object.scale.set(520, 160, 1); - sprite.setVisible(false); - return { sprite, texture, anchor }; + color: "#ffffff", + fog: false, + })); + sprite.scale.setScalar(4); + sprite.visible = false; + return createSceneNode(sprite); } export function createShellReticle(documentRef: Document, color: string, size: number): SceneNode { diff --git a/apps/viewer/src/viewerSceneSync.ts b/apps/viewer/src/viewerSceneSync.ts index 8dac432..8dc5c84 100644 --- a/apps/viewer/src/viewerSceneSync.ts +++ b/apps/viewer/src/viewerSceneSync.ts @@ -1,36 +1,33 @@ import * as THREE from "three"; import { + ACTIVE_SYSTEM_DETAIL_SCALE, PLANET_RENDER_SCALE, STAR_RENDER_SCALE, } from "./viewerConstants"; +import { DISPLAY_UNITS_PER_KILOMETER } from "./viewerMath"; import type { - BubbleVisual, + CelestialVisual, ClaimVisual, ConstructionSiteVisual, NodeVisual, OrbitLineVisual, PlanetVisual, - PresentationEntry, Selectable, ShipVisual, - SpatialNodeVisual, StructureVisual, - SystemSummaryVisual, SystemVisual, } from "./viewerTypes"; import type { + CelestialDelta, + CelestialSnapshot, ClaimDelta, ClaimSnapshot, ConstructionSiteDelta, ConstructionSiteSnapshot, - LocalBubbleDelta, - LocalBubbleSnapshot, ResourceNodeDelta, ResourceNodeSnapshot, ShipDelta, ShipSnapshot, - SpatialNodeDelta, - SpatialNodeSnapshot, StationDelta, StationSnapshot, SystemSnapshot, @@ -45,7 +42,6 @@ import { } from "./viewerMath"; import { getAnimatedShipLocalPosition } from "./viewerPresentation"; import { - createBubbleRing, createClaimMesh, createConstructionSiteMesh, createMoonVisuals, @@ -54,10 +50,10 @@ import { createPlanetRing, createShellReticle, createShipMesh, - createSpatialNodeMesh, + createCelestialMesh, createStarCluster, + createStarDot, createStationMesh, - createSystemSummaryVisual, createTacticalIcon, } from "./viewerSceneFactory"; import { @@ -68,47 +64,41 @@ import { } from "./viewerScenePrimitives"; import type { SceneNode } from "./viewerScenePrimitives"; +/** Scale a local km position to system-scene display coordinates. */ +function toSystemPos(localPosition: THREE.Vector3): THREE.Vector3 { + return localPosition.clone().multiplyScalar(DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE); +} + interface SceneSyncContext { documentRef: Document; worldOrbitalTimeSeconds?: number; orbitalSimulationSpeed: number; worldSeed: number; worldTimeSyncMs: number; - systemGroup: THREE.Group; - spatialNodeGroup: THREE.Group; - bubbleGroup: THREE.Group; + galaxySystemGroup: THREE.Group; + celestialGroup: THREE.Group; nodeGroup: THREE.Group; stationGroup: THREE.Group; claimGroup: THREE.Group; constructionSiteGroup: THREE.Group; shipGroup: THREE.Group; - selectableTargets: Map; - presentationEntries: PresentationEntry[]; + galaxySelectableTargets: Map; + systemSelectableTargets: Map; systemVisuals: Map; - systemSummaryVisuals: Map; planetVisuals: PlanetVisual[]; orbitLines: OrbitLineVisual[]; - spatialNodeVisuals: Map; - bubbleVisuals: Map; + celestialVisuals: Map; nodeVisuals: Map; stationVisuals: Map; claimVisuals: Map; constructionSiteVisuals: Map; shipVisuals: Map; - registerPresentation: ( - detail: SceneNode, - icon: SceneNode, - hideDetailInUniverse: boolean, - hideIconInUniverse?: boolean, - systemId?: string, - ) => void; shipSize: (ship: ShipSnapshot) => number; shipLength: (ship: ShipSnapshot) => number; shipPresentationColor: (ship: ShipSnapshot) => string; - spatialNodeColor: (kind: string) => string; + celestialColor: (kind: string) => string; createCirclePoints: (radius: number, segments: number) => THREE.Vector3[]; - resolveBubblePosition: (bubble: LocalBubbleSnapshot | LocalBubbleDelta) => THREE.Vector3; - resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3; + resolvePointPosition: (systemId: string, celestialId?: string | null) => THREE.Vector3; resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => NodeVisual["anchor"]; deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: NodeVisual["anchor"]) => { radius: number; @@ -120,7 +110,6 @@ interface SceneSyncContext { phase: number; inclination: number; }; - setBubbleVisualState: (visual: BubbleVisual, bubble: LocalBubbleSnapshot | LocalBubbleDelta) => void; } export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapshot[]) { @@ -128,44 +117,37 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho ? context.worldOrbitalTimeSeconds + ((performance.now() - context.worldTimeSyncMs) / 1000 * context.orbitalSimulationSpeed) : 0; - context.systemGroup.clear(); - context.selectableTargets.clear(); - context.presentationEntries.length = 0; + context.galaxySystemGroup.clear(); + context.galaxySelectableTargets.clear(); + context.systemSelectableTargets.clear(); context.planetVisuals.length = 0; context.orbitLines.length = 0; context.systemVisuals.clear(); - context.systemSummaryVisuals.clear(); for (const system of systems) { - const root = createSceneNode(new THREE.Group()); - root.setPosition(toDisplayGalaxyVector(system.galaxyPosition)); - const detailGroup = createSceneNode(new THREE.Group()); - const renderedStarSize = celestialRenderRadius(system.starSize, 0.00018, 0.16, 0.62); + // Galaxy root: star dot + shell reticle — lives in galaxyScene + const galaxyRoot = createSceneNode(new THREE.Group()); + galaxyRoot.setPosition(toDisplayGalaxyVector(system.galaxyPosition)); - const starCluster = createStarCluster(system); - const systemIcon = createTacticalIcon(context.documentRef, system.starColor, 96); + const systemIcon = createStarDot(context.documentRef, system.starColor); const shellReticle = createShellReticle(context.documentRef, "#ff3b30", 400); - const summaryVisual = createSystemSummaryVisual( - context.documentRef, - toDisplayGalaxyVector(system.galaxyPosition).add(new THREE.Vector3(0, renderedStarSize + 140, 0)), - ); - summaryVisual.sprite.setPosition(new THREE.Vector3(0, renderedStarSize + 110, 0)); - root.add(starCluster, systemIcon, shellReticle, summaryVisual.sprite, detailGroup); - context.registerPresentation(starCluster, systemIcon, true); - context.systemVisuals.set(system.id, { - root, + galaxyRoot.add(systemIcon, shellReticle); + + registerSelectableTarget(context.galaxySelectableTargets, systemIcon, { kind: "system", id: system.id }); + registerSelectableTarget(context.galaxySelectableTargets, shellReticle, { kind: "system", id: system.id }); + + // System root: star cluster + planet detail group — added to systemScene only when this system is active + const systemRoot = createSceneNode(new THREE.Group()); + const detailGroup = createSceneNode(new THREE.Group()); + const starCluster = createStarCluster(system); + systemRoot.add(starCluster, detailGroup); + + registerSelectableDescendants( + context.systemSelectableTargets, starCluster, - icon: systemIcon, - shellReticle, - shellReticleBaseScale: 400, - detailGroup, - summary: summaryVisual, - galaxyPosition: toDisplayGalaxyVector(system.galaxyPosition), - }); - context.systemSummaryVisuals.set(system.id, summaryVisual); - registerSelectableDescendants(context.selectableTargets, starCluster, { kind: "system", id: system.id }, (child) => child instanceof THREE.Mesh); - registerSelectableTarget(context.selectableTargets, systemIcon, { kind: "system", id: system.id }); - registerSelectableTarget(context.selectableTargets, shellReticle, { kind: "system", id: system.id }); + { kind: "system", id: system.id }, + (child) => child instanceof THREE.Mesh, + ); for (const [planetIndex, planet] of system.planets.entries()) { const orbit = createPlanetOrbit(planet); @@ -179,12 +161,13 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho emissive: new THREE.Color(planet.color).multiplyScalar(0.04), }), )); - planetMesh.setPosition(scaleLocalVector(computePlanetLocalPosition(planet, worldTimeSeconds))); + const initialPos = toSystemPos(computePlanetLocalPosition(planet, worldTimeSeconds)); + planetMesh.setPosition(initialPos); const planetIcon = createTacticalIcon(context.documentRef, planet.color, Math.max(24, renderedPlanetRadius * 2)); - planetIcon.setPosition(rawObject(planetMesh).position.clone()); + planetIcon.setPosition(initialPos); const ring = planet.hasRing ? createPlanetRing(planet) : undefined; if (ring) { - ring.setPosition(rawObject(planetMesh).position.clone()); + ring.setPosition(initialPos); } const moons = createMoonVisuals(planet, context.worldSeed); detailGroup.add(orbit, planetMesh, planetIcon); @@ -194,8 +177,8 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho for (const moon of moons) { moon.systemId = system.id; moon.planetIndex = planetIndex; - moon.orbit.setPosition(rawObject(planetMesh).position.clone()); - moon.mesh.setPosition(rawObject(planetMesh).position.clone()); + moon.orbit.setPosition(initialPos); + moon.mesh.setPosition(initialPos); detailGroup.add(moon.orbit, moon.mesh); context.orbitLines.push({ line: moon.orbit, @@ -203,7 +186,6 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho kind: "moon", planetIndex, }); - context.registerPresentation(moon.mesh, planetIcon, true, true, system.id); } context.orbitLines.push({ line: orbit, @@ -211,68 +193,73 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho kind: "planet", planetIndex, }); - context.registerPresentation(planetMesh, planetIcon, true, true, system.id); - if (ring) { - context.registerPresentation(ring, planetIcon, true, true, system.id); - } context.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, ring, moons }); - registerSelectableTarget(context.selectableTargets, planetMesh, { kind: "planet", systemId: system.id, planetIndex }); - registerSelectableTarget(context.selectableTargets, planetIcon, { kind: "planet", systemId: system.id, planetIndex }); + registerSelectableTarget(context.systemSelectableTargets, planetMesh, { kind: "planet", systemId: system.id, planetIndex }); + registerSelectableTarget(context.systemSelectableTargets, planetIcon, { kind: "planet", systemId: system.id, planetIndex }); } - context.systemGroup.add(rawObject(root)); + context.systemVisuals.set(system.id, { + galaxyRoot, + systemRoot, + starCluster, + icon: systemIcon, + shellReticle, + shellReticleBaseScale: 400, + detailGroup, + galaxyPosition: toDisplayGalaxyVector(system.galaxyPosition), + }); + + context.galaxySystemGroup.add(rawObject(galaxyRoot)); } } -export function syncSpatialNodes(context: SceneSyncContext, nodes: SpatialNodeSnapshot[]) { - context.spatialNodeGroup.clear(); - context.spatialNodeVisuals.clear(); +export function syncCelestials(context: SceneSyncContext, celestials: CelestialSnapshot[], activeSystemId?: string) { + context.celestialGroup.clear(); + context.celestialVisuals.clear(); - for (const node of nodes) { - const mesh = createSpatialNodeMesh(node, context.spatialNodeColor); - const icon = createTacticalIcon(context.documentRef, context.spatialNodeColor(node.kind), 18); - const localPosition = toThreeVector(node.localPosition); - mesh.setPosition(localPosition); - icon.setPosition(localPosition); - context.spatialNodeVisuals.set(node.id, { - id: node.id, - systemId: node.systemId, + for (const celestial of celestials) { + // Stars, planets, and moons are already rendered by rebuildSystems via SystemSnapshot. + // Only create visual objects for kinds not covered by the system builder. + if (celestial.kind === "star" || celestial.kind === "planet" || celestial.kind === "moon") { + continue; + } + + const mesh = createCelestialMesh(celestial, context.celestialColor); + const icon = createTacticalIcon(context.documentRef, context.celestialColor(celestial.kind), 18); + const orbitalAnchor = toSystemPos(toThreeVector(celestial.orbitalAnchor)); + mesh.setPosition(orbitalAnchor); + icon.setPosition(orbitalAnchor); + const isActive = celestial.systemId === activeSystemId; + mesh.setVisible(isActive); + icon.setVisible(isActive); + context.celestialVisuals.set(celestial.id, { + id: celestial.id, + systemId: celestial.systemId, mesh, icon, - kind: node.kind, - localPosition, + kind: celestial.kind, + orbitalAnchor, }); - context.spatialNodeGroup.add(rawObject(mesh), rawObject(icon)); - context.registerPresentation(mesh, icon, true, true, node.systemId); - registerSelectableTarget(context.selectableTargets, mesh, { kind: "spatial-node", id: node.id }); - registerSelectableTarget(context.selectableTargets, icon, { kind: "spatial-node", id: node.id }); + context.celestialGroup.add(rawObject(mesh), rawObject(icon)); + registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "celestial", id: celestial.id }); + registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "celestial", id: celestial.id }); } } -export function syncLocalBubbles(context: SceneSyncContext, bubbles: LocalBubbleSnapshot[]) { - context.bubbleGroup.clear(); - context.bubbleVisuals.clear(); - - for (const bubble of bubbles) { - const localPosition = context.resolveBubblePosition(bubble); - const mesh = createBubbleRing(bubble, localPosition, context.createCirclePoints); - const visual = { id: bubble.id, systemId: bubble.systemId, mesh, localPosition, radius: bubble.radius }; - context.setBubbleVisualState(visual, bubble); - context.bubbleVisuals.set(bubble.id, visual); - context.bubbleGroup.add(rawObject(mesh)); - registerSelectableTarget(context.selectableTargets, mesh, { kind: "bubble", id: bubble.id }); - } -} - -export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot[]) { +export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot[], activeSystemId?: string) { context.nodeGroup.clear(); context.nodeVisuals.clear(); for (const node of nodes) { const mesh = createNodeMesh(node); const icon = createTacticalIcon(context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20); - icon.setPosition(rawObject(mesh).position.clone()); const localPosition = toThreeVector(node.localPosition); + const displayPos = toSystemPos(localPosition); + mesh.setPosition(displayPos); + icon.setPosition(displayPos); + const isActive = node.systemId === activeSystemId; + mesh.setVisible(isActive); + icon.setVisible(isActive); const anchor = context.resolveOrbitalAnchor(node.systemId, localPosition); const orbital = context.deriveNodeOrbital(node, anchor); context.nodeVisuals.set(node.id, { @@ -287,21 +274,25 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot orbitInclination: orbital.inclination, }); context.nodeGroup.add(rawObject(mesh), rawObject(icon)); - context.registerPresentation(mesh, icon, true, true, node.systemId); - registerSelectableTarget(context.selectableTargets, mesh, { kind: "node", id: node.id }); - registerSelectableTarget(context.selectableTargets, icon, { kind: "node", id: node.id }); + registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "node", id: node.id }); + registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "node", id: node.id }); } } -export function syncStations(context: SceneSyncContext, stations: StationSnapshot[]) { +export function syncStations(context: SceneSyncContext, stations: StationSnapshot[], activeSystemId?: string) { context.stationGroup.clear(); context.stationVisuals.clear(); for (const station of stations) { const mesh = createStationMesh(station); const icon = createTacticalIcon(context.documentRef, station.color, 26); - icon.setPosition(rawObject(mesh).position.clone()); const localPosition = toThreeVector(station.localPosition); + const displayPos = toSystemPos(localPosition); + mesh.setPosition(displayPos); + icon.setPosition(displayPos); + const isActive = station.systemId === activeSystemId; + mesh.setVisible(isActive); + icon.setVisible(isActive); const anchor = context.resolveOrbitalAnchor(station.systemId, localPosition); const orbital = context.deriveOrbitalFromLocalPosition(localPosition, station.systemId, anchor); context.stationVisuals.set(station.id, { @@ -316,63 +307,68 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho localPosition, }); context.stationGroup.add(rawObject(mesh), rawObject(icon)); - context.registerPresentation(mesh, icon, true, true, station.systemId); - registerSelectableTarget(context.selectableTargets, mesh, { kind: "station", id: station.id }); - registerSelectableTarget(context.selectableTargets, icon, { kind: "station", id: station.id }); + registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "station", id: station.id }); + registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "station", id: station.id }); } } -export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[]) { +export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[], activeSystemId?: string) { context.claimGroup.clear(); context.claimVisuals.clear(); for (const claim of claims) { - const localPosition = context.resolvePointPosition(claim.systemId, claim.nodeId); + const localPosition = context.resolvePointPosition(claim.systemId, claim.celestialId); + const displayPos = toSystemPos(localPosition); const mesh = createClaimMesh(claim); const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 18); - mesh.setPosition(localPosition); - icon.setPosition(localPosition); + mesh.setPosition(displayPos); + icon.setPosition(displayPos); + const isActive = claim.systemId === activeSystemId; + mesh.setVisible(isActive); + icon.setVisible(isActive); context.claimVisuals.set(claim.id, { id: claim.id, - nodeId: claim.nodeId, + celestialId: claim.celestialId, systemId: claim.systemId, mesh, icon, localPosition, }); context.claimGroup.add(rawObject(mesh), rawObject(icon)); - context.registerPresentation(mesh, icon, true, true, claim.systemId); - registerSelectableTarget(context.selectableTargets, mesh, { kind: "claim", id: claim.id }); - registerSelectableTarget(context.selectableTargets, icon, { kind: "claim", id: claim.id }); + registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "claim", id: claim.id }); + registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "claim", id: claim.id }); } } -export function syncConstructionSites(context: SceneSyncContext, sites: ConstructionSiteSnapshot[]) { +export function syncConstructionSites(context: SceneSyncContext, sites: ConstructionSiteSnapshot[], activeSystemId?: string) { context.constructionSiteGroup.clear(); context.constructionSiteVisuals.clear(); for (const site of sites) { - const localPosition = context.resolvePointPosition(site.systemId, site.nodeId); + const localPosition = context.resolvePointPosition(site.systemId, site.celestialId); + const displayPos = toSystemPos(localPosition); const mesh = createConstructionSiteMesh(site); const icon = createTacticalIcon(context.documentRef, "#9df29c", 18); - mesh.setPosition(localPosition); - icon.setPosition(localPosition); + mesh.setPosition(displayPos); + icon.setPosition(displayPos); + const isActive = site.systemId === activeSystemId; + mesh.setVisible(isActive); + icon.setVisible(isActive); context.constructionSiteVisuals.set(site.id, { id: site.id, - nodeId: site.nodeId, + celestialId: site.celestialId, systemId: site.systemId, mesh, icon, localPosition, }); context.constructionSiteGroup.add(rawObject(mesh), rawObject(icon)); - context.registerPresentation(mesh, icon, true, true, site.systemId); - registerSelectableTarget(context.selectableTargets, mesh, { kind: "construction-site", id: site.id }); - registerSelectableTarget(context.selectableTargets, icon, { kind: "construction-site", id: site.id }); + registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "construction-site", id: site.id }); + registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "construction-site", id: site.id }); } } -export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tickIntervalMs: number) { +export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tickIntervalMs: number, activeSystemId?: string) { context.shipGroup.clear(); context.shipVisuals.clear(); @@ -380,19 +376,23 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick const mesh = createShipMesh(ship, context.shipSize(ship), context.shipLength(ship), context.shipPresentationColor(ship)); const shipColor = context.shipPresentationColor(ship); const icon = createTacticalIcon(context.documentRef, shipColor, 18); - const position = toThreeVector(ship.localPosition); - icon.setPosition(position); + const localPosition = toThreeVector(ship.localPosition); + const displayPos = toSystemPos(localPosition); + mesh.setPosition(displayPos); + icon.setPosition(displayPos); icon.setColor(shipColor); + const isActive = ship.systemId === activeSystemId; + mesh.setVisible(isActive); + icon.setVisible(isActive); context.shipGroup.add(rawObject(mesh), rawObject(icon)); - registerSelectableTarget(context.selectableTargets, mesh, { kind: "ship", id: ship.id }); - registerSelectableTarget(context.selectableTargets, icon, { kind: "ship", id: ship.id }); - context.registerPresentation(mesh, icon, true, true, ship.systemId); + registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "ship", id: ship.id }); + registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "ship", id: ship.id }); context.shipVisuals.set(ship.id, { systemId: ship.systemId, mesh, icon, - startPosition: position.clone(), - authoritativePosition: position.clone(), + startPosition: localPosition.clone(), + authoritativePosition: localPosition.clone(), targetPosition: toThreeVector(ship.targetLocalPosition), velocity: toThreeVector(ship.localVelocity), receivedAtMs: performance.now(), @@ -401,39 +401,30 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick } } -export function applySpatialNodeDeltas(context: SceneSyncContext, nodes: SpatialNodeDelta[]) { - for (const node of nodes) { - const visual = context.spatialNodeVisuals.get(node.id); +export function applyCelestialDeltas(context: SceneSyncContext, celestials: CelestialDelta[], activeSystemId?: string) { + for (const celestial of celestials) { + if (celestial.kind === "star" || celestial.kind === "planet" || celestial.kind === "moon") { + continue; + } + + const visual = context.celestialVisuals.get(celestial.id); if (!visual) { continue; } - visual.systemId = node.systemId; - visual.kind = node.kind; - visual.localPosition.copy(toThreeVector(node.localPosition)); - visual.mesh.setPosition(visual.localPosition); - visual.icon.setPosition(visual.localPosition); - visual.mesh.setColor(context.spatialNodeColor(node.kind)); + visual.systemId = celestial.systemId; + visual.kind = celestial.kind; + visual.orbitalAnchor.copy(toSystemPos(toThreeVector(celestial.orbitalAnchor))); + visual.mesh.setPosition(visual.orbitalAnchor); + visual.icon.setPosition(visual.orbitalAnchor); + visual.mesh.setColor(context.celestialColor(celestial.kind)); + const isActive = visual.systemId === activeSystemId; + visual.mesh.setVisible(isActive); + visual.icon.setVisible(isActive); } } -export function applyLocalBubbleDeltas(context: SceneSyncContext, bubbles: LocalBubbleDelta[]) { - for (const bubble of bubbles) { - const visual = context.bubbleVisuals.get(bubble.id); - if (!visual) { - continue; - } - - visual.systemId = bubble.systemId; - visual.radius = bubble.radius; - visual.localPosition.copy(context.resolveBubblePosition(bubble)); - visual.mesh.setPosition(visual.localPosition); - visual.mesh.setScaleScalar(Math.max(bubble.radius, 60)); - context.setBubbleVisualState(visual, bubble); - } -} - -export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDelta[]) { +export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDelta[], activeSystemId?: string) { for (const node of nodes) { const visual = context.nodeVisuals.get(node.id); if (!visual) { @@ -449,10 +440,13 @@ export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDe visual.orbitPhase = orbital.phase; visual.orbitInclination = orbital.inclination; visual.mesh.setScaleScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); + const isActive = visual.systemId === activeSystemId; + visual.mesh.setVisible(isActive); + visual.icon.setVisible(isActive); } } -export function applyStationDeltas(context: SceneSyncContext, stations: StationDelta[]) { +export function applyStationDeltas(context: SceneSyncContext, stations: StationDelta[], activeSystemId?: string) { for (const station of stations) { const visual = context.stationVisuals.get(station.id); if (!visual) { @@ -468,10 +462,13 @@ export function applyStationDeltas(context: SceneSyncContext, stations: StationD visual.orbitInclination = orbital.inclination; visual.mesh.setColor(station.color); visual.mesh.setEmissive(station.color, 0.1); + const isActive = visual.systemId === activeSystemId; + visual.mesh.setVisible(isActive); + visual.icon.setVisible(isActive); } } -export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[]) { +export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[], activeSystemId?: string) { for (const claim of claims) { const visual = context.claimVisuals.get(claim.id); if (!visual) { @@ -479,15 +476,19 @@ export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[] } visual.systemId = claim.systemId; - visual.localPosition.copy(context.resolvePointPosition(claim.systemId, claim.nodeId)); - visual.mesh.setPosition(visual.localPosition); - visual.icon.setPosition(visual.localPosition); + visual.localPosition.copy(context.resolvePointPosition(claim.systemId, claim.celestialId)); + const displayPos = toSystemPos(visual.localPosition); + visual.mesh.setPosition(displayPos); + visual.icon.setPosition(displayPos); visual.mesh.setColor(claim.state === "active" ? "#ff7f50" : "#ff5b5b"); visual.mesh.setEmissive(claim.state === "active" ? "#ffb27d" : "#7a2020"); + const isActive = visual.systemId === activeSystemId; + visual.mesh.setVisible(isActive); + visual.icon.setVisible(isActive); } } -export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: ConstructionSiteDelta[]) { +export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: ConstructionSiteDelta[], activeSystemId?: string) { for (const site of sites) { const visual = context.constructionSiteVisuals.get(site.id); if (!visual) { @@ -495,15 +496,19 @@ export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: Co } visual.systemId = site.systemId; - visual.localPosition.copy(context.resolvePointPosition(site.systemId, site.nodeId)); - visual.mesh.setPosition(visual.localPosition); - visual.icon.setPosition(visual.localPosition); + visual.localPosition.copy(context.resolvePointPosition(site.systemId, site.celestialId)); + const displayPos = toSystemPos(visual.localPosition); + visual.mesh.setPosition(displayPos); + visual.icon.setPosition(displayPos); visual.mesh.setColor(site.state === "completed" ? "#46d37f" : "#9df29c"); visual.mesh.setScaleScalar(0.75 + site.progress * 0.35); + const isActive = visual.systemId === activeSystemId; + visual.mesh.setVisible(isActive); + visual.icon.setVisible(isActive); } } -export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], tickIntervalMs: number) { +export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], tickIntervalMs: number, activeSystemId?: string) { for (const ship of ships) { const visual = context.shipVisuals.get(ship.id); if (!visual) { @@ -521,5 +526,8 @@ export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], t visual.mesh.setColor(shipColor); visual.mesh.setEmissive(shipColor, 0.18); visual.icon.setColor(shipColor); + const isActive = visual.systemId === activeSystemId; + visual.mesh.setVisible(isActive); + visual.icon.setVisible(isActive); } } diff --git a/apps/viewer/src/viewerSelection.ts b/apps/viewer/src/viewerSelection.ts index 574e055..2c87889 100644 --- a/apps/viewer/src/viewerSelection.ts +++ b/apps/viewer/src/viewerSelection.ts @@ -1,4 +1,4 @@ -import type { ShipSnapshot, SpatialNodeSnapshot, SystemSnapshot } from "./contracts"; +import type { CelestialSnapshot, ShipSnapshot, SystemSnapshot } from "./contracts"; import type { CameraMode, OrbitalAnchor, @@ -21,11 +21,8 @@ export function describeSelectable(world: WorldState | undefined, item: Selectab if (item.kind === "node") { return item.id; } - if (item.kind === "spatial-node") { - return `${world.spatialNodes.get(item.id)?.kind ?? "node"} ${item.id}`; - } - if (item.kind === "bubble") { - return `bubble ${item.id}`; + if (item.kind === "celestial") { + return `${world.celestials.get(item.id)?.kind ?? "celestial"} ${item.id}`; } if (item.kind === "claim") { return `claim ${item.id}`; @@ -53,7 +50,29 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab } if (item.kind === "system") { - return world.systems.get(item.id)?.label ?? item.id; + const system = world.systems.get(item.id); + if (!system) { + return item.id; + } + const starLabel = system.starCount > 1 ? `${system.starCount}× ${system.starKind}` : system.starKind; + const planetCount = system.planets.length; + const shipCount = [...world.ships.values()].filter((s) => s.systemId === item.id).length; + const stationCount = [...world.stations.values()].filter((s) => s.systemId === item.id).length; + const lines = [ + system.label, + `${starLabel} · ${planetCount} planet${planetCount !== 1 ? "s" : ""}`, + ]; + const parts: string[] = []; + if (shipCount > 0) { + parts.push(`${shipCount} ship${shipCount !== 1 ? "s" : ""}`); + } + if (stationCount > 0) { + parts.push(`${stationCount} station${stationCount !== 1 ? "s" : ""}`); + } + if (parts.length > 0) { + lines.push(parts.join(" · ")); + } + return lines.join("\n"); } if (item.kind === "planet") { @@ -68,46 +87,38 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab return item.id; } - const anchorPath = node.anchorNodeId - ? describeSpatialNodePathWithinSystem(world, node.systemId, node.anchorNodeId) + const anchorPath = node.celestialId + ? describeCelestialPathWithinSystem(world, node.systemId, node.celestialId) : undefined; return anchorPath ? `${anchorPath} / ${node.itemId}` : `${node.systemId} / ${node.itemId}`; } - if (item.kind === "spatial-node") { - const node = world.spatialNodes.get(item.id); - if (!node) { + if (item.kind === "celestial") { + const celestial = world.celestials.get(item.id); + if (!celestial) { return item.id; } - if (node.kind === "star") { - const system = world.systems.get(node.systemId); - return system ? `${system.label} star` : `${node.systemId} star`; + if (celestial.kind === "star") { + const system = world.systems.get(celestial.systemId); + return system ? `${system.label} star` : `${celestial.systemId} star`; } - return describeSpatialNodePathWithinSystem(world, node.systemId, node.id) ?? `${node.systemId} / ${node.kind}`; - } - - if (item.kind === "bubble") { - const bubble = world.localBubbles.get(item.id); - const anchorPath = bubble?.nodeId - ? describeSpatialNodePathWithinSystem(world, bubble.systemId, bubble.nodeId) - : undefined; - return anchorPath ? `${anchorPath} bubble` : `Bubble ${item.id}`; + return describeCelestialPathWithinSystem(world, celestial.systemId, celestial.id) ?? `${celestial.systemId} / ${celestial.kind}`; } if (item.kind === "claim") { const claim = world.claims.get(item.id); - const anchorPath = claim?.nodeId - ? describeSpatialNodePathWithinSystem(world, claim.systemId, claim.nodeId) + const anchorPath = claim?.celestialId + ? describeCelestialPathWithinSystem(world, claim.systemId, claim.celestialId) : undefined; return anchorPath ? `${anchorPath} claim` : `Claim ${item.id}`; } if (item.kind === "construction-site") { const site = world.constructionSites.get(item.id); - const anchorPath = site?.nodeId - ? describeSpatialNodePathWithinSystem(world, site.systemId, site.nodeId) + const anchorPath = site?.celestialId + ? describeCelestialPathWithinSystem(world, site.systemId, site.celestialId) : undefined; const siteLabel = site ? (site.blueprintId ?? site.targetDefinitionId) : item.id; return anchorPath ? `${anchorPath} / ${siteLabel}` : `Construction ${siteLabel}`; @@ -123,8 +134,6 @@ export function getSelectionGroup(item: Selectable): SelectionGroup { if ( item.kind === "station" || item.kind === "node" - || item.kind === "spatial-node" - || item.kind === "bubble" || item.kind === "claim" || item.kind === "construction-site" ) { @@ -147,11 +156,8 @@ export function resolveSelectableSystemId(world: WorldState | undefined, selecti if (selection.kind === "node") { return world.nodes.get(selection.id)?.systemId; } - if (selection.kind === "spatial-node") { - return world.spatialNodes.get(selection.id)?.systemId; - } - if (selection.kind === "bubble") { - return world.localBubbles.get(selection.id)?.systemId; + if (selection.kind === "celestial") { + return world.celestials.get(selection.id)?.systemId; } if (selection.kind === "claim") { return world.claims.get(selection.id)?.systemId; @@ -165,29 +171,26 @@ export function resolveSelectableSystemId(world: WorldState | undefined, selecti return selection.id; } -export function resolveFocusedBubbleId(world: WorldState | undefined, selectedItems: Selectable[]): string | undefined { +export function resolveFocusedCelestialId(world: WorldState | undefined, selectedItems: Selectable[]): string | undefined { if (!world || selectedItems.length !== 1) { return undefined; } const selected = selectedItems[0]; - if (selected.kind === "bubble") { + if (selected.kind === "celestial") { return selected.id; } if (selected.kind === "ship") { - return world.ships.get(selected.id)?.bubbleId ?? world.ships.get(selected.id)?.spatialState.currentBubbleId ?? undefined; + return world.ships.get(selected.id)?.spatialState.currentCelestialId ?? world.ships.get(selected.id)?.celestialId ?? undefined; } if (selected.kind === "station") { - return world.stations.get(selected.id)?.bubbleId ?? undefined; - } - if (selected.kind === "spatial-node") { - return world.spatialNodes.get(selected.id)?.bubbleId ?? undefined; + return world.stations.get(selected.id)?.celestialId ?? undefined; } if (selected.kind === "claim") { - return world.claims.get(selected.id)?.bubbleId ?? undefined; + return world.claims.get(selected.id)?.celestialId ?? undefined; } if (selected.kind === "construction-site") { - return world.constructionSites.get(selected.id)?.bubbleId ?? undefined; + return world.constructionSites.get(selected.id)?.celestialId ?? undefined; } return undefined; } @@ -232,8 +235,7 @@ export function renderSystemDetails( let shipCount = 0; let stationCount = 0; let nodeCount = 0; - let spatialNodeCount = 0; - let bubbleCount = 0; + let celestialCount = 0; let claimCount = 0; let constructionCount = 0; let moonCount = 0; @@ -253,14 +255,9 @@ export function renderSystemDetails( nodeCount += 1; } } - for (const node of world.spatialNodes.values()) { - if (node.systemId === system.id) { - spatialNodeCount += 1; - } - } - for (const bubble of world.localBubbles.values()) { - if (bubble.systemId === system.id) { - bubbleCount += 1; + for (const celestial of world.celestials.values()) { + if (celestial.systemId === system.id) { + celestialCount += 1; } } for (const claim of world.claims.values()) { @@ -285,7 +282,7 @@ export function renderSystemDetails(

${system.id}${activeContext ? " · active system" : ""}

${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}

Planets ${system.planets.length}
Moons ${moonCount}
Ships ${shipCount}
Stations ${stationCount}

-

Spatial nodes ${spatialNodeCount}
Resource nodes ${nodeCount}
Bubbles ${bubbleCount}

+

Celestials ${celestialCount}
Resource nodes ${nodeCount}

Claims ${claimCount}
Construction sites ${constructionCount}

Height ${formatGalaxyDistance(system.galaxyPosition.y)}

${system.planets.slice(0, 8).map((planet) => `${planet.label} (${planet.planetType})`).join("
")}

@@ -308,18 +305,18 @@ export function describeShipState(world: WorldState | undefined, ship: ShipSnaps return baseState; } - const destinationNode = world.spatialNodes.get(destinationNodeId); - if (!destinationNode) { + const destinationCelestial = world.celestials.get(destinationNodeId); + if (!destinationCelestial) { return `${baseState} -> ${destinationNodeId}`; } if (baseState === "warping" || baseState === "spooling-warp") { - const destinationPath = describeSpatialNodePathWithinSystem(world, destinationNode.systemId, destinationNodeId); + const destinationPath = describeCelestialPathWithinSystem(world, destinationCelestial.systemId, destinationNodeId); return `${baseState} -> ${destinationPath ?? destinationNodeId}`; } - const destinationSystem = world.systems.get(destinationNode.systemId); - return `${baseState} -> ${destinationSystem?.label ?? destinationNode.systemId}`; + const destinationSystem = world.systems.get(destinationCelestial.systemId); + return `${baseState} -> ${destinationSystem?.label ?? destinationCelestial.systemId}`; } function describeControllerTask(taskKind: string): string { @@ -381,8 +378,8 @@ export function describeShipLocation(world: WorldState | undefined, ship: ShipSn if (ship.dockedStationId) { const station = world.stations.get(ship.dockedStationId); if (station) { - const anchorPath = station.anchorNodeId - ? describeSpatialNodePathWithinSystem(world, station.systemId, station.anchorNodeId) + const anchorPath = station.celestialId + ? describeCelestialPathWithinSystem(world, station.systemId, station.celestialId) : undefined; return { system: systemLabel, @@ -391,22 +388,11 @@ export function describeShipLocation(world: WorldState | undefined, ship: ShipSn } } - const currentNodeId = ship.spatialState.currentNodeId ?? ship.nodeId; - if (currentNodeId) { - const nodePath = describeSpatialNodePathWithinSystem(world, systemId, currentNodeId); - if (nodePath) { - return { system: systemLabel, local: nodePath }; - } - } - - const currentBubbleId = ship.spatialState.currentBubbleId ?? ship.bubbleId; - if (currentBubbleId) { - const bubble = world.localBubbles.get(currentBubbleId); - if (bubble?.nodeId) { - const nodePath = describeSpatialNodePathWithinSystem(world, systemId, bubble.nodeId); - if (nodePath) { - return { system: systemLabel, local: nodePath }; - } + const currentCelestialId = ship.spatialState.currentCelestialId ?? ship.celestialId; + if (currentCelestialId) { + const celestialPath = describeCelestialPathWithinSystem(world, systemId, currentCelestialId); + if (celestialPath) { + return { system: systemLabel, local: celestialPath }; } } @@ -415,11 +401,11 @@ export function describeShipLocation(world: WorldState | undefined, ship: ShipSn export function describeActiveSpace( world: WorldState | undefined, - zoomLevel: "local" | "system" | "universe", + povLevel: "local" | "system" | "galaxy", activeSystemId: string | undefined, selectedItems: Selectable[], ): string { - if (!world || zoomLevel === "universe") { + if (!world || povLevel === "galaxy") { return "deep-space"; } @@ -428,16 +414,13 @@ export function describeActiveSpace( return "deep-space"; } - if (zoomLevel !== "local") { + if (povLevel !== "local") { return activeSystem.label; } - const bubbleId = resolveFocusedBubbleId(world, selectedItems); - if (bubbleId) { - const bubble = world.localBubbles.get(bubbleId); - const localPath = bubble?.nodeId - ? describeSpatialNodePathWithinSystem(world, activeSystem.id, bubble.nodeId) - : undefined; + const celestialId = resolveFocusedCelestialId(world, selectedItems); + if (celestialId) { + const localPath = describeCelestialPathWithinSystem(world, activeSystem.id, celestialId); return localPath ? `${activeSystem.label} / ${localPath}` : activeSystem.label; @@ -454,51 +437,43 @@ export function describeActiveSpace( return activeSystem.label; } -export function describeSpatialNodePathWithinSystem(world: WorldState, systemId: string, nodeId: string): string | undefined { - const node = world.spatialNodes.get(nodeId); +export function describeCelestialPathWithinSystem(world: WorldState, systemId: string, celestialId: string): string | undefined { + const celestial = world.celestials.get(celestialId); const system = world.systems.get(systemId); - if (!node || !system) { + if (!celestial || !system) { return undefined; } - if (node.parentNodeId) { - const parentPath = describeSpatialNodePathWithinSystem(world, systemId, node.parentNodeId); - const segment = describeSpatialNodeSegment(world, system, node); + if (celestial.parentNodeId) { + const parentPath = describeCelestialPathWithinSystem(world, systemId, celestial.parentNodeId); + const segment = describeCelestialSegment(system, celestial); return parentPath ? `${parentPath}/${segment}` : segment; } - if (node.kind === "star") { + if (celestial.kind === "star") { return undefined; } - return describeSpatialNodeSegment(world, system, node); + return describeCelestialSegment(system, celestial); } -function describeSpatialNodeSegment(world: WorldState, system: SystemSnapshot, node: SpatialNodeSnapshot): string { - const moonMatch = node.id.match(/-planet-(\d+)-moon-(\d+)$/); +function describeCelestialSegment(system: SystemSnapshot, celestial: CelestialSnapshot): string { + const moonMatch = celestial.id.match(/-planet-(\d+)-moon-(\d+)$/); if (moonMatch) { const moonIndex = Number.parseInt(moonMatch[2], 10); return `Moon ${moonIndex}`; } - const lagrangeMatch = node.id.match(/-planet-\d+-(l[1-5])$/); + const lagrangeMatch = celestial.id.match(/-planet-\d+-(l[1-5])$/); if (lagrangeMatch) { return lagrangeMatch[1].toUpperCase(); } - const planetMatch = node.id.match(/-planet-(\d+)$/); + const planetMatch = celestial.id.match(/-planet-(\d+)$/); if (planetMatch) { const planetIndex = Number.parseInt(planetMatch[1], 10) - 1; return system.planets[planetIndex]?.label ?? `Planet ${planetMatch[1]}`; } - if (node.kind === "station" && node.occupyingStructureId) { - return world.stations.get(node.occupyingStructureId)?.label ?? node.occupyingStructureId; - } - - if (node.kind === "resource-site") { - return node.orbitReferenceId ?? "Resource Site"; - } - - return node.orbitReferenceId ?? node.kind; + return celestial.orbitReferenceId ?? celestial.kind; } diff --git a/apps/viewer/src/viewerState.ts b/apps/viewer/src/viewerState.ts index 9e1d90b..a3cee0c 100644 --- a/apps/viewer/src/viewerState.ts +++ b/apps/viewer/src/viewerState.ts @@ -40,8 +40,7 @@ export function createWorldState(snapshot: WorldSnapshot): WorldState { orbitalSimulation: snapshot.orbitalSimulation, generatedAtUtc: snapshot.generatedAtUtc, systems: new Map(snapshot.systems.map((system) => [system.id, system])), - spatialNodes: new Map(snapshot.spatialNodes.map((node) => [node.id, node])), - localBubbles: new Map(snapshot.localBubbles.map((bubble) => [bubble.id, bubble])), + celestials: new Map(snapshot.celestials.map((celestial) => [celestial.id, celestial])), nodes: new Map(snapshot.nodes.map((node) => [node.id, node])), stations: new Map(snapshot.stations.map((station) => [station.id, station])), claims: new Map(snapshot.claims.map((claim) => [claim.id, claim])), @@ -62,11 +61,8 @@ export function applyDeltaToWorld(world: WorldState, delta: WorldDelta): boolean world.generatedAtUtc = delta.generatedAtUtc; world.recentEvents = [...delta.events, ...world.recentEvents].slice(0, 18); - for (const node of delta.spatialNodes) { - world.spatialNodes.set(node.id, node); - } - for (const bubble of delta.localBubbles) { - world.localBubbles.set(bubble.id, bubble); + for (const celestial of delta.celestials) { + world.celestials.set(celestial.id, celestial); } for (const node of delta.nodes) { world.nodes.set(node.id, node); @@ -100,8 +96,7 @@ export function recordDeltaStats(networkStats: NetworkStats, delta: WorldDelta, const changedEntities = delta.ships.length + delta.stations.length + delta.nodes.length - + delta.spatialNodes.length - + delta.localBubbles.length + + delta.celestials.length + delta.claims.length + delta.constructionSites.length + delta.marketOrders.length diff --git a/apps/viewer/src/viewerSystemLayer.ts b/apps/viewer/src/viewerSystemLayer.ts new file mode 100644 index 0000000..a7392ad --- /dev/null +++ b/apps/viewer/src/viewerSystemLayer.ts @@ -0,0 +1,47 @@ +import * as THREE from "three"; +import type { Selectable } from "./viewerTypes"; + +/** + * System rendering layer. + * Scene coordinate unit: km * DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE. + * Camera far plane covers a solar system. + * Only the active system's objects are visible; inactive system objects are hidden in place. + */ +export class SystemLayer { + readonly scene = new THREE.Scene(); + readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 50000); + + readonly celestialGroup = new THREE.Group(); + readonly nodeGroup = new THREE.Group(); + readonly stationGroup = new THREE.Group(); + readonly claimGroup = new THREE.Group(); + readonly constructionSiteGroup = new THREE.Group(); + readonly shipGroup = new THREE.Group(); + + readonly selectableTargets = new Map(); + + constructor() { + 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.celestialGroup, + this.nodeGroup, + this.stationGroup, + this.claimGroup, + this.constructionSiteGroup, + this.shipGroup, + ); + } + + updateCamera(systemFocus: THREE.Vector3, orbitOffset: THREE.Vector3) { + this.camera.position.copy(systemFocus).add(orbitOffset); + this.camera.lookAt(systemFocus); + } + + onResize(aspect: number) { + this.camera.aspect = aspect; + this.camera.updateProjectionMatrix(); + } +} diff --git a/apps/viewer/src/viewerTypes.ts b/apps/viewer/src/viewerTypes.ts index ffd7a2d..c3ede19 100644 --- a/apps/viewer/src/viewerTypes.ts +++ b/apps/viewer/src/viewerTypes.ts @@ -1,23 +1,22 @@ import * as THREE from "three"; import type { SceneNode } from "./viewerScenePrimitives"; import type { + CelestialSnapshot, ClaimSnapshot, ConstructionSiteSnapshot, FactionSnapshot, - LocalBubbleSnapshot, MarketOrderSnapshot, PlanetSnapshot, PolicySetSnapshot, ResourceNodeSnapshot, ShipSnapshot, SimulationEventRecord, - SpatialNodeSnapshot, StationSnapshot, SystemSnapshot, OrbitalSimulationSnapshot, } from "./contracts"; -export type ZoomLevel = "local" | "system" | "universe"; +export type PovLevel = "local" | "system" | "galaxy"; export type SelectionGroup = "ships" | "structures" | "celestials"; export type DragMode = "orbit" | "marquee"; export type CameraMode = "tactical" | "follow"; @@ -26,8 +25,7 @@ export type Selectable = | { kind: "ship"; id: string } | { kind: "station"; id: string } | { kind: "node"; id: string } - | { kind: "spatial-node"; id: string } - | { kind: "bubble"; id: string } + | { kind: "celestial"; id: string } | { kind: "claim"; id: string } | { kind: "construction-site"; id: string } | { kind: "system"; id: string } @@ -86,26 +84,18 @@ export interface NodeVisual { orbitInclination: number; } -export interface SpatialNodeVisual { +export interface CelestialVisual { id: string; systemId: string; mesh: SceneNode; icon: SceneNode; kind: string; - localPosition: THREE.Vector3; -} - -export interface BubbleVisual { - id: string; - systemId: string; - mesh: SceneNode; - localPosition: THREE.Vector3; - radius: number; + orbitalAnchor: THREE.Vector3; } export interface ClaimVisual { id: string; - nodeId: string; + celestialId: string; systemId: string; mesh: SceneNode; icon: SceneNode; @@ -114,7 +104,7 @@ export interface ClaimVisual { export interface ConstructionSiteVisual { id: string; - nodeId: string; + celestialId: string; systemId: string; mesh: SceneNode; icon: SceneNode; @@ -134,13 +124,13 @@ export interface StructureVisual { } export interface SystemVisual { - root: SceneNode; + galaxyRoot: SceneNode; // lives in galaxyScene (star dot + shell reticle) + systemRoot: SceneNode; // added/removed from systemScene when system becomes active/inactive starCluster: SceneNode; - icon: SceneNode; - shellReticle: SceneNode; + icon: SceneNode; // star dot sprite (child of galaxyRoot) + shellReticle: SceneNode; // reticle sprite (child of galaxyRoot) shellReticleBaseScale: number; - detailGroup: SceneNode; - summary: SystemSummaryVisual; + detailGroup: SceneNode; // planets + moons (child of systemRoot) galaxyPosition: THREE.Vector3; } @@ -153,8 +143,7 @@ export interface WorldState { orbitalSimulation: OrbitalSimulationSnapshot; generatedAtUtc: string; systems: Map; - spatialNodes: Map; - localBubbles: Map; + celestials: Map; nodes: Map; stations: Map; claims: Map; @@ -195,20 +184,6 @@ export interface PerformanceStats { lastPanelUpdateAtMs: number; } -export interface PresentationEntry { - detail: SceneNode; - icon: SceneNode; - systemId?: string; - hideDetailInUniverse?: boolean; - hideIconInUniverse?: boolean; -} - -export interface SystemSummaryVisual { - sprite: SceneNode; - texture: THREE.CanvasTexture; - anchor: THREE.Vector3; -} - export interface HistoryWindowState { id: string; target: Selectable; diff --git a/apps/viewer/src/viewerUniverseLayer.ts b/apps/viewer/src/viewerUniverseLayer.ts new file mode 100644 index 0000000..ad079bb --- /dev/null +++ b/apps/viewer/src/viewerUniverseLayer.ts @@ -0,0 +1,25 @@ +import * as THREE from "three"; + +/** + * Universe rendering layer — always the first layer rendered. + * Contains the infinite backdrop: backdrop stars and nebula clouds. + * Has no dedicated camera; rendered with whichever camera is active for the current POV + * so the backdrop always aligns with the foreground view. + */ +export class UniverseLayer { + readonly scene = new THREE.Scene(); + + /** Backdrop stars and nebula clouds. Follows the active camera to act as a skybox. */ + readonly ambienceGroup = new THREE.Group(); + + constructor() { + this.scene.background = new THREE.Color(0x040912); + this.scene.add(this.ambienceGroup); + } + + updateAmbience(activeCamera: THREE.Camera, delta: number) { + this.ambienceGroup.position.copy(activeCamera.position); + this.ambienceGroup.rotation.y += delta * 0.005; + this.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015; + } +} diff --git a/apps/viewer/src/viewerWorldLifecycle.ts b/apps/viewer/src/viewerWorldLifecycle.ts index 92216eb..74d754e 100644 --- a/apps/viewer/src/viewerWorldLifecycle.ts +++ b/apps/viewer/src/viewerWorldLifecycle.ts @@ -3,19 +3,17 @@ import { renderOpsStrip } from "./viewerOpsStrip"; import { updateDetailPanel } from "./viewerPanels"; import { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState"; import type { + CelestialDelta, + CelestialSnapshot, ClaimDelta, ClaimSnapshot, ConstructionSiteDelta, ConstructionSiteSnapshot, FactionSnapshot, - LocalBubbleDelta, - LocalBubbleSnapshot, ResourceNodeDelta, ResourceNodeSnapshot, ShipDelta, ShipSnapshot, - SpatialNodeDelta, - SpatialNodeSnapshot, StationDelta, StationSnapshot, SystemSnapshot, @@ -27,7 +25,7 @@ import type { NetworkStats, Selectable, WorldState, - ZoomLevel, + PovLevel, } from "./viewerTypes"; export interface ViewerWorldLifecycleContext { @@ -41,7 +39,7 @@ export interface ViewerWorldLifecycleContext { setStream: (stream: EventSource | undefined) => void; getCurrentStreamScopeKey: () => string; setCurrentStreamScopeKey: (value: string) => void; - getZoomLevel: () => ZoomLevel; + getPovLevel: () => PovLevel; getActiveSystemId: () => string | undefined; getSelectedItems: () => Selectable[]; getCameraMode: () => CameraMode; @@ -54,22 +52,20 @@ export interface ViewerWorldLifecycleContext { detailBodyEl: HTMLDivElement; worldLabel: () => string; rebuildSystems: (systems: SystemSnapshot[]) => void; - syncSpatialNodes: (nodes: SpatialNodeSnapshot[]) => void; - syncLocalBubbles: (bubbles: LocalBubbleSnapshot[]) => void; + syncCelestials: (celestials: CelestialSnapshot[]) => void; syncNodes: (nodes: ResourceNodeSnapshot[]) => void; syncStations: (stations: StationSnapshot[]) => void; syncClaims: (claims: ClaimSnapshot[]) => void; syncConstructionSites: (sites: ConstructionSiteSnapshot[]) => void; syncShips: (ships: ShipSnapshot[], tickIntervalMs: number) => void; - applySpatialNodeDeltas: (nodes: SpatialNodeDelta[]) => void; - applyLocalBubbleDeltas: (bubbles: LocalBubbleDelta[]) => void; + applyCelestialDeltas: (celestials: CelestialDelta[]) => void; applyNodeDeltas: (nodes: ResourceNodeDelta[]) => void; applyStationDeltas: (stations: StationDelta[]) => void; applyClaimDeltas: (claims: ClaimDelta[]) => void; applyConstructionSiteDeltas: (sites: ConstructionSiteDelta[]) => void; applyShipDeltas: (ships: ShipDelta[], tickIntervalMs: number) => void; refreshHistoryWindows: () => void; - resolveFocusedBubbleId: () => string | undefined; + resolveFocusedCelestialId: () => string | undefined; updateSystemSummaries: () => void; applyZoomPresentation: () => void; updateNetworkPanel: () => void; @@ -161,8 +157,7 @@ export class ViewerWorldLifecycle { this.context.rebuildSystems(snapshot.systems); } - this.context.syncSpatialNodes(snapshot.spatialNodes); - this.context.syncLocalBubbles(snapshot.localBubbles); + this.context.syncCelestials(snapshot.celestials); this.context.syncNodes(snapshot.nodes); this.context.syncStations(snapshot.stations); this.context.syncClaims(snapshot.claims); @@ -182,8 +177,7 @@ export class ViewerWorldLifecycle { this.context.setWorldTimeSyncMs(performance.now()); applyDeltaToWorld(world, delta); - this.context.applySpatialNodeDeltas(delta.spatialNodes); - this.context.applyLocalBubbleDeltas(delta.localBubbles); + this.context.applyCelestialDeltas(delta.celestials); this.context.applyNodeDeltas(delta.nodes); this.context.applyStationDeltas(delta.stations); this.context.applyClaimDeltas(delta.claims); @@ -199,7 +193,7 @@ export class ViewerWorldLifecycle { this.context.getSelectedItems(), this.context.getCameraMode(), this.context.getCameraTargetShipId(), - this.context.getZoomLevel(), + this.context.getPovLevel(), this.context.getActiveSystemId(), ); } @@ -216,7 +210,7 @@ export class ViewerWorldLifecycle { updateDetailPanel(this.context.detailTitleEl, this.context.detailBodyEl, { world, selectedItems: this.context.getSelectedItems(), - zoomLevel: this.context.getZoomLevel(), + povLevel: this.context.getPovLevel(), cameraMode: this.context.getCameraMode(), cameraTargetShipId: this.context.getCameraTargetShipId(), worldLabel: this.context.worldLabel(), @@ -226,16 +220,16 @@ export class ViewerWorldLifecycle { private getPreferredStreamScope() { const activeSystemId = this.context.getActiveSystemId(); - if (this.context.getZoomLevel() === "universe" || !activeSystemId) { + if (this.context.getPovLevel() === "galaxy" || !activeSystemId) { return { scopeKind: "universe" as const }; } - const bubbleId = this.context.resolveFocusedBubbleId(); - if (this.context.getZoomLevel() === "local" && bubbleId) { + const celestialId = this.context.resolveFocusedCelestialId(); + if (this.context.getPovLevel() === "local" && celestialId) { return { - scopeKind: "local-bubble" as const, + scopeKind: "local-celestial" as const, systemId: activeSystemId, - bubbleId, + celestialId, }; } diff --git a/apps/viewer/src/viewerWorldPresentation.ts b/apps/viewer/src/viewerWorldPresentation.ts index 1133949..11b6a7b 100644 --- a/apps/viewer/src/viewerWorldPresentation.ts +++ b/apps/viewer/src/viewerWorldPresentation.ts @@ -1,42 +1,40 @@ import * as THREE from "three"; import { + DISPLAY_UNITS_PER_KILOMETER, + DISPLAY_UNITS_PER_LIGHT_YEAR, + KILOMETERS_PER_AU, computeMoonLocalPosition, computeMoonSize, computePlanetLocalPosition, currentWorldTimeSeconds, resolveOrbitalAnchorPosition, - toDisplayGalaxyVector, toThreeVector, } from "./viewerMath"; -import { describeActiveSpace } from "./viewerSelection"; +import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants"; +import { describeActiveSpace, resolveFocusedCelestialId } from "./viewerSelection"; import { resolveShipHeading, updateSystemStarPresentation, - updateSystemSummaryPresentation, getAnimatedShipLocalPosition, } from "./viewerPresentation"; import { rawObject } from "./viewerScenePrimitives"; import type { - LocalBubbleDelta, - LocalBubbleSnapshot, ResourceNodeDelta, ResourceNodeSnapshot, ShipSnapshot, } from "./contracts"; import type { - BubbleVisual, + CelestialVisual, ClaimVisual, Selectable, ConstructionSiteVisual, NodeVisual, OrbitalAnchor, ShipVisual, - SpatialNodeVisual, StructureVisual, - SystemSummaryVisual, SystemVisual, WorldState, - ZoomLevel, + PovLevel, CameraMode, } from "./viewerTypes"; @@ -47,23 +45,22 @@ export interface WorldOrbitalContext { worldTimeSyncMs: number; worldSeed: number; nodeVisuals: Map; - spatialNodeVisuals: Map; - bubbleVisuals: Map; + celestialVisuals: Map; stationVisuals: Map; } export interface WorldPresentationContext extends WorldOrbitalContext { activeSystemId?: string; - zoomLevel: ZoomLevel; + povLevel: PovLevel; orbitYaw: number; camera: THREE.PerspectiveCamera; - systemFocusLocal: THREE.Vector3; + systemAnchor: THREE.Vector3; shipVisuals: Map; claimVisuals: Map; constructionSiteVisuals: Map; systemVisuals: Map; - systemSummaryVisuals: Map; - toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3; + systemSummaryVisuals: Map; + toDisplayLocalPosition: (localPosition: THREE.Vector3) => THREE.Vector3; updateSystemDetailVisibility: () => void; setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], opacity: number) => void; } @@ -74,15 +71,17 @@ export interface GameStatusParams { world?: WorldState; activeSystemId?: string; cameraMode: CameraMode; - zoomLevel: ZoomLevel; + povLevel: PovLevel; selectedItems: Selectable[]; mode: string; + galaxyAnchor?: THREE.Vector3; + systemAnchor?: THREE.Vector3; } export function updateWorldPresentation(context: WorldPresentationContext) { const now = performance.now(); const worldTimeSeconds = currentWorldTimeSeconds(context.world, context.worldTimeSyncMs); - const renderMode = resolveRenderSpaceMode(context.activeSystemId, context.zoomLevel); + const renderMode = resolveRenderSpaceMode(context.activeSystemId, context.povLevel); for (const [shipId, visual] of context.shipVisuals.entries()) { const ship = context.world?.ships.get(shipId); @@ -91,7 +90,7 @@ export function updateWorldPresentation(context: WorldPresentationContext) { } const worldPosition = getAnimatedShipLocalPosition(visual, now); - const displayPosition = resolveShipWorldPosition(context, ship, visual, worldPosition); + const displayPosition = context.toDisplayLocalPosition(worldPosition); visual.mesh.setPosition(displayPosition); visual.icon.setPosition(rawObject(visual.mesh).position.clone()); const shipVisible = isShipVisible(renderMode, context.activeSystemId, ship); @@ -105,67 +104,51 @@ export function updateWorldPresentation(context: WorldPresentationContext) { for (const visual of context.nodeVisuals.values()) { const animatedLocalPosition = computeNodeLocalPosition(context, visual, worldTimeSeconds); - visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition)); visual.icon.setPosition(rawObject(visual.mesh).position.clone()); visual.mesh.setVisible(visual.systemId === context.activeSystemId); } - for (const visual of context.spatialNodeVisuals.values()) { - const animatedLocalPosition = computeSpatialNodeLocalPosition(context, visual, worldTimeSeconds); - visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + for (const visual of context.celestialVisuals.values()) { + const animatedLocalPosition = computeCelestialLocalPosition(context, visual, worldTimeSeconds); + visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition)); visual.icon.setPosition(rawObject(visual.mesh).position.clone()); visual.mesh.setVisible(visual.systemId === context.activeSystemId); visual.icon.setVisible(visual.systemId === context.activeSystemId); } - for (const visual of context.bubbleVisuals.values()) { - const animatedLocalPosition = resolveBubbleAnimatedLocalPosition(context, visual, worldTimeSeconds); - visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); - visual.mesh.setVisible(visual.systemId === context.activeSystemId); - } - for (const visual of context.stationVisuals.values()) { const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds); - visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition)); visual.icon.setPosition(rawObject(visual.mesh).position.clone()); visual.mesh.setVisible(visual.systemId === context.activeSystemId); } for (const visual of context.claimVisuals.values()) { - const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone(); - visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + const animatedLocalPosition = computeCelestialLocalPositionById(context, visual.celestialId, worldTimeSeconds) ?? visual.localPosition.clone(); + visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition)); visual.icon.setPosition(rawObject(visual.mesh).position.clone()); visual.mesh.setVisible(visual.systemId === context.activeSystemId); visual.icon.setVisible(visual.systemId === context.activeSystemId); } for (const visual of context.constructionSiteVisuals.values()) { - const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone(); - visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + const animatedLocalPosition = computeCelestialLocalPositionById(context, visual.celestialId, worldTimeSeconds) ?? visual.localPosition.clone(); + visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition)); visual.icon.setPosition(rawObject(visual.mesh).position.clone()); visual.mesh.setVisible(visual.systemId === context.activeSystemId); visual.icon.setVisible(visual.systemId === context.activeSystemId); } - - updateSystemStarPresentation( - context.systemVisuals, - context.activeSystemId, - context.systemFocusLocal, - context.camera, - context.setShellReticleOpacity, - ); - context.updateSystemDetailVisibility(); - updateSystemSummaryPresentation(context.systemSummaryVisuals, context.camera, context.activeSystemId); } type RenderSpaceMode = "galaxy" | "system" | "local"; -function resolveRenderSpaceMode(activeSystemId: string | undefined, zoomLevel: ZoomLevel): RenderSpaceMode { - if (!activeSystemId || zoomLevel === "universe") { +function resolveRenderSpaceMode(activeSystemId: string | undefined, povLevel: PovLevel): RenderSpaceMode { + if (!activeSystemId || povLevel === "galaxy") { return "galaxy"; } - return zoomLevel === "local" ? "local" : "system"; + return povLevel === "local" ? "local" : "system"; } function isShipVisible(mode: RenderSpaceMode, activeSystemId: string | undefined, ship: ShipSnapshot) { @@ -186,22 +169,11 @@ export function resolveShipWorldPosition( visual: ShipVisual, animatedLocalPosition = getAnimatedShipLocalPosition(visual), ) { - if (ship.spatialState.movementRegime === "ftl-transit") { - const destinationNodeId = ship.spatialState.transit?.destinationNodeId; - const destinationNode = destinationNodeId ? context.world?.spatialNodes.get(destinationNodeId) : undefined; - const originSystem = context.world?.systems.get(ship.systemId); - const destinationSystem = destinationNode ? context.world?.systems.get(destinationNode.systemId) : undefined; - if (originSystem && destinationSystem) { - const origin = toDisplayGalaxyVector(originSystem.galaxyPosition); - const destination = toDisplayGalaxyVector(destinationSystem.galaxyPosition); - return origin.lerp(destination, THREE.MathUtils.clamp(ship.spatialState.transit?.progress ?? 0, 0, 1)); - } - } - - return context.toDisplayLocalPosition(animatedLocalPosition, ship.systemId); + // FTL ships are invisible in system scene; just return their last known local position. + return context.toDisplayLocalPosition(animatedLocalPosition); } -export function updateSystemSummaries(world: WorldState | undefined, systemSummaryVisuals: Map) { +export function updateSystemSummaries(world: WorldState | undefined, systemSummaryVisuals: Map) { if (!world) { return; } @@ -275,26 +247,50 @@ export function renderRecentEvents(world: WorldState | undefined, entityKind: st .join("
"); } +function fmtVec(v: THREE.Vector3, digits: number) { + return `${v.x.toFixed(digits)} ${v.y.toFixed(digits)} ${v.z.toFixed(digits)}`; +} + export function updateGameStatus(params: GameStatusParams) { - const { statusEl, summaryEl, world, activeSystemId, cameraMode, zoomLevel, selectedItems, mode } = params; + const { statusEl, summaryEl, world, activeSystemId, cameraMode, povLevel, selectedItems, mode, galaxyAnchor, systemAnchor } = params; const sequence = world?.sequence ?? 0; const generatedAt = world?.generatedAtUtc ? new Date(world.generatedAtUtc).toLocaleTimeString() : "n/a"; - const displayZoomLevel = activeSystemId ? zoomLevel : "universe"; - const activeSpace = describeActiveSpace(world, displayZoomLevel, activeSystemId, selectedItems); + const displayPovLevel = activeSystemId ? povLevel : "galaxy"; + const activeSpace = describeActiveSpace(world, displayPovLevel, activeSystemId, selectedItems); const cameraModeLabel = cameraMode === "follow" ? "follow" : "map"; + // Galaxy space: galaxyAnchor in light-years — changes only during galaxy navigation + const galPos = galaxyAnchor + ? `gal pos: ${fmtVec(galaxyAnchor.clone().divideScalar(DISPLAY_UNITS_PER_LIGHT_YEAR), 2)} ly` + : ""; + // System space: systemAnchor in AU — changes only during system navigation + const sysPos = systemAnchor + ? `sys pos: ${fmtVec(systemAnchor.clone().divideScalar(KILOMETERS_PER_AU), 3)} AU` + : ""; + // Local space: position relative to the focused celestial's orbital anchor in km + const focusedCelestialId = resolveFocusedCelestialId(world, selectedItems); + const celestialAnchor = focusedCelestialId + ? world?.celestials.get(focusedCelestialId)?.orbitalAnchor + : undefined; + const locPos = systemAnchor && celestialAnchor + ? `loc pos: ${fmtVec(systemAnchor.clone().sub(toThreeVector(celestialAnchor)), 0)} km` + : ""; + statusEl.textContent = [ `mode: ${mode}`, `camera: ${cameraModeLabel}`, - `zoom: ${displayZoomLevel}`, + `zoom: ${displayPovLevel}`, `space: ${activeSpace}`, + galPos, + sysPos, + locPos, `sequence: ${sequence}`, `snapshot: ${generatedAt}`, - ].join("\n"); + ].filter(Boolean).join("\n"); if (summaryEl) { - summaryEl.textContent = `${mode} | ${displayZoomLevel} | ${activeSpace}`; + summaryEl.textContent = `${mode} | ${displayPovLevel} | ${activeSpace}`; } } @@ -372,58 +368,54 @@ export function resolveOrbitalAnchor(context: WorldOrbitalContext, systemId: str return bestAnchor; } -export function resolvePointPosition(context: WorldOrbitalContext, _systemId: string, nodeId?: string | null) { - if (nodeId) { - const spatialNode = context.world?.spatialNodes.get(nodeId); - if (spatialNode) { - return toThreeVector(spatialNode.localPosition); +export function resolvePointPosition(context: WorldOrbitalContext, _systemId: string, celestialId?: string | null) { + if (celestialId) { + const celestial = context.world?.celestials.get(celestialId); + if (celestial) { + return toThreeVector(celestial.orbitalAnchor); } } return new THREE.Vector3(0, 0, 0); } -export function resolveBubblePosition(context: WorldOrbitalContext, bubble: LocalBubbleSnapshot | LocalBubbleDelta) { - return resolvePointPosition(context, bubble.systemId, bubble.nodeId); +export function computeCelestialLocalPosition(context: WorldOrbitalContext, visual: CelestialVisual, timeSeconds: number) { + return computeCelestialLocalPositionById(context, visual.id, timeSeconds) ?? visual.orbitalAnchor.clone(); } -export function computeSpatialNodeLocalPosition(context: WorldOrbitalContext, visual: SpatialNodeVisual, timeSeconds: number) { - return computeSpatialNodeLocalPositionById(context, visual.id, timeSeconds) ?? visual.localPosition.clone(); -} - -export function computeSpatialNodeLocalPositionById( +export function computeCelestialLocalPositionById( context: WorldOrbitalContext, - nodeId: string, + celestialId: string, timeSeconds: number, visiting = new Set(), ): THREE.Vector3 | undefined { - if (!context.world || visiting.has(nodeId)) { + if (!context.world || visiting.has(celestialId)) { return undefined; } - const node = context.world.spatialNodes.get(nodeId); - if (!node) { + const celestial = context.world.celestials.get(celestialId); + if (!celestial) { return undefined; } - const basePosition = toThreeVector(node.localPosition); - if (!node.parentNodeId) { + const basePosition = toThreeVector(celestial.orbitalAnchor); + if (!celestial.parentNodeId) { return basePosition; } - const parentNode = context.world.spatialNodes.get(node.parentNodeId); - if (!parentNode) { + const parentCelestial = context.world.celestials.get(celestial.parentNodeId); + if (!parentCelestial) { return basePosition; } - visiting.add(nodeId); - const parentCurrentPosition = computeSpatialNodeLocalPositionById(context, node.parentNodeId, timeSeconds, visiting); - visiting.delete(nodeId); + visiting.add(celestialId); + const parentCurrentPosition = computeCelestialLocalPositionById(context, celestial.parentNodeId, timeSeconds, visiting); + visiting.delete(celestialId); if (!parentCurrentPosition) { return basePosition; } - const parentInitialPosition = toThreeVector(parentNode.localPosition); + const parentInitialPosition = toThreeVector(parentCelestial.orbitalAnchor); const relativeOffset = basePosition.clone().sub(parentInitialPosition); const initialAngle = Math.atan2(parentInitialPosition.z, parentInitialPosition.x); const currentAngle = Math.atan2(parentCurrentPosition.z, parentCurrentPosition.x); @@ -431,13 +423,6 @@ export function computeSpatialNodeLocalPositionById( return parentCurrentPosition.clone().add(rotatedOffset); } -export function setBubbleVisualState(visual: BubbleVisual, bubble: LocalBubbleSnapshot | LocalBubbleDelta) { - const intensity = bubble.occupantShipIds.length + bubble.occupantStationIds.length + bubble.occupantConstructionSiteIds.length; - const material = (rawObject(visual.mesh) as THREE.LineLoop).material as THREE.LineBasicMaterial; - material.opacity = THREE.MathUtils.clamp(0.18 + intensity * 0.05, 0.18, 0.72); - material.color.set(intensity > 0 ? "#7fffd4" : "#6ed6ff"); -} - function drawCountIcon( context: CanvasRenderingContext2D, kind: SummaryIconKind, @@ -504,24 +489,15 @@ function getOrbitalAnchorPosition(context: WorldOrbitalContext, systemId: string return resolveOrbitalAnchorPosition(context.world, systemId, anchor, timeSeconds, context.worldSeed); } -function resolveBubbleAnimatedLocalPosition(context: WorldOrbitalContext, visual: BubbleVisual, timeSeconds: number) { - const bubble = context.world?.localBubbles.get(visual.id); - if (!bubble) { - return visual.localPosition.clone(); - } - - return computeSpatialNodeLocalPositionById(context, bubble.nodeId, timeSeconds) ?? visual.localPosition.clone(); -} - function resolveStructureAnimatedLocalPosition(context: WorldOrbitalContext, visual: StructureVisual, timeSeconds: number) { if (!context.world) { return visual.localPosition.clone(); } const station = context.world.stations.get(visual.id); - if (!station?.nodeId) { + if (!station?.celestialId) { return computeStructureLocalPosition(context, visual, timeSeconds, 0.14); } - return computeSpatialNodeLocalPositionById(context, station.nodeId, timeSeconds) ?? visual.localPosition.clone(); + return computeCelestialLocalPositionById(context, station.celestialId, timeSeconds) ?? visual.localPosition.clone(); }