import * as THREE from "three"; import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds, resolveOrbitalAnchorPosition, toThreeVector, } from "./viewerMath"; import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants"; import { describeActiveSpace, resolveFocusedCelestialId } from "./viewerSelection"; import { resolveShipHeading, updateSystemStarPresentation, getAnimatedShipLocalPosition, iconWorldScale, } from "./viewerPresentation"; import { rawObject } from "./viewerScenePrimitives"; import type { ResourceNodeDelta, ResourceNodeSnapshot, ShipSnapshot, } from "./contracts"; import type { CelestialVisual, ClaimVisual, Selectable, ConstructionSiteVisual, NodeVisual, OrbitalAnchor, ShipVisual, StructureVisual, SystemVisual, WorldState, PovLevel, CameraMode, } from "./viewerTypes"; type SummaryIconKind = "ship" | "station" | "structure"; const SHIP_BILLBOARD_HIDE_DISTANCE = 0.003; const SHIP_BILLBOARD_FULL_DISTANCE = 0.018; const SHIP_BILLBOARD_MIN_PIXELS = 34; const SHIP_BILLBOARD_MAX_PIXELS = 82; const STATION_ICON_MIN_PIXELS = 28; const STATION_ICON_MAX_PIXELS = 72; export interface WorldOrbitalContext { world?: WorldState; worldTimeSyncMs: number; worldSeed: number; nodeVisuals: Map; celestialVisuals: Map; stationVisuals: Map; } export interface WorldPresentationContext extends WorldOrbitalContext { activeSystemId?: string; cameraMode: CameraMode; povLevel: PovLevel; orbitYaw: number; camera: THREE.PerspectiveCamera; systemAnchor: THREE.Vector3; shipVisuals: Map; claimVisuals: Map; constructionSiteVisuals: Map; systemVisuals: Map; systemSummaryVisuals: Map; toDisplayLocalPosition: (localPosition: THREE.Vector3) => THREE.Vector3; updateSystemDetailVisibility: () => void; setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], opacity: number) => void; } export interface GameStatusParams { world?: WorldState; activeSystemId?: string; cameraMode: CameraMode; 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.povLevel); for (const [shipId, visual] of context.shipVisuals.entries()) { const ship = context.world?.ships.get(shipId); if (!ship) { continue; } const worldPosition = getAnimatedShipLocalPosition(visual, now); const displayPosition = context.toDisplayLocalPosition(worldPosition); visual.mesh.setPosition(displayPosition); visual.icon.setPosition(rawObject(visual.mesh).position.clone()); const shipVisible = isShipVisible(renderMode, context.activeSystemId, ship); const distToShip = context.camera.position.distanceTo(displayPosition); const billboardOpacity = context.cameraMode === "tactical" ? 1 : THREE.MathUtils.clamp( (distToShip - SHIP_BILLBOARD_HIDE_DISTANCE) / (SHIP_BILLBOARD_FULL_DISTANCE - SHIP_BILLBOARD_HIDE_DISTANCE), 0, 1, ); const useTacticalIcon = context.cameraMode === "tactical" || billboardOpacity > 0.01; const iconScale = THREE.MathUtils.clamp( visual.iconBaseScale, iconWorldScale(distToShip, context.camera, SHIP_BILLBOARD_MIN_PIXELS), iconWorldScale(distToShip, context.camera, SHIP_BILLBOARD_MAX_PIXELS), ); visual.icon.setScaleScalar(iconScale); visual.icon.setOpacity(shipVisible ? billboardOpacity : 0); visual.mesh.setVisible(shipVisible && context.cameraMode !== "tactical" && billboardOpacity < 0.98); visual.icon.setVisible(shipVisible && useTacticalIcon); const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw); if (desiredHeading.lengthSq() > 0.01) { visual.mesh.lookAt(rawObject(visual.mesh).position.clone().add(desiredHeading)); } } for (const visual of context.nodeVisuals.values()) { const animatedLocalPosition = computeNodeLocalPosition(context, visual, worldTimeSeconds); 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.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); const iconWorldPos = visual.icon.getWorldPosition(new THREE.Vector3()); const distToIcon = context.camera.position.distanceTo(iconWorldPos); const isNearPlanetLagrange = /-l[12]$/.test(visual.id); const inCluster = !isNearPlanetLagrange || distToIcon < 400; visual.icon.setVisible(visual.systemId === context.activeSystemId && inCluster); const t = THREE.MathUtils.clamp(distToIcon / 300, 0, 1); const rawCelestialScale = visual.iconBaseScale * t * Math.sqrt(t); const celestialIconScale = THREE.MathUtils.clamp(rawCelestialScale, iconWorldScale(distToIcon, context.camera, 15), iconWorldScale(distToIcon, context.camera, 100)); visual.icon.setScaleScalar(celestialIconScale); } for (const visual of context.stationVisuals.values()) { const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds); const displayPosition = context.toDisplayLocalPosition(animatedLocalPosition); visual.mesh.setPosition(displayPosition); visual.icon.setPosition(rawObject(visual.mesh).position.clone()); const stationVisible = visual.systemId === context.activeSystemId; const distToStation = context.camera.position.distanceTo(displayPosition); const stationIconScale = THREE.MathUtils.clamp( 130, iconWorldScale(distToStation, context.camera, STATION_ICON_MIN_PIXELS), iconWorldScale(distToStation, context.camera, STATION_ICON_MAX_PIXELS), ); visual.icon.setScaleScalar(stationIconScale); visual.icon.setVisible(stationVisible); visual.mesh.setVisible(stationVisible && renderMode === "local" && context.cameraMode !== "tactical"); } for (const visual of context.claimVisuals.values()) { 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 = 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); } } type RenderSpaceMode = "galaxy" | "system" | "local"; function resolveRenderSpaceMode(activeSystemId: string | undefined, povLevel: PovLevel): RenderSpaceMode { if (!activeSystemId || povLevel === "galaxy") { return "galaxy"; } return povLevel === "local" ? "local" : "system"; } function isShipVisible(mode: RenderSpaceMode, activeSystemId: string | undefined, ship: ShipSnapshot) { if (ship.spatialState.movementRegime === "ftl-transit") { return mode === "galaxy"; } if (!activeSystemId) { return false; } return ship.systemId === activeSystemId; } export function resolveShipWorldPosition( context: Pick, ship: ShipSnapshot, visual: ShipVisual, animatedLocalPosition = getAnimatedShipLocalPosition(visual), ) { // 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) { if (!world) { return; } const shipCounts = new Map(); const stationCounts = new Map(); const structureCounts = new Map(); for (const ship of world.ships.values()) { shipCounts.set(ship.systemId, (shipCounts.get(ship.systemId) ?? 0) + 1); } for (const station of world.stations.values()) { stationCounts.set(station.systemId, (stationCounts.get(station.systemId) ?? 0) + 1); structureCounts.set(station.systemId, (structureCounts.get(station.systemId) ?? 0) + 1); } for (const node of world.nodes.values()) { structureCounts.set(node.systemId, (structureCounts.get(node.systemId) ?? 0) + 1); } for (const [systemId, system] of world.systems.entries()) { const visual = systemSummaryVisuals.get(systemId); if (!visual) { continue; } const canvas = visual.texture.image as HTMLCanvasElement; const context = canvas.getContext("2d"); if (!context) { continue; } context.clearRect(0, 0, canvas.width, canvas.height); context.fillStyle = "#eaf4ff"; context.font = "600 34px Space Grotesk, sans-serif"; context.textAlign = "center"; context.fillText(system.label, canvas.width / 2, 40); const ships = shipCounts.get(systemId) ?? 0; const stations = stationCounts.get(systemId) ?? 0; const structures = structureCounts.get(systemId) ?? 0; const gasClouds = [...world.nodes.values()] .filter((node) => node.systemId === systemId && node.sourceKind === "gas-cloud") .length; const total = ships + stations + structures; if (total > 0) { context.fillStyle = "rgba(3, 8, 18, 0.72)"; context.fillRect(56, 64, canvas.width - 112, 68); context.strokeStyle = "rgba(132, 196, 255, 0.22)"; context.strokeRect(56, 64, canvas.width - 112, 68); drawCountIcon(context, "ship", 126, 98, ships, "#8bc0ff"); drawCountIcon(context, "station", 256, 98, stations, "#ffbf69"); drawCountIcon(context, "structure", 386, 98, structures, gasClouds > 0 ? "#7fd6ff" : "#98adc4"); } visual.texture.needsUpdate = true; } } export function renderRecentEvents(world: WorldState | undefined, entityKind: string, entityId: string) { if (!world) { return ""; } return world.recentEvents .filter((event) => event.entityKind === entityKind && (!entityId || event.entityId === entityId)) .slice(0, 8) .map((event) => `${new Date(event.occurredAtUtc).toLocaleTimeString()} ${event.message}`) .join("
"); } function fmtVec(v: THREE.Vector3, digits: number) { return `${v.x.toFixed(digits)} ${v.y.toFixed(digits)} ${v.z.toFixed(digits)}`; } export function describeGameStatus(params: GameStatusParams) { const { 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 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` : ""; return { bodyText: [ `mode: ${mode}`, `camera: ${cameraModeLabel}`, `zoom: ${displayPovLevel}`, `space: ${activeSpace}`, galPos, sysPos, locPos, `sequence: ${sequence}`, `snapshot: ${generatedAt}`, ].filter(Boolean).join("\n"), summaryText: `${mode} | ${displayPovLevel} | ${activeSpace}`, }; } export function updateGameStatus(params: GameStatusParams & { statusEl: HTMLDivElement; summaryEl?: HTMLSpanElement }) { const state = describeGameStatus(params); params.statusEl.textContent = state.bodyText; if (params.summaryEl) { params.summaryEl.textContent = state.summaryText; } } export function deriveNodeOrbital( context: WorldOrbitalContext, node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: OrbitalAnchor, ) { return deriveOrbitalFromLocalPosition(context, toThreeVector(node.localPosition), node.systemId, anchor); } export function deriveOrbitalFromLocalPosition( context: WorldOrbitalContext, localPosition: THREE.Vector3, systemId: string, anchor: OrbitalAnchor, ) { const anchorPosition = getOrbitalAnchorPosition(context, systemId, anchor, currentWorldTimeSeconds(context.world, context.worldTimeSyncMs)); const relativePosition = localPosition.clone().sub(anchorPosition); const radius = Math.max(Math.sqrt((relativePosition.x * relativePosition.x) + (relativePosition.z * relativePosition.z)), 24); const phase = Math.atan2(relativePosition.z, relativePosition.x); const inclination = Math.atan2(relativePosition.y, radius); return { radius, phase, inclination }; } export function computeNodeLocalPosition(context: WorldOrbitalContext, node: NodeVisual, timeSeconds: number) { const speed = computeNodeOrbitSpeed(node); const angle = node.orbitPhase + (timeSeconds * speed); const orbit = new THREE.Vector3( Math.cos(angle) * node.orbitRadius, 0, Math.sin(angle) * node.orbitRadius, ); orbit.applyAxisAngle(new THREE.Vector3(1, 0, 0), node.orbitInclination); return orbit.add(getOrbitalAnchorPosition(context, node.systemId, node.anchor, timeSeconds)); } export function resolveOrbitalAnchor(context: WorldOrbitalContext, systemId: string, localPosition: THREE.Vector3): OrbitalAnchor { if (!context.world) { return { kind: "star" }; } const system = context.world.systems.get(systemId); if (!system) { return { kind: "star" }; } const nowSeconds = currentWorldTimeSeconds(context.world, context.worldTimeSyncMs); let bestAnchor: OrbitalAnchor = { kind: "star" }; let bestDistance = Number.POSITIVE_INFINITY; for (const [planetIndex, planet] of system.planets.entries()) { const planetPosition = computePlanetLocalPosition(planet, nowSeconds); const planetDistance = localPosition.distanceTo(planetPosition); const planetThreshold = Math.max(planet.size * 10, 180); if (planetDistance < planetThreshold && planetDistance < bestDistance) { bestDistance = planetDistance; bestAnchor = { kind: "planet", planetIndex }; } for (const [moonIndex, moon] of planet.moons.entries()) { const moonPosition = planetPosition .clone() .add(computeMoonLocalPosition(moon, nowSeconds)); const moonDistance = localPosition.distanceTo(moonPosition); const moonThreshold = Math.max(moon.size * 14, 80); if (moonDistance < moonThreshold && moonDistance < bestDistance) { bestDistance = moonDistance; bestAnchor = { kind: "moon", planetIndex, moonIndex }; } } } return bestAnchor; } 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 computeCelestialLocalPosition(context: WorldOrbitalContext, visual: CelestialVisual, timeSeconds: number) { return computeCelestialLocalPositionById(context, visual.id, timeSeconds) ?? visual.orbitalAnchor.clone(); } export function computeCelestialLocalPositionById( context: WorldOrbitalContext, celestialId: string, timeSeconds: number, visiting = new Set(), ): THREE.Vector3 | undefined { if (!context.world || visiting.has(celestialId)) { return undefined; } const celestial = context.world.celestials.get(celestialId); if (!celestial) { return undefined; } const basePosition = toThreeVector(celestial.orbitalAnchor); if (!celestial.parentNodeId) { return basePosition; } const parentCelestial = context.world.celestials.get(celestial.parentNodeId); if (!parentCelestial) { return basePosition; } visiting.add(celestialId); const parentCurrentPosition = computeCelestialLocalPositionById(context, celestial.parentNodeId, timeSeconds, visiting); visiting.delete(celestialId); if (!parentCurrentPosition) { return basePosition; } const parentInitialPosition = toThreeVector(parentCelestial.orbitalAnchor); const relativeOffset = basePosition.clone().sub(parentInitialPosition); const initialDir = parentInitialPosition.clone().normalize(); const currentDir = parentCurrentPosition.clone().normalize(); let rotatedOffset: THREE.Vector3; if (initialDir.lengthSq() > 0.0001 && currentDir.lengthSq() > 0.0001) { const quaternion = new THREE.Quaternion().setFromUnitVectors(initialDir, currentDir); rotatedOffset = relativeOffset.clone().applyQuaternion(quaternion); } else { rotatedOffset = relativeOffset.clone(); } return parentCurrentPosition.clone().add(rotatedOffset); } function drawCountIcon( context: CanvasRenderingContext2D, kind: SummaryIconKind, x: number, y: number, value: number, color: string, ) { context.save(); context.strokeStyle = color; context.fillStyle = color; context.lineWidth = 3; if (kind === "ship") { context.beginPath(); context.moveTo(x - 14, y + 10); context.lineTo(x, y - 14); context.lineTo(x + 14, y + 10); context.closePath(); context.stroke(); } else if (kind === "station") { context.strokeRect(x - 14, y - 14, 28, 28); } else { context.beginPath(); context.arc(x, y, 14, 0, Math.PI * 2); context.stroke(); context.beginPath(); context.moveTo(x - 8, y); context.lineTo(x + 8, y); context.moveTo(x, y - 8); context.lineTo(x, y + 8); context.stroke(); } context.fillStyle = "#eaf4ff"; context.font = "600 26px IBM Plex Mono, monospace"; context.textAlign = "left"; context.fillText(String(value), x + 24, y + 9); context.restore(); } function computeNodeOrbitSpeed(node: NodeVisual) { const base = node.sourceKind === "gas-cloud" ? 0.16 : 0.24; return base / Math.sqrt(Math.max(node.orbitRadius / 140, 0.4)); } function computeStructureLocalPosition( context: WorldOrbitalContext, structure: StructureVisual, timeSeconds: number, baseSpeed: number, ) { const angle = structure.orbitPhase + (timeSeconds * (baseSpeed / Math.sqrt(Math.max(structure.orbitRadius / 180, 0.45)))); const orbit = new THREE.Vector3( Math.cos(angle) * structure.orbitRadius, 0, Math.sin(angle) * structure.orbitRadius, ); orbit.applyAxisAngle(new THREE.Vector3(1, 0, 0), structure.orbitInclination); return orbit.add(getOrbitalAnchorPosition(context, structure.systemId, structure.anchor, timeSeconds)); } function getOrbitalAnchorPosition(context: WorldOrbitalContext, systemId: string, anchor: OrbitalAnchor, timeSeconds: number) { return resolveOrbitalAnchorPosition(context.world, systemId, anchor, timeSeconds); } 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?.celestialId) { return computeStructureLocalPosition(context, visual, timeSeconds, 0.14); } return computeCelestialLocalPositionById(context, station.celestialId, timeSeconds) ?? visual.localPosition.clone(); }