feat: 3 scene rendering setup

This commit is contained in:
2026-03-18 08:49:51 -04:00
parent 933c6afd08
commit 358122a74a
33 changed files with 1094 additions and 1132 deletions

View File

@@ -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<string, NodeVisual>;
spatialNodeVisuals: Map<string, SpatialNodeVisual>;
bubbleVisuals: Map<string, BubbleVisual>;
celestialVisuals: Map<string, CelestialVisual>;
stationVisuals: Map<string, StructureVisual>;
}
export interface WorldPresentationContext extends WorldOrbitalContext {
activeSystemId?: string;
zoomLevel: ZoomLevel;
povLevel: PovLevel;
orbitYaw: number;
camera: THREE.PerspectiveCamera;
systemFocusLocal: THREE.Vector3;
systemAnchor: THREE.Vector3;
shipVisuals: Map<string, ShipVisual>;
claimVisuals: Map<string, ClaimVisual>;
constructionSiteVisuals: Map<string, ConstructionSiteVisual>;
systemVisuals: Map<string, SystemVisual>;
systemSummaryVisuals: Map<string, SystemSummaryVisual>;
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
systemSummaryVisuals: Map<string, any>;
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<string, SystemSummaryVisual>) {
export function updateSystemSummaries(world: WorldState | undefined, systemSummaryVisuals: Map<string, any>) {
if (!world) {
return;
}
@@ -275,26 +247,50 @@ export function renderRecentEvents(world: WorldState | undefined, entityKind: st
.join("<br>");
}
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<string>(),
): 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();
}