Refactor simulation and viewer architecture

This commit is contained in:
2026-03-14 15:08:49 -04:00
parent ddca4a16d5
commit 651556c916
71 changed files with 11472 additions and 9031 deletions

View File

@@ -0,0 +1,465 @@
import * as THREE from "three";
import {
computeMoonLocalPosition,
computeMoonSize,
computePlanetLocalPosition,
currentWorldTimeSeconds,
resolveOrbitalAnchorPosition,
toThreeVector,
} from "./viewerMath";
import {
resolveShipHeading,
updateSystemStarPresentation,
updateSystemSummaryPresentation,
getAnimatedShipLocalPosition,
} from "./viewerPresentation";
import type {
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
} from "./contracts";
import type {
BubbleVisual,
ClaimVisual,
ConstructionSiteVisual,
NodeVisual,
OrbitalAnchor,
ShipVisual,
SpatialNodeVisual,
StructureVisual,
SystemSummaryVisual,
SystemVisual,
WorldState,
ZoomLevel,
CameraMode,
} from "./viewerTypes";
type SummaryIconKind = "ship" | "station" | "structure";
export interface WorldOrbitalContext {
world?: WorldState;
worldTimeSyncMs: number;
worldSeed: number;
nodeVisuals: Map<string, NodeVisual>;
spatialNodeVisuals: Map<string, SpatialNodeVisual>;
bubbleVisuals: Map<string, BubbleVisual>;
stationVisuals: Map<string, StructureVisual>;
}
export interface WorldPresentationContext extends WorldOrbitalContext {
activeSystemId?: string;
orbitYaw: number;
camera: THREE.PerspectiveCamera;
systemFocusLocal: 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;
updateSystemDetailVisibility: () => void;
setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void;
}
export interface GameStatusParams {
statusEl: HTMLDivElement;
world?: WorldState;
activeSystemId?: string;
cameraMode: CameraMode;
zoomLevel: ZoomLevel;
mode: string;
}
export function updateWorldPresentation(context: WorldPresentationContext) {
const now = performance.now();
const worldTimeSeconds = currentWorldTimeSeconds(context.world, context.worldTimeSyncMs);
for (const visual of context.shipVisuals.values()) {
const worldPosition = getAnimatedShipLocalPosition(visual, now);
visual.mesh.position.copy(context.toDisplayLocalPosition(worldPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
const shipVisible = visual.systemId === context.activeSystemId;
visual.mesh.visible = shipVisible;
visual.icon.visible = shipVisible && visual.icon.visible;
const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw);
if (desiredHeading.lengthSq() > 0.01) {
visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading));
}
}
for (const visual of context.nodeVisuals.values()) {
const animatedLocalPosition = computeNodeLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === context.activeSystemId;
}
for (const visual of context.spatialNodeVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === context.activeSystemId;
visual.icon.visible = visual.systemId === context.activeSystemId;
}
for (const visual of context.bubbleVisuals.values()) {
const animatedLocalPosition = resolveBubbleAnimatedLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.mesh.visible = visual.systemId === context.activeSystemId;
}
for (const visual of context.stationVisuals.values()) {
const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === context.activeSystemId;
}
for (const visual of context.claimVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone();
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === context.activeSystemId;
visual.icon.visible = visual.systemId === context.activeSystemId;
}
for (const visual of context.constructionSiteVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone();
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === context.activeSystemId;
visual.icon.visible = visual.systemId === context.activeSystemId;
}
updateSystemStarPresentation(
context.systemVisuals,
context.activeSystemId,
context.systemFocusLocal,
context.camera,
context.setShellReticleOpacity,
);
context.updateSystemDetailVisibility();
updateSystemSummaryPresentation(context.systemSummaryVisuals, context.camera, context.activeSystemId);
}
export function updateSystemSummaries(world: WorldState | undefined, systemSummaryVisuals: Map<string, SystemSummaryVisual>) {
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>");
}
export function updateGameStatus(params: GameStatusParams) {
const { statusEl, world, activeSystemId, cameraMode, zoomLevel, mode } = params;
const sequence = world?.sequence ?? 0;
const generatedAt = world?.generatedAtUtc
? new Date(world.generatedAtUtc).toLocaleTimeString()
: "n/a";
const activeSystem = activeSystemId ?? "deep-space";
const cameraModeLabel = cameraMode === "follow" ? "camera-follow" : "tactical";
statusEl.textContent = [
`mode: ${mode}`,
`camera: ${cameraModeLabel}`,
`zoom: ${zoomLevel}`,
`system: ${activeSystem}`,
`sequence: ${sequence}`,
`snapshot: ${generatedAt}`,
].join("\n");
}
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 };
}
const moonCount = Math.min(planet.moonCount, 12);
for (let moonIndex = 0; moonIndex < moonCount; moonIndex += 1) {
const moonPosition = planetPosition
.clone()
.add(computeMoonLocalPosition(planet, moonIndex, nowSeconds, context.world.seed));
const moonDistance = localPosition.distanceTo(moonPosition);
const moonThreshold = Math.max(computeMoonSize(planet, moonIndex, context.world.seed) * 14, 80);
if (moonDistance < moonThreshold && moonDistance < bestDistance) {
bestDistance = moonDistance;
bestAnchor = { kind: "moon", planetIndex, moonIndex };
}
}
}
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);
}
}
return new THREE.Vector3(0, 0, 0);
}
export function resolveBubblePosition(context: WorldOrbitalContext, bubble: LocalBubbleSnapshot | LocalBubbleDelta) {
return resolvePointPosition(context, bubble.systemId, bubble.nodeId);
}
export function computeSpatialNodeLocalPosition(context: WorldOrbitalContext, visual: SpatialNodeVisual, timeSeconds: number) {
return computeSpatialNodeLocalPositionById(context, visual.id, timeSeconds) ?? visual.localPosition.clone();
}
export function computeSpatialNodeLocalPositionById(
context: WorldOrbitalContext,
nodeId: string,
timeSeconds: number,
visiting = new Set<string>(),
): THREE.Vector3 | undefined {
if (!context.world || visiting.has(nodeId)) {
return undefined;
}
const node = context.world.spatialNodes.get(nodeId);
if (!node) {
return undefined;
}
const basePosition = toThreeVector(node.localPosition);
if (!node.parentNodeId) {
return basePosition;
}
const parentNode = context.world.spatialNodes.get(node.parentNodeId);
if (!parentNode) {
return basePosition;
}
visiting.add(nodeId);
const parentCurrentPosition = computeSpatialNodeLocalPositionById(context, node.parentNodeId, timeSeconds, visiting);
visiting.delete(nodeId);
if (!parentCurrentPosition) {
return basePosition;
}
const parentInitialPosition = toThreeVector(parentNode.localPosition);
const relativeOffset = basePosition.clone().sub(parentInitialPosition);
const initialAngle = Math.atan2(parentInitialPosition.z, parentInitialPosition.x);
const currentAngle = Math.atan2(parentCurrentPosition.z, parentCurrentPosition.x);
const rotatedOffset = relativeOffset.applyAxisAngle(new THREE.Vector3(0, 1, 0), currentAngle - initialAngle);
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 = visual.mesh.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,
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, 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) {
return computeStructureLocalPosition(context, visual, timeSeconds, 0.14);
}
return computeSpatialNodeLocalPositionById(context, station.nodeId, timeSeconds) ?? visual.localPosition.clone();
}