557 lines
21 KiB
TypeScript
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();
|
|
}
|