Files
space-game/apps/viewer/src/viewerWorldPresentation.ts

557 lines
21 KiB
TypeScript

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<string, NodeVisual>;
celestialVisuals: Map<string, CelestialVisual>;
stationVisuals: Map<string, StructureVisual>;
}
export interface WorldPresentationContext extends WorldOrbitalContext {
activeSystemId?: string;
cameraMode: CameraMode;
povLevel: PovLevel;
orbitYaw: number;
camera: THREE.PerspectiveCamera;
systemAnchor: THREE.Vector3;
shipVisuals: Map<string, ShipVisual>;
claimVisuals: Map<string, ClaimVisual>;
constructionSiteVisuals: Map<string, ConstructionSiteVisual>;
systemVisuals: Map<string, SystemVisual>;
systemSummaryVisuals: Map<string, any>;
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<WorldPresentationContext, "world" | "toDisplayLocalPosition">,
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<string, any>) {
if (!world) {
return;
}
const shipCounts = new Map<string, number>();
const stationCounts = new Map<string, number>();
const structureCounts = new Map<string, number>();
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("<br>");
}
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<string>(),
): 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();
}