Refactor simulation and viewer architecture
This commit is contained in:
465
apps/viewer/src/viewerWorldPresentation.ts
Normal file
465
apps/viewer/src/viewerWorldPresentation.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user