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

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}

View File

@@ -2,49 +2,15 @@ import * as THREE from "three";
import { import {
MAX_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE,
MIN_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE,
ZOOM_DISTANCE, NAV_DISTANCE,
} from "./viewerConstants"; } from "./viewerConstants";
import { createViewerHud } from "./viewerHud"; import { createViewerHud } from "./viewerHud";
import {
classifyZoomLevel,
computeZoomBlend,
formatBytes,
inventoryAmount,
smoothBand,
} from "./viewerMath";
import { updatePanFromKeyboard } from "./viewerCamera"; import { updatePanFromKeyboard } from "./viewerCamera";
import { import { setShellReticleOpacity } from "./viewerControls";
createCirclePoints,
shipLength,
shipPresentationColor,
shipSize,
spatialNodeColor,
} from "./viewerSceneAppearance";
import {
createBackdropStars,
createNebulaClouds,
createNebulaTexture,
} from "./viewerSceneFactory";
import {
setShellReticleOpacity,
} from "./viewerControls";
import {
recordPerformanceStats,
updateNetworkPanel as renderNetworkPanel,
updatePerformancePanel as renderPerformancePanel,
} from "./viewerTelemetry";
import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop"; import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop";
import { updatePlanetPresentation } from "./viewerPresentation"; import { updateSystemStarPresentation } from "./viewerPresentation";
import { import { resolveFocusedCelestialId } from "./viewerSelection";
renderRecentEvents, import { describeSelectionParent } from "./viewerPanels";
updateGameStatus,
updateSystemSummaries,
updateWorldPresentation,
} from "./viewerWorldPresentation";
import {
resolveFocusedBubbleId,
} from "./viewerSelection";
import { describeSelectionParent, updateSystemPanel } from "./viewerPanels";
import { import {
createInitialNetworkStats, createInitialNetworkStats,
createInitialPerformanceStats, createInitialPerformanceStats,
@@ -55,68 +21,65 @@ import { ViewerNavigationController } from "./viewerNavigationController";
import { ViewerSceneDataController } from "./viewerSceneDataController"; import { ViewerSceneDataController } from "./viewerSceneDataController";
import { ViewerPresentationController } from "./viewerPresentationController"; import { ViewerPresentationController } from "./viewerPresentationController";
import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory"; import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory";
import type { SceneNode } from "./viewerScenePrimitives"; import { toDisplayLocalPosition, getSystemCameraFocus } from "./viewerCamera";
import type { FactionSnapshot, ShipSnapshot } from "./contracts"; import { UniverseLayer } from "./viewerUniverseLayer";
import { GalaxyLayer } from "./viewerGalaxyLayer";
import { SystemLayer } from "./viewerSystemLayer";
import { LocalLayer } from "./viewerLocalLayer";
import type { FactionSnapshot } from "./contracts";
import type { import type {
BubbleVisual, CelestialVisual,
CameraMode, CameraMode,
ClaimVisual, ClaimVisual,
ConstructionSiteVisual, ConstructionSiteVisual,
DragMode, DragMode,
HistoryWindowState, HistoryWindowState,
MoonVisual,
NetworkStats, NetworkStats,
NodeVisual, NodeVisual,
OrbitLineVisual, OrbitLineVisual,
OrbitalAnchor,
PerformanceStats, PerformanceStats,
PlanetVisual,
PresentationEntry,
Selectable, Selectable,
ShipVisual, ShipVisual,
SpatialNodeVisual,
StructureVisual, StructureVisual,
SystemSummaryVisual,
SystemVisual, SystemVisual,
WorldState, WorldState,
ZoomLevel, PovLevel,
} from "./viewerTypes"; } from "./viewerTypes";
export class ViewerAppController { export class ViewerAppController {
private readonly container: HTMLElement; private readonly container: HTMLElement;
private readonly renderer = new THREE.WebGLRenderer({ antialias: true }); private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
private readonly scene = new THREE.Scene();
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 160000); // ── Three independent rendering layers ───────────────────────────────────
private readonly universeLayer = new UniverseLayer();
private readonly galaxyLayer = new GalaxyLayer();
private readonly systemLayer = new SystemLayer();
private readonly localLayer = new LocalLayer();
private readonly clock = new THREE.Clock(); private readonly clock = new THREE.Clock();
private readonly raycaster = new THREE.Raycaster(); private readonly raycaster = new THREE.Raycaster();
private readonly mouse = new THREE.Vector2(); private readonly mouse = new THREE.Vector2();
private readonly galaxyFocus = new THREE.Vector3(2200, 0, 300);
private readonly systemFocusLocal = new THREE.Vector3(); // ── Galaxy-space anchor ───────────────────────────────────────────────────
private readonly galaxyAnchor = new THREE.Vector3(2200, 0, 300);
// ── System-space anchor ───────────────────────────────────────────────────
private readonly systemAnchor = new THREE.Vector3();
private readonly cameraOffset = new THREE.Vector3(); private readonly cameraOffset = new THREE.Vector3();
private readonly keyState = new Set<string>(); private readonly keyState = new Set<string>();
private readonly systemGroup = new THREE.Group();
private readonly spatialNodeGroup = new THREE.Group();
private readonly bubbleGroup = new THREE.Group();
private readonly nodeGroup = new THREE.Group();
private readonly stationGroup = new THREE.Group();
private readonly claimGroup = new THREE.Group();
private readonly constructionSiteGroup = new THREE.Group();
private readonly shipGroup = new THREE.Group();
private readonly ambienceGroup = new THREE.Group();
private readonly gamePanelEl: HTMLDivElement; private readonly gamePanelEl: HTMLDivElement;
private readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
private readonly presentationEntries: PresentationEntry[] = []; private readonly celestialVisuals = new Map<string, CelestialVisual>();
private readonly nodeVisuals = new Map<string, NodeVisual>();
private readonly spatialNodeVisuals = new Map<string, SpatialNodeVisual>();
private readonly bubbleVisuals = new Map<string, BubbleVisual>();
private readonly stationVisuals = new Map<string, StructureVisual>(); private readonly stationVisuals = new Map<string, StructureVisual>();
private readonly claimVisuals = new Map<string, ClaimVisual>(); private readonly claimVisuals = new Map<string, ClaimVisual>();
private readonly constructionSiteVisuals = new Map<string, ConstructionSiteVisual>(); private readonly constructionSiteVisuals = new Map<string, ConstructionSiteVisual>();
private readonly shipVisuals = new Map<string, ShipVisual>(); private readonly shipVisuals = new Map<string, ShipVisual>();
private readonly systemVisuals = new Map<string, SystemVisual>(); private readonly systemVisuals = new Map<string, SystemVisual>();
private readonly systemSummaryVisuals = new Map<string, SystemSummaryVisual>(); private readonly nodeVisuals = new Map<string, NodeVisual>();
private readonly planetVisuals: PlanetVisual[] = []; private readonly planetVisuals: any[] = [];
private readonly orbitLines: OrbitLineVisual[] = []; private readonly orbitLines: OrbitLineVisual[] = [];
private readonly statusEl: HTMLDivElement; private readonly statusEl: HTMLDivElement;
private readonly gameSummaryEl: HTMLSpanElement; private readonly gameSummaryEl: HTMLSpanElement;
private readonly systemPanelEl: HTMLDivElement; private readonly systemPanelEl: HTMLDivElement;
@@ -145,9 +108,9 @@ export class ViewerAppController {
private selectedItems: Selectable[] = []; private selectedItems: Selectable[] = [];
private worldSignature = ""; private worldSignature = "";
private zoomLevel: ZoomLevel = "system"; private povLevel: PovLevel = "system";
private currentDistance = ZOOM_DISTANCE.system; private currentDistance = NAV_DISTANCE.system;
private desiredDistance = ZOOM_DISTANCE.system; private desiredDistance = NAV_DISTANCE.system;
private orbitYaw = -2.3; private orbitYaw = -2.3;
private orbitPitch = 0.62; private orbitPitch = 0.62;
private cameraMode: CameraMode = "tactical"; private cameraMode: CameraMode = "tactical";
@@ -181,23 +144,7 @@ export class ViewerAppController {
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.outputColorSpace = THREE.SRGBColorSpace; this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.scene.background = new THREE.Color(0x040912);
this.scene.fog = new THREE.FogExp2(0x040912, 0.00011);
this.scene.add(new THREE.AmbientLight(0x90a6c0, 0.55));
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3);
keyLight.position.set(1000, 1200, 800);
this.scene.add(keyLight);
this.scene.add(
this.ambienceGroup,
this.systemGroup,
this.spatialNodeGroup,
this.bubbleGroup,
this.nodeGroup,
this.stationGroup,
this.claimGroup,
this.constructionSiteGroup,
this.shipGroup,
);
const hud = createViewerHud(document); const hud = createViewerHud(document);
this.gamePanelEl = hud.gamePanelEl; this.gamePanelEl = hud.gamePanelEl;
this.statusEl = hud.statusEl; this.statusEl = hud.statusEl;
@@ -263,12 +210,11 @@ export class ViewerAppController {
return this.sceneDataController.createWorldPresentationContext({ return this.sceneDataController.createWorldPresentationContext({
world: this.world, world: this.world,
activeSystemId: this.activeSystemId, activeSystemId: this.activeSystemId,
zoomLevel: this.zoomLevel, povLevel: this.povLevel,
orbitYaw: this.orbitYaw, orbitYaw: this.orbitYaw,
camera: this.camera, systemCamera: this.systemLayer.camera,
systemFocusLocal: this.systemFocusLocal, systemAnchor: this.systemAnchor,
toDisplayLocalPosition: this.toDisplayLocalPosition.bind(this), toDisplayLocalPosition: (localPosition) => toDisplayLocalPosition(localPosition),
updateSystemDetailVisibility: () => this.navigationController.updateSystemDetailVisibility(),
setShellReticleOpacity: (sprite, opacity) => this.setShellReticleOpacity(sprite, opacity), setShellReticleOpacity: (sprite, opacity) => this.setShellReticleOpacity(sprite, opacity),
}); });
} }
@@ -285,8 +231,14 @@ export class ViewerAppController {
renderFrame({ renderFrame({
clock: this.clock, clock: this.clock,
renderer: this.renderer, renderer: this.renderer,
scene: this.scene, universeScene: this.universeLayer.scene,
camera: this.camera, galaxyScene: this.galaxyLayer.scene,
galaxyCamera: this.galaxyLayer.camera,
systemScene: this.systemLayer.scene,
systemCamera: this.systemLayer.camera,
localScene: this.localLayer.scene,
localCamera: this.localLayer.camera,
getPovLevel: () => this.povLevel,
updateCamera: (delta) => this.updateCamera(delta), updateCamera: (delta) => this.updateCamera(delta),
updateAmbience: (delta) => this.presentationController.updateAmbience(delta), updateAmbience: (delta) => this.presentationController.updateAmbience(delta),
updatePlanetPresentation: () => this.presentationController.updatePlanetPresentation(), updatePlanetPresentation: () => this.presentationController.updatePlanetPresentation(),
@@ -298,10 +250,13 @@ export class ViewerAppController {
}); });
} }
private updateAmbience(delta: number) { private computeOrbitOffset(): THREE.Vector3 {
this.ambienceGroup.position.copy(this.camera.position); const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch);
this.ambienceGroup.rotation.y += delta * 0.005; return new THREE.Vector3(
this.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015; Math.cos(this.orbitYaw) * horizontalDistance,
this.currentDistance * Math.sin(this.orbitPitch),
Math.sin(this.orbitYaw) * horizontalDistance,
);
} }
private updateCamera(delta: number) { private updateCamera(delta: number) {
@@ -312,25 +267,38 @@ export class ViewerAppController {
delta, delta,
}); });
this.currentDistance = nextState.currentDistance; this.currentDistance = nextState.currentDistance;
this.zoomLevel = nextState.zoomLevel; this.povLevel = nextState.povLevel;
this.orbitPitch = nextState.orbitPitch; this.orbitPitch = nextState.orbitPitch;
this.navigationController.updateActiveSystem(); this.navigationController.updateActiveSystem();
if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) { if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) {
// Follow camera directly controls systemLayer.camera in updateFollowCamera.
// Still update galaxy camera independently.
const orbitOffset = this.computeOrbitOffset();
this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset);
return; return;
} }
this.updatePanFromKeyboard(delta); this.updatePanFromKeyboard(delta);
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3); this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3);
const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch); const orbitOffset = this.computeOrbitOffset();
const focus = this.navigationController.getCameraFocusWorldPosition();
this.cameraOffset.set( this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset);
Math.cos(this.orbitYaw) * horizontalDistance,
this.currentDistance * Math.sin(this.orbitPitch), if (this.activeSystemId) {
Math.sin(this.orbitYaw) * horizontalDistance, this.systemLayer.updateCamera(getSystemCameraFocus(this.systemAnchor), orbitOffset);
}
this.localLayer.updateCamera(orbitOffset);
// Update star dot scales in galaxy scene
updateSystemStarPresentation(
this.systemVisuals,
this.activeSystemId,
this.galaxyLayer.camera,
(sprite, opacity) => this.setShellReticleOpacity(sprite, opacity),
); );
this.camera.position.copy(focus).add(this.cameraOffset);
this.camera.lookAt(focus);
} }
private updatePanFromKeyboard(delta: number) { private updatePanFromKeyboard(delta: number) {
@@ -338,10 +306,10 @@ export class ViewerAppController {
this.keyState, this.keyState,
this.orbitYaw, this.orbitYaw,
this.currentDistance, this.currentDistance,
this.zoomLevel, this.povLevel,
this.activeSystemId, this.activeSystemId,
this.systemFocusLocal, this.systemAnchor,
this.galaxyFocus, this.galaxyAnchor,
delta, delta,
MIN_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE,
MAX_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE,
@@ -352,16 +320,6 @@ export class ViewerAppController {
this.presentationController.updateSystemSummaries(); this.presentationController.updateSystemSummaries();
} }
private registerPresentation(
detail: SceneNode,
icon: SceneNode,
hideDetailInUniverse: boolean,
hideIconInUniverse = false,
systemId?: string,
) {
this.presentationEntries.push({ detail, icon, systemId, hideDetailInUniverse, hideIconInUniverse });
}
private renderRecentEvents(entityKind: string, entityId: string) { private renderRecentEvents(entityKind: string, entityId: string) {
return this.presentationController.renderRecentEvents(entityKind, entityId); return this.presentationController.renderRecentEvents(entityKind, entityId);
} }
@@ -378,14 +336,16 @@ export class ViewerAppController {
this.interactionController.refreshHistoryWindows(); this.interactionController.refreshHistoryWindows();
} }
private resolveFocusedBubbleId() { private resolveFocusedCelestialId() {
return resolveFocusedBubbleId(this.world, this.selectedItems); return resolveFocusedCelestialId(this.world, this.selectedItems);
} }
private onResize = () => { private onResize = () => {
resizeViewer({ resizeViewer({
renderer: this.renderer, renderer: this.renderer,
camera: this.camera, galaxyCamera: this.galaxyLayer.camera,
systemCamera: this.systemLayer.camera,
localCamera: this.localLayer.camera,
}); });
}; };
@@ -398,7 +358,7 @@ export class ViewerAppController {
} }
private toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) { private toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) {
return this.navigationController.toDisplayLocalPosition(localPosition, systemId); return toDisplayLocalPosition(localPosition);
} }
private updateSystemPanel() { private updateSystemPanel() {

View File

@@ -11,10 +11,8 @@ export type {
PlanetSnapshot, PlanetSnapshot,
ResourceNodeSnapshot, ResourceNodeSnapshot,
ResourceNodeDelta, ResourceNodeDelta,
SpatialNodeSnapshot, CelestialSnapshot,
SpatialNodeDelta, CelestialDelta,
LocalBubbleSnapshot,
LocalBubbleDelta,
} from "./contractsCelestial"; } from "./contractsCelestial";
export type { export type {
StationSnapshot, StationSnapshot,

View File

@@ -32,7 +32,7 @@ export interface ResourceNodeSnapshot {
id: string; id: string;
systemId: string; systemId: string;
localPosition: Vector3Dto; localPosition: Vector3Dto;
anchorNodeId?: string | null; celestialId?: string | null;
sourceKind: string; sourceKind: string;
oreRemaining: number; oreRemaining: number;
maxOre: number; maxOre: number;
@@ -41,28 +41,15 @@ export interface ResourceNodeSnapshot {
export interface ResourceNodeDelta extends ResourceNodeSnapshot {} export interface ResourceNodeDelta extends ResourceNodeSnapshot {}
export interface SpatialNodeSnapshot { export interface CelestialSnapshot {
id: string; id: string;
systemId: string; systemId: string;
kind: string; kind: string;
localPosition: Vector3Dto; orbitalAnchor: Vector3Dto;
bubbleId: string; localSpaceRadius: number;
parentNodeId?: string | null; parentNodeId?: string | null;
occupyingStructureId?: string | null; occupyingStructureId?: string | null;
orbitReferenceId?: string | null; orbitReferenceId?: string | null;
} }
export interface SpatialNodeDelta extends SpatialNodeSnapshot {} export interface CelestialDelta extends CelestialSnapshot {}
export interface LocalBubbleSnapshot {
id: string;
nodeId: string;
systemId: string;
radius: number;
occupantShipIds: string[];
occupantStationIds: string[];
occupantClaimIds: string[];
occupantConstructionSiteIds: string[];
}
export interface LocalBubbleDelta extends LocalBubbleSnapshot {}

View File

@@ -18,9 +18,7 @@ export interface StationSnapshot {
category: string; category: string;
systemId: string; systemId: string;
localPosition: Vector3Dto; localPosition: Vector3Dto;
nodeId?: string | null; celestialId?: string | null;
bubbleId?: string | null;
anchorNodeId?: string | null;
color: string; color: string;
dockedShips: number; dockedShips: number;
dockedShipIds: string[]; dockedShipIds: string[];
@@ -45,8 +43,7 @@ export interface ClaimSnapshot {
id: string; id: string;
factionId: string; factionId: string;
systemId: string; systemId: string;
nodeId: string; celestialId: string;
bubbleId: string;
state: string; state: string;
health: number; health: number;
placedAtUtc: string; placedAtUtc: string;
@@ -59,8 +56,7 @@ export interface ConstructionSiteSnapshot {
id: string; id: string;
factionId: string; factionId: string;
systemId: string; systemId: string;
nodeId: string; celestialId: string;
bubbleId: string;
targetKind: string; targetKind: string;
targetDefinitionId: string; targetDefinitionId: string;
blueprintId?: string | null; blueprintId?: string | null;

View File

@@ -15,8 +15,7 @@ export interface ShipSnapshot {
behaviorPhase: string | null; behaviorPhase: string | null;
controllerTaskKind: string; controllerTaskKind: string;
commanderObjective: string | null; commanderObjective: string | null;
nodeId?: string | null; celestialId?: string | null;
bubbleId?: string | null;
dockedStationId?: string | null; dockedStationId?: string | null;
commanderId?: string | null; commanderId?: string | null;
policySetId?: string | null; policySetId?: string | null;
@@ -42,8 +41,7 @@ export interface ShipActionProgressSnapshot {
export interface ShipSpatialStateSnapshot { export interface ShipSpatialStateSnapshot {
spaceLayer: string; spaceLayer: string;
currentSystemId: string; currentSystemId: string;
currentNodeId?: string | null; currentCelestialId?: string | null;
currentBubbleId?: string | null;
localPosition?: Vector3Dto | null; localPosition?: Vector3Dto | null;
systemPosition?: Vector3Dto | null; systemPosition?: Vector3Dto | null;
movementRegime: string; movementRegime: string;

View File

@@ -9,12 +9,10 @@ import type {
FactionSnapshot, FactionSnapshot,
} from "./contractsFactions"; } from "./contractsFactions";
import type { import type {
LocalBubbleDelta, CelestialDelta,
LocalBubbleSnapshot, CelestialSnapshot,
ResourceNodeDelta, ResourceNodeDelta,
ResourceNodeSnapshot, ResourceNodeSnapshot,
SpatialNodeDelta,
SpatialNodeSnapshot,
SystemSnapshot, SystemSnapshot,
} from "./contractsCelestial"; } from "./contractsCelestial";
import type { import type {
@@ -37,8 +35,7 @@ export interface WorldSnapshot {
orbitalSimulation: OrbitalSimulationSnapshot; orbitalSimulation: OrbitalSimulationSnapshot;
generatedAtUtc: string; generatedAtUtc: string;
systems: SystemSnapshot[]; systems: SystemSnapshot[];
spatialNodes: SpatialNodeSnapshot[]; celestials: CelestialSnapshot[];
localBubbles: LocalBubbleSnapshot[];
nodes: ResourceNodeSnapshot[]; nodes: ResourceNodeSnapshot[];
stations: import("./contractsInfrastructure").StationSnapshot[]; stations: import("./contractsInfrastructure").StationSnapshot[];
claims: ClaimSnapshot[]; claims: ClaimSnapshot[];
@@ -57,8 +54,7 @@ export interface WorldDelta {
generatedAtUtc: string; generatedAtUtc: string;
requiresSnapshotRefresh: boolean; requiresSnapshotRefresh: boolean;
events: SimulationEventRecord[]; events: SimulationEventRecord[];
spatialNodes: SpatialNodeDelta[]; celestials: CelestialDelta[];
localBubbles: LocalBubbleDelta[];
nodes: ResourceNodeDelta[]; nodes: ResourceNodeDelta[];
stations: import("./contractsInfrastructure").StationDelta[]; stations: import("./contractsInfrastructure").StationDelta[];
claims: ClaimDelta[]; claims: ClaimDelta[];
@@ -85,7 +81,7 @@ export interface SimulationEventRecord {
export interface ObserverScope { export interface ObserverScope {
scopeKind: string; scopeKind: string;
systemId?: string | null; systemId?: string | null;
bubbleId?: string | null; celestialId?: string | null;
} }
export interface OrbitalSimulationSnapshot { export interface OrbitalSimulationSnapshot {

View File

@@ -1,19 +1,14 @@
import * as THREE from "three"; import * as THREE from "three";
import { ACTIVE_SYSTEM_CAPTURE_RADIUS, ACTIVE_SYSTEM_DETAIL_SCALE, GALAXY_PARALLAX_FACTOR } from "./viewerConstants"; import { ACTIVE_SYSTEM_CAPTURE_RADIUS, ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
import { KILOMETERS_PER_AU, computePlanetLocalPosition, currentWorldTimeSeconds, scaleGalaxyVector, scaleLocalVector, toThreeVector } from "./viewerMath"; import { DISPLAY_UNITS_PER_KILOMETER, KILOMETERS_PER_AU, computePlanetLocalPosition, currentWorldTimeSeconds, scaleGalaxyVector, scaleLocalVector, toThreeVector } from "./viewerMath";
import { resolveSelectableSystemId } from "./viewerSelection"; import { resolveSelectableSystemId } from "./viewerSelection";
import type { import type {
BubbleVisual,
ClaimVisual,
ConstructionSiteVisual,
NodeVisual, NodeVisual,
PlanetVisual, PlanetVisual,
Selectable, Selectable,
ShipVisual, ShipVisual,
SpatialNodeVisual,
StructureVisual,
WorldState, WorldState,
ZoomLevel, PovLevel,
} from "./viewerTypes"; } from "./viewerTypes";
interface ResolveSelectionPositionParams { interface ResolveSelectionPositionParams {
@@ -23,14 +18,13 @@ interface ResolveSelectionPositionParams {
nodeVisuals: Map<string, NodeVisual>; nodeVisuals: Map<string, NodeVisual>;
planetVisuals: PlanetVisual[]; planetVisuals: PlanetVisual[];
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3; computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined; resolvePointPosition: (systemId: string, celestialId?: string | null) => THREE.Vector3;
resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3;
} }
interface FocusOnSelectionParams extends ResolveSelectionPositionParams { interface FocusOnSelectionParams extends ResolveSelectionPositionParams {
activeSystemId?: string; activeSystemId?: string;
galaxyFocus: THREE.Vector3; galaxyAnchor: THREE.Vector3;
systemFocusLocal: THREE.Vector3; systemAnchor: THREE.Vector3;
} }
interface DetermineActiveSystemParams { interface DetermineActiveSystemParams {
@@ -39,7 +33,7 @@ interface DetermineActiveSystemParams {
cameraTargetShipId?: string; cameraTargetShipId?: string;
currentDistance: number; currentDistance: number;
selectedItems: Selectable[]; selectedItems: Selectable[];
galaxyFocus: THREE.Vector3; galaxyAnchor: THREE.Vector3;
} }
interface SeedSystemFocusParams { interface SeedSystemFocusParams {
@@ -48,38 +42,30 @@ interface SeedSystemFocusParams {
cameraMode: "tactical" | "follow"; cameraMode: "tactical" | "follow";
cameraTargetShipId?: string; cameraTargetShipId?: string;
selectedItems: Selectable[]; selectedItems: Selectable[];
systemFocusLocal: THREE.Vector3; systemAnchor: THREE.Vector3;
worldTimeSyncMs: number; worldTimeSyncMs: number;
nodeVisuals: Map<string, NodeVisual>; nodeVisuals: Map<string, NodeVisual>;
planetVisuals: PlanetVisual[]; planetVisuals: PlanetVisual[];
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3; computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined; resolvePointPosition: (systemId: string, celestialId?: string | null) => THREE.Vector3;
resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3;
} }
interface CameraFocusParams { interface CameraFocusParams {
world: WorldState | undefined; galaxyAnchor: THREE.Vector3;
activeSystemId?: string;
galaxyFocus: THREE.Vector3;
systemFocusLocal: THREE.Vector3;
} }
interface DisplayLocalPositionParams { export function getSystemCameraFocus(systemAnchor: THREE.Vector3): THREE.Vector3 {
world: WorldState | undefined; return systemAnchor.clone().multiplyScalar(DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
systemId?: string;
activeSystemId?: string;
localPosition: THREE.Vector3;
systemFocusLocal: THREE.Vector3;
} }
export function updatePanFromKeyboard( export function updatePanFromKeyboard(
keyState: Set<string>, keyState: Set<string>,
orbitYaw: number, orbitYaw: number,
currentDistance: number, currentDistance: number,
zoomLevel: ZoomLevel, povLevel: PovLevel,
activeSystemId: string | undefined, activeSystemId: string | undefined,
systemFocusLocal: THREE.Vector3, systemAnchor: THREE.Vector3,
galaxyFocus: THREE.Vector3, galaxyAnchor: THREE.Vector3,
delta: number, delta: number,
minimumDistance: number, minimumDistance: number,
maximumDistance: number, maximumDistance: number,
@@ -106,15 +92,15 @@ export function updatePanFromKeyboard(
const right = new THREE.Vector3(-forward.z, 0, forward.x); const right = new THREE.Vector3(-forward.z, 0, forward.x);
const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z)); const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z));
if (activeSystemId) { if (activeSystemId) {
const speedKilometers = zoomLevel === "system" const speedKilometers = povLevel === "system"
? THREE.MathUtils.mapLinear(currentDistance, 80, 4000, KILOMETERS_PER_AU * 0.002, KILOMETERS_PER_AU * 0.35) ? THREE.MathUtils.mapLinear(currentDistance, 80, 4000, KILOMETERS_PER_AU * 0.002, KILOMETERS_PER_AU * 0.35)
: THREE.MathUtils.mapLinear(currentDistance, minimumDistance, 120, 40, 180000); : THREE.MathUtils.mapLinear(currentDistance, minimumDistance, 120, 40, 180000);
systemFocusLocal.addScaledVector(pan, speedKilometers * delta); systemAnchor.addScaledVector(pan, speedKilometers * delta);
return; return;
} }
const speed = THREE.MathUtils.mapLinear(currentDistance, minimumDistance, maximumDistance, 320, 6800); const speed = THREE.MathUtils.mapLinear(currentDistance, minimumDistance, maximumDistance, 320, 6800);
galaxyFocus.addScaledVector(pan, speed * delta); galaxyAnchor.addScaledVector(pan, speed * delta);
} }
export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined { export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined {
@@ -124,7 +110,7 @@ export function determineActiveSystemId(params: DetermineActiveSystemParams): st
cameraTargetShipId, cameraTargetShipId,
currentDistance, currentDistance,
selectedItems, selectedItems,
galaxyFocus, galaxyAnchor,
} = params; } = params;
if (!world) { if (!world) {
@@ -165,7 +151,7 @@ export function determineActiveSystemId(params: DetermineActiveSystemParams): st
let nearestDistance = Number.POSITIVE_INFINITY; let nearestDistance = Number.POSITIVE_INFINITY;
for (const system of world.systems.values()) { for (const system of world.systems.values()) {
const center = scaleGalaxyVector(toThreeVector(system.galaxyPosition)); const center = scaleGalaxyVector(toThreeVector(system.galaxyPosition));
const distance = center.distanceTo(galaxyFocus); const distance = center.distanceTo(galaxyAnchor);
if (distance < nearestDistance) { if (distance < nearestDistance) {
nearestDistance = distance; nearestDistance = distance;
nearestSystemId = system.id; nearestSystemId = system.id;
@@ -185,7 +171,6 @@ export function resolveSelectionPosition(params: ResolveSelectionPositionParams)
nodeVisuals, nodeVisuals,
planetVisuals, planetVisuals,
computeNodeLocalPosition, computeNodeLocalPosition,
resolveBubblePosition,
resolvePointPosition, resolvePointPosition,
} = params; } = params;
@@ -208,20 +193,17 @@ export function resolveSelectionPosition(params: ResolveSelectionPositionParams)
? computeNodeLocalPosition(visual, currentWorldTimeSeconds(world, worldTimeSyncMs)) ? computeNodeLocalPosition(visual, currentWorldTimeSeconds(world, worldTimeSyncMs))
: (node ? toThreeVector(node.localPosition) : undefined); : (node ? toThreeVector(node.localPosition) : undefined);
} }
if (selection.kind === "spatial-node") { if (selection.kind === "celestial") {
const node = world.spatialNodes.get(selection.id); const celestial = world.celestials.get(selection.id);
return node ? toThreeVector(node.localPosition) : undefined; return celestial ? toThreeVector(celestial.orbitalAnchor) : undefined;
}
if (selection.kind === "bubble") {
return resolveBubblePosition(selection.id);
} }
if (selection.kind === "claim") { if (selection.kind === "claim") {
const claim = world.claims.get(selection.id); const claim = world.claims.get(selection.id);
return claim ? resolvePointPosition(claim.systemId, claim.nodeId) : undefined; return claim ? resolvePointPosition(claim.systemId, claim.celestialId) : undefined;
} }
if (selection.kind === "construction-site") { if (selection.kind === "construction-site") {
const site = world.constructionSites.get(selection.id); const site = world.constructionSites.get(selection.id);
return site ? resolvePointPosition(site.systemId, site.nodeId) : undefined; return site ? resolvePointPosition(site.systemId, site.celestialId) : undefined;
} }
if (selection.kind === "planet") { if (selection.kind === "planet") {
const system = world.systems.get(selection.systemId); const system = world.systems.get(selection.systemId);
@@ -242,8 +224,8 @@ export function focusOnSelection(params: FocusOnSelectionParams) {
world, world,
selection, selection,
activeSystemId, activeSystemId,
galaxyFocus, galaxyAnchor,
systemFocusLocal, systemAnchor,
} = params; } = params;
const nextFocus = resolveSelectionPosition(params); const nextFocus = resolveSelectionPosition(params);
@@ -252,8 +234,8 @@ export function focusOnSelection(params: FocusOnSelectionParams) {
} }
if (selection.kind === "system") { if (selection.kind === "system") {
galaxyFocus.copy(nextFocus); galaxyAnchor.copy(nextFocus);
systemFocusLocal.set(0, 0, 0); systemAnchor.set(0, 0, 0);
return; return;
} }
@@ -261,18 +243,18 @@ export function focusOnSelection(params: FocusOnSelectionParams) {
if (selectionSystemId && world) { if (selectionSystemId && world) {
const system = world.systems.get(selectionSystemId); const system = world.systems.get(selectionSystemId);
if (system) { if (system) {
galaxyFocus.copy(scaleGalaxyVector(toThreeVector(system.galaxyPosition))); galaxyAnchor.copy(scaleGalaxyVector(toThreeVector(system.galaxyPosition)));
systemFocusLocal.copy(nextFocus); systemAnchor.copy(nextFocus);
return; return;
} }
} }
if (activeSystemId && resolveSelectableSystemId(world, selection) === activeSystemId) { if (activeSystemId && resolveSelectableSystemId(world, selection) === activeSystemId) {
systemFocusLocal.copy(nextFocus); systemAnchor.copy(nextFocus);
return; return;
} }
galaxyFocus.copy(nextFocus); galaxyAnchor.copy(nextFocus);
} }
export function seedSystemFocusLocal(params: SeedSystemFocusParams) { export function seedSystemFocusLocal(params: SeedSystemFocusParams) {
@@ -282,7 +264,7 @@ export function seedSystemFocusLocal(params: SeedSystemFocusParams) {
cameraMode, cameraMode,
cameraTargetShipId, cameraTargetShipId,
selectedItems, selectedItems,
systemFocusLocal, systemAnchor,
} = params; } = params;
if (!world) { if (!world) {
@@ -292,7 +274,7 @@ export function seedSystemFocusLocal(params: SeedSystemFocusParams) {
if (cameraMode === "follow" && cameraTargetShipId) { if (cameraMode === "follow" && cameraTargetShipId) {
const followedShip = world.ships.get(cameraTargetShipId); const followedShip = world.ships.get(cameraTargetShipId);
if (followedShip?.systemId === systemId) { if (followedShip?.systemId === systemId) {
systemFocusLocal.copy(toThreeVector(followedShip.localPosition)); systemAnchor.copy(toThreeVector(followedShip.localPosition));
return; return;
} }
} }
@@ -300,7 +282,7 @@ export function seedSystemFocusLocal(params: SeedSystemFocusParams) {
const selected = selectedItems[0]; const selected = selectedItems[0];
if (selected && resolveSelectableSystemId(world, selected) === systemId) { if (selected && resolveSelectableSystemId(world, selected) === systemId) {
if (selected.kind === "system") { if (selected.kind === "system") {
systemFocusLocal.set(0, 0, 0); systemAnchor.set(0, 0, 0);
return; return;
} }
@@ -311,62 +293,26 @@ export function seedSystemFocusLocal(params: SeedSystemFocusParams) {
nodeVisuals: params.nodeVisuals, nodeVisuals: params.nodeVisuals,
planetVisuals: params.planetVisuals, planetVisuals: params.planetVisuals,
computeNodeLocalPosition: params.computeNodeLocalPosition, computeNodeLocalPosition: params.computeNodeLocalPosition,
resolveBubblePosition: params.resolveBubblePosition,
resolvePointPosition: params.resolvePointPosition, resolvePointPosition: params.resolvePointPosition,
}); });
if (selectedPosition) { if (selectedPosition) {
systemFocusLocal.copy(selectedPosition); systemAnchor.copy(selectedPosition);
return; return;
} }
} }
systemFocusLocal.set(0, 0, 0); systemAnchor.set(0, 0, 0);
} }
export function getCameraFocusWorldPosition(params: CameraFocusParams): THREE.Vector3 { export function getCameraFocusWorldPosition(params: CameraFocusParams): THREE.Vector3 {
const { return params.galaxyAnchor;
world,
activeSystemId,
galaxyFocus,
systemFocusLocal,
} = params;
if (!activeSystemId || !world) {
return galaxyFocus;
}
const system = world.systems.get(activeSystemId);
return system
? scaleGalaxyVector(toThreeVector(system.galaxyPosition)).add(
scaleLocalVector(systemFocusLocal).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE * GALAXY_PARALLAX_FACTOR),
)
: galaxyFocus;
} }
export function toDisplayLocalPosition(params: DisplayLocalPositionParams): THREE.Vector3 { /**
const { * Convert a local km position to system-scene display coordinates.
world, * System scene coordinate system: star at origin, all positions scaled by
systemId, * DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE.
activeSystemId, */
localPosition, export function toDisplayLocalPosition(localPosition: THREE.Vector3): THREE.Vector3 {
systemFocusLocal, return localPosition.clone().multiplyScalar(DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
} = params;
if (!world || !systemId) {
return scaleLocalVector(localPosition);
}
const system = world.systems.get(systemId);
if (!system) {
return scaleLocalVector(localPosition);
}
const center = scaleGalaxyVector(toThreeVector(system.galaxyPosition));
const scaledLocalPosition = scaleLocalVector(localPosition);
const scaledSystemFocus = scaleLocalVector(systemFocusLocal);
if (systemId !== activeSystemId) {
return center.clone().add(scaledLocalPosition);
}
return center.clone().add(scaledLocalPosition.sub(scaledSystemFocus).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE));
} }

View File

@@ -1,9 +1,9 @@
import type { ZoomLevel } from "./viewerTypes"; import type { PovLevel } from "./viewerTypes";
export const ZOOM_DISTANCE: Record<ZoomLevel, number> = { export const NAV_DISTANCE: Record<PovLevel, number> = {
local: 18, local: 18,
system: 3200, system: 3200,
universe: 32000, galaxy: 32000,
}; };
export const ACTIVE_SYSTEM_DETAIL_SCALE = 10; export const ACTIVE_SYSTEM_DETAIL_SCALE = 10;
@@ -14,10 +14,10 @@ export const STAR_RENDER_SCALE = 0.18;
export const PLANET_RENDER_SCALE = 0.95; export const PLANET_RENDER_SCALE = 0.95;
export const MOON_RENDER_SCALE = 1.1; export const MOON_RENDER_SCALE = 1.1;
export const MIN_CAMERA_DISTANCE = 2; export const MIN_CAMERA_DISTANCE = 2;
export const MAX_CAMERA_DISTANCE = 52000; export const MAX_CAMERA_DISTANCE = 150000;
export interface ZoomBlend { export interface ZoomBlend {
localWeight: number; localWeight: number;
systemWeight: number; systemWeight: number;
universeWeight: number; galaxyWeight: number;
} }

View File

@@ -14,28 +14,26 @@ export function createViewerControllers(host: any) {
getWorldSeed: () => host.world?.seed ?? 1, getWorldSeed: () => host.world?.seed ?? 1,
getWorldTimeSyncMs: () => host.worldTimeSyncMs, getWorldTimeSyncMs: () => host.worldTimeSyncMs,
getWorldPresentationContext: () => host.createWorldPresentationContext(), getWorldPresentationContext: () => host.createWorldPresentationContext(),
systemGroup: host.systemGroup, getActiveSystemId: () => host.activeSystemId,
spatialNodeGroup: host.spatialNodeGroup, galaxySystemGroup: host.galaxyLayer.systemGroup,
bubbleGroup: host.bubbleGroup, systemScene: host.systemLayer.scene,
nodeGroup: host.nodeGroup, celestialGroup: host.systemLayer.celestialGroup,
stationGroup: host.stationGroup, nodeGroup: host.systemLayer.nodeGroup,
claimGroup: host.claimGroup, stationGroup: host.systemLayer.stationGroup,
constructionSiteGroup: host.constructionSiteGroup, claimGroup: host.systemLayer.claimGroup,
shipGroup: host.shipGroup, constructionSiteGroup: host.systemLayer.constructionSiteGroup,
selectableTargets: host.selectableTargets, shipGroup: host.systemLayer.shipGroup,
presentationEntries: host.presentationEntries, galaxySelectableTargets: host.galaxyLayer.selectableTargets,
systemSelectableTargets: host.systemLayer.selectableTargets,
systemVisuals: host.systemVisuals, systemVisuals: host.systemVisuals,
systemSummaryVisuals: host.systemSummaryVisuals,
planetVisuals: host.planetVisuals, planetVisuals: host.planetVisuals,
orbitLines: host.orbitLines, orbitLines: host.orbitLines,
spatialNodeVisuals: host.spatialNodeVisuals, celestialVisuals: host.celestialVisuals,
bubbleVisuals: host.bubbleVisuals,
nodeVisuals: host.nodeVisuals, nodeVisuals: host.nodeVisuals,
stationVisuals: host.stationVisuals, stationVisuals: host.stationVisuals,
claimVisuals: host.claimVisuals, claimVisuals: host.claimVisuals,
constructionSiteVisuals: host.constructionSiteVisuals, constructionSiteVisuals: host.constructionSiteVisuals,
shipVisuals: host.shipVisuals, shipVisuals: host.shipVisuals,
registerPresentation: host.registerPresentation.bind(host),
}); });
const navigationController = new ViewerNavigationController({ const navigationController = new ViewerNavigationController({
@@ -45,6 +43,9 @@ export function createViewerControllers(host: any) {
setActiveSystemId: (value) => { setActiveSystemId: (value) => {
host.activeSystemId = value; host.activeSystemId = value;
}, },
onActiveSystemChanged: (oldId, newId) => {
sceneDataController.onActiveSystemChanged(oldId, newId);
},
getCameraMode: () => host.cameraMode, getCameraMode: () => host.cameraMode,
setCameraMode: (value) => { setCameraMode: (value) => {
host.cameraMode = value; host.cameraMode = value;
@@ -54,12 +55,13 @@ export function createViewerControllers(host: any) {
host.cameraTargetShipId = value; host.cameraTargetShipId = value;
}, },
getCurrentDistance: () => host.currentDistance, getCurrentDistance: () => host.currentDistance,
getZoomLevel: () => host.zoomLevel, getPovLevel: () => host.povLevel,
getSelectedItems: () => host.selectedItems, getSelectedItems: () => host.selectedItems,
getOrbitYaw: () => host.orbitYaw, getOrbitYaw: () => host.orbitYaw,
galaxyFocus: host.galaxyFocus, galaxyAnchor: host.galaxyAnchor,
systemFocusLocal: host.systemFocusLocal, systemAnchor: host.systemAnchor,
camera: host.camera, galaxyCamera: host.galaxyLayer.camera,
systemCamera: host.systemLayer.camera,
shipVisuals: host.shipVisuals, shipVisuals: host.shipVisuals,
nodeVisuals: host.nodeVisuals, nodeVisuals: host.nodeVisuals,
planetVisuals: host.planetVisuals, planetVisuals: host.planetVisuals,
@@ -76,9 +78,12 @@ export function createViewerControllers(host: any) {
const presentationController = new ViewerPresentationController({ const presentationController = new ViewerPresentationController({
renderer: host.renderer, renderer: host.renderer,
scene: host.scene, galaxyScene: host.galaxyLayer.scene,
camera: host.camera, galaxyCamera: host.galaxyLayer.camera,
ambienceGroup: host.ambienceGroup, systemCamera: host.systemLayer.camera,
galaxyAnchor: host.galaxyAnchor,
systemAnchor: host.systemAnchor,
ambienceGroup: host.universeLayer.ambienceGroup,
gameSummaryEl: host.gameSummaryEl, gameSummaryEl: host.gameSummaryEl,
networkSummaryEl: host.networkSummaryEl, networkSummaryEl: host.networkSummaryEl,
performanceSummaryEl: host.performanceSummaryEl, performanceSummaryEl: host.performanceSummaryEl,
@@ -94,14 +99,11 @@ export function createViewerControllers(host: any) {
getActiveSystemId: () => host.activeSystemId, getActiveSystemId: () => host.activeSystemId,
getCameraMode: () => host.cameraMode, getCameraMode: () => host.cameraMode,
getCameraTargetShipId: () => host.cameraTargetShipId, getCameraTargetShipId: () => host.cameraTargetShipId,
getZoomLevel: () => host.zoomLevel, getPovLevel: () => host.povLevel,
getSelectedItems: () => host.selectedItems, getSelectedItems: () => host.selectedItems,
getWorldTimeSyncMs: () => host.worldTimeSyncMs, getWorldTimeSyncMs: () => host.worldTimeSyncMs,
getCurrentDistance: () => host.currentDistance, getCurrentDistance: () => host.currentDistance,
systemFocusLocal: host.systemFocusLocal,
planetVisuals: host.planetVisuals, planetVisuals: host.planetVisuals,
systemSummaryVisuals: host.systemSummaryVisuals,
presentationEntries: host.presentationEntries,
orbitLines: host.orbitLines, orbitLines: host.orbitLines,
systemVisuals: host.systemVisuals, systemVisuals: host.systemVisuals,
createWorldPresentationContext: () => host.createWorldPresentationContext(), createWorldPresentationContext: () => host.createWorldPresentationContext(),
@@ -128,35 +130,33 @@ export function createViewerControllers(host: any) {
setCurrentStreamScopeKey: (value) => { setCurrentStreamScopeKey: (value) => {
host.currentStreamScopeKey = value; host.currentStreamScopeKey = value;
}, },
getZoomLevel: () => host.zoomLevel, getPovLevel: () => host.povLevel,
getActiveSystemId: () => host.activeSystemId, getActiveSystemId: () => host.activeSystemId,
getSelectedItems: () => host.selectedItems, getSelectedItems: () => host.selectedItems,
getCameraMode: () => host.cameraMode, getCameraMode: () => host.cameraMode,
getCameraTargetShipId: () => host.cameraTargetShipId, getCameraTargetShipId: () => host.cameraTargetShipId,
getNetworkStats: () => host.networkStats, getNetworkStats: () => host.networkStats,
getSystemSummaryVisuals: () => host.systemSummaryVisuals, getSystemSummaryVisuals: () => new Map(),
errorEl: host.errorEl, errorEl: host.errorEl,
opsStripEl: host.opsStripEl, opsStripEl: host.opsStripEl,
detailTitleEl: host.detailTitleEl, detailTitleEl: host.detailTitleEl,
detailBodyEl: host.detailBodyEl, detailBodyEl: host.detailBodyEl,
worldLabel: () => host.world?.label ?? "", worldLabel: () => host.world?.label ?? "",
rebuildSystems: (systems) => sceneDataController.rebuildSystems(systems), rebuildSystems: (systems) => sceneDataController.rebuildSystems(systems),
syncSpatialNodes: (nodes) => sceneDataController.syncSpatialNodes(nodes), syncCelestials: (celestials) => sceneDataController.syncCelestials(celestials),
syncLocalBubbles: (bubbles) => sceneDataController.syncLocalBubbles(bubbles),
syncNodes: (nodes) => sceneDataController.syncNodes(nodes), syncNodes: (nodes) => sceneDataController.syncNodes(nodes),
syncStations: (stations) => sceneDataController.syncStations(stations), syncStations: (stations) => sceneDataController.syncStations(stations),
syncClaims: (claims) => sceneDataController.syncClaims(claims), syncClaims: (claims) => sceneDataController.syncClaims(claims),
syncConstructionSites: (sites) => sceneDataController.syncConstructionSites(sites), syncConstructionSites: (sites) => sceneDataController.syncConstructionSites(sites),
syncShips: (ships, tickIntervalMs) => sceneDataController.syncShips(ships, tickIntervalMs), syncShips: (ships, tickIntervalMs) => sceneDataController.syncShips(ships, tickIntervalMs),
applySpatialNodeDeltas: (nodes) => sceneDataController.applySpatialNodeDeltas(nodes), applyCelestialDeltas: (celestials) => sceneDataController.applyCelestialDeltas(celestials),
applyLocalBubbleDeltas: (bubbles) => sceneDataController.applyLocalBubbleDeltas(bubbles),
applyNodeDeltas: (nodes) => sceneDataController.applyNodeDeltas(nodes), applyNodeDeltas: (nodes) => sceneDataController.applyNodeDeltas(nodes),
applyStationDeltas: (stations) => sceneDataController.applyStationDeltas(stations), applyStationDeltas: (stations) => sceneDataController.applyStationDeltas(stations),
applyClaimDeltas: (claims) => sceneDataController.applyClaimDeltas(claims), applyClaimDeltas: (claims) => sceneDataController.applyClaimDeltas(claims),
applyConstructionSiteDeltas: (sites) => sceneDataController.applyConstructionSiteDeltas(sites), applyConstructionSiteDeltas: (sites) => sceneDataController.applyConstructionSiteDeltas(sites),
applyShipDeltas: (ships, tickIntervalMs) => sceneDataController.applyShipDeltas(ships, tickIntervalMs), applyShipDeltas: (ships, tickIntervalMs) => sceneDataController.applyShipDeltas(ships, tickIntervalMs),
refreshHistoryWindows: () => host.refreshHistoryWindows(), refreshHistoryWindows: () => host.refreshHistoryWindows(),
resolveFocusedBubbleId: () => host.resolveFocusedBubbleId(), resolveFocusedCelestialId: () => host.resolveFocusedCelestialId(),
updateSystemSummaries: () => host.updateSystemSummaries(), updateSystemSummaries: () => host.updateSystemSummaries(),
applyZoomPresentation: () => presentationController.applyZoomPresentation(), applyZoomPresentation: () => presentationController.applyZoomPresentation(),
updateNetworkPanel: () => presentationController.updateNetworkPanel(), updateNetworkPanel: () => presentationController.updateNetworkPanel(),
@@ -193,14 +193,16 @@ export function createViewerControllers(host: any) {
renderer: host.renderer, renderer: host.renderer,
raycaster: host.raycaster, raycaster: host.raycaster,
mouse: host.mouse, mouse: host.mouse,
camera: host.camera, galaxyCamera: host.galaxyLayer.camera,
selectableTargets: host.selectableTargets, systemCamera: host.systemLayer.camera,
galaxySelectableTargets: host.galaxyLayer.selectableTargets,
systemSelectableTargets: host.systemLayer.selectableTargets,
hoverLabelEl: host.hoverLabelEl, hoverLabelEl: host.hoverLabelEl,
marqueeEl: host.marqueeEl, marqueeEl: host.marqueeEl,
keyState: host.keyState, keyState: host.keyState,
getWorld: () => host.world, getWorld: () => host.world,
getActiveSystemId: () => host.activeSystemId, getActiveSystemId: () => host.activeSystemId,
getZoomLevel: () => host.zoomLevel, getPovLevel: () => host.povLevel,
getSelectedItems: () => host.selectedItems, getSelectedItems: () => host.selectedItems,
setSelectedItems: (items) => { setSelectedItems: (items) => {
host.selectedItems = items; host.selectedItems = items;

View File

@@ -1,6 +1,6 @@
import * as THREE from "three"; import * as THREE from "three";
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, ZOOM_DISTANCE } from "./viewerConstants"; import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE } from "./viewerConstants";
import { scaleGalaxyVector, toDisplayGalaxyVector, toThreeVector } from "./viewerMath"; import { scaleGalaxyVector, toThreeVector } from "./viewerMath";
import { rawObject } from "./viewerScenePrimitives"; import { rawObject } from "./viewerScenePrimitives";
import { resolveShipWorldPosition } from "./viewerWorldPresentation"; import { resolveShipWorldPosition } from "./viewerWorldPresentation";
import type { import type {
@@ -9,7 +9,6 @@ import type {
ShipVisual, ShipVisual,
SystemVisual, SystemVisual,
WorldState, WorldState,
ZoomLevel,
} from "./viewerTypes"; } from "./viewerTypes";
export function syncFollowStateFromSelection( export function syncFollowStateFromSelection(
@@ -89,7 +88,7 @@ export function updateFollowCamera(params: {
followCameraDirection: THREE.Vector3; followCameraDirection: THREE.Vector3;
followCameraDesiredDirection: THREE.Vector3; followCameraDesiredDirection: THREE.Vector3;
followCameraOffset: THREE.Vector3; followCameraOffset: THREE.Vector3;
systemFocusLocal: THREE.Vector3; systemAnchor: THREE.Vector3;
delta: number; delta: number;
getAnimatedShipLocalPosition: (visual: ShipVisual) => THREE.Vector3; getAnimatedShipLocalPosition: (visual: ShipVisual) => THREE.Vector3;
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3; toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
@@ -106,7 +105,7 @@ export function updateFollowCamera(params: {
followCameraDirection, followCameraDirection,
followCameraDesiredDirection, followCameraDesiredDirection,
followCameraOffset, followCameraOffset,
systemFocusLocal, systemAnchor,
delta, delta,
getAnimatedShipLocalPosition, getAnimatedShipLocalPosition,
toDisplayLocalPosition, toDisplayLocalPosition,
@@ -143,10 +142,10 @@ export function updateFollowCamera(params: {
); );
if (ship.spatialState.movementRegime === "ftl-transit") { if (ship.spatialState.movementRegime === "ftl-transit") {
systemFocusLocal.set(0, 0, 0); systemAnchor.set(0, 0, 0);
const destinationNodeId = ship.spatialState.transit?.destinationNodeId; const destinationNodeId = ship.spatialState.transit?.destinationNodeId;
const destinationNode = destinationNodeId ? world.spatialNodes.get(destinationNodeId) : undefined; const destinationCelestial = destinationNodeId ? world.celestials.get(destinationNodeId) : undefined;
const destinationSystem = destinationNode ? world.systems.get(destinationNode.systemId) : undefined; const destinationSystem = destinationCelestial ? world.systems.get(destinationCelestial.systemId) : undefined;
const originSystem = world.systems.get(ship.systemId); const originSystem = world.systems.get(ship.systemId);
if (originSystem && destinationSystem) { if (originSystem && destinationSystem) {
followCameraDesiredDirection followCameraDesiredDirection
@@ -154,7 +153,7 @@ export function updateFollowCamera(params: {
.normalize(); .normalize();
} }
} else { } else {
systemFocusLocal.lerp(shipLocalPosition, 1 - Math.exp(-delta * 8)); systemAnchor.lerp(shipLocalPosition, 1 - Math.exp(-delta * 8));
followCameraDesiredDirection.copy(resolveShipHeading(visual, shipLocalPosition)).normalize(); followCameraDesiredDirection.copy(resolveShipHeading(visual, shipLocalPosition)).normalize();
} }
@@ -190,13 +189,6 @@ export function updateFollowCamera(params: {
}; };
} }
export function updateSystemDetailVisibility(systemVisuals: Map<string, SystemVisual>, activeSystemId?: string, zoomLevel?: ZoomLevel) {
const detailVisible = !!activeSystemId && zoomLevel !== "universe";
for (const [systemId, visual] of systemVisuals.entries()) {
visual.detailGroup.setVisible(detailVisible && systemId === activeSystemId);
}
}
export function setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) { export function setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
sprite.setVisible(opacity > 0.02); sprite.setVisible(opacity > 0.02);
const material = (rawObject(sprite) as THREE.Sprite).material; const material = (rawObject(sprite) as THREE.Sprite).material;
@@ -204,7 +196,7 @@ export function setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opa
material.needsUpdate = true; material.needsUpdate = true;
} }
export function zoomFromWheel(desiredDistance: number, deltaY: number) { export function navigateFromWheel(desiredDistance: number, deltaY: number) {
const clampedDelta = THREE.MathUtils.clamp(deltaY, -180, 180); const clampedDelta = THREE.MathUtils.clamp(deltaY, -180, 180);
const zoomFactor = Math.exp(clampedDelta * 0.00135); const zoomFactor = Math.exp(clampedDelta * 0.00135);
return THREE.MathUtils.clamp(desiredDistance * zoomFactor, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE); return THREE.MathUtils.clamp(desiredDistance * zoomFactor, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE);
@@ -226,21 +218,21 @@ export function applyKeyboardControl(params: {
cameraMode = "tactical"; cameraMode = "tactical";
} }
if (key === "1") { if (key === "1") {
desiredDistance = ZOOM_DISTANCE.local; desiredDistance = NAV_DISTANCE.local;
} else if (key === "2") { } else if (key === "2") {
desiredDistance = ZOOM_DISTANCE.system; desiredDistance = NAV_DISTANCE.system;
} else if (key === "3") { } else if (key === "3") {
desiredDistance = ZOOM_DISTANCE.universe; desiredDistance = NAV_DISTANCE.galaxy;
} else if (key === "=") { } else if (key === "=") {
desiredDistance = desiredDistance <= ZOOM_DISTANCE.system desiredDistance = desiredDistance <= NAV_DISTANCE.system
? ZOOM_DISTANCE.local ? NAV_DISTANCE.local
: ZOOM_DISTANCE.system; : NAV_DISTANCE.system;
} else if (key === "-") { } else if (key === "-") {
desiredDistance = desiredDistance >= ZOOM_DISTANCE.system desiredDistance = desiredDistance >= NAV_DISTANCE.system
? ZOOM_DISTANCE.universe ? NAV_DISTANCE.galaxy
: ZOOM_DISTANCE.system; : NAV_DISTANCE.system;
} else if (key === "/") { } else if (key === "/") {
desiredDistance = ZOOM_DISTANCE.system; desiredDistance = NAV_DISTANCE.system;
} }
return { cameraMode, desiredDistance }; return { cameraMode, desiredDistance };

View File

@@ -0,0 +1,37 @@
import * as THREE from "three";
import type { Selectable } from "./viewerTypes";
/**
* Galaxy rendering layer — the galaxy map.
* Scene coordinate unit: display-unit (light-year scale).
* Only visible in galaxy POV, rendered on top of the universe backdrop.
* Contains star dots and shell reticles for each system.
*/
export class GalaxyLayer {
readonly scene = new THREE.Scene();
readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 600000);
/** Star dots and shell reticles, one per system. */
readonly systemGroup = new THREE.Group();
readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
constructor() {
this.scene.fog = new THREE.FogExp2(0x040912, 0.000035);
this.scene.add(new THREE.AmbientLight(0x90a6c0, 0.55));
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3);
keyLight.position.set(1000, 1200, 800);
this.scene.add(keyLight);
this.scene.add(this.systemGroup);
}
updateCamera(focus: THREE.Vector3, orbitOffset: THREE.Vector3) {
this.camera.position.copy(focus).add(orbitOffset);
this.camera.lookAt(focus);
}
onResize(aspect: number) {
this.camera.aspect = aspect;
this.camera.updateProjectionMatrix();
}
}

View File

@@ -2,27 +2,16 @@ import * as THREE from "three";
import { describeHoverLabel, getSelectionGroup } from "./viewerSelection"; import { describeHoverLabel, getSelectionGroup } from "./viewerSelection";
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants"; import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, formatAdaptiveDistanceFromKilometers, formatSystemDistance } from "./viewerMath"; import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, formatAdaptiveDistanceFromKilometers, formatSystemDistance } from "./viewerMath";
import type { Selectable, SelectionGroup, WorldState, ZoomLevel } from "./viewerTypes"; import type { Selectable, SelectionGroup, WorldState, PovLevel } from "./viewerTypes";
export interface HoverPickResult { export interface HoverPickResult {
selection: Selectable; selection: Selectable;
object: THREE.Object3D; object: THREE.Object3D;
/** Which camera was used for this pick (for distance calculation) */
camera: THREE.Camera;
} }
export function pickSelectableAtClientPosition( function pickOneCamera(
renderer: THREE.WebGLRenderer,
raycaster: THREE.Raycaster,
mouse: THREE.Vector2,
camera: THREE.Camera,
selectableTargets: Map<THREE.Object3D, Selectable>,
clientX: number,
clientY: number,
) {
const hit = pickSelectableHitAtClientPosition(renderer, raycaster, mouse, camera, selectableTargets, clientX, clientY);
return hit?.selection;
}
export function pickSelectableHitAtClientPosition(
renderer: THREE.WebGLRenderer, renderer: THREE.WebGLRenderer,
raycaster: THREE.Raycaster, raycaster: THREE.Raycaster,
mouse: THREE.Vector2, mouse: THREE.Vector2,
@@ -38,29 +27,61 @@ export function pickSelectableHitAtClientPosition(
const hit = raycaster.intersectObjects([...selectableTargets.keys()], false)[0]; const hit = raycaster.intersectObjects([...selectableTargets.keys()], false)[0];
const selection = hit ? selectableTargets.get(hit.object) : undefined; const selection = hit ? selectableTargets.get(hit.object) : undefined;
return hit && selection return hit && selection
? { selection, object: hit.object } ? { selection, object: hit.object, camera }
: undefined; : undefined;
} }
export function pickSelectableAtClientPosition(
renderer: THREE.WebGLRenderer,
raycaster: THREE.Raycaster,
mouse: THREE.Vector2,
galaxyCamera: THREE.Camera,
galaxySelectableTargets: Map<THREE.Object3D, Selectable>,
systemCamera: THREE.Camera,
systemSelectableTargets: Map<THREE.Object3D, Selectable>,
clientX: number,
clientY: number,
) {
const hit = pickSelectableHitAtClientPosition(renderer, raycaster, mouse, galaxyCamera, galaxySelectableTargets, systemCamera, systemSelectableTargets, clientX, clientY);
return hit?.selection;
}
export function pickSelectableHitAtClientPosition(
renderer: THREE.WebGLRenderer,
raycaster: THREE.Raycaster,
mouse: THREE.Vector2,
galaxyCamera: THREE.Camera,
galaxySelectableTargets: Map<THREE.Object3D, Selectable>,
systemCamera: THREE.Camera,
systemSelectableTargets: Map<THREE.Object3D, Selectable>,
clientX: number,
clientY: number,
): HoverPickResult | undefined {
// Try system camera first (higher priority when in a system)
const systemHit = pickOneCamera(renderer, raycaster, mouse, systemCamera, systemSelectableTargets, clientX, clientY);
if (systemHit) {
return systemHit;
}
return pickOneCamera(renderer, raycaster, mouse, galaxyCamera, galaxySelectableTargets, clientX, clientY);
}
export function updateHoverLabel(params: { export function updateHoverLabel(params: {
dragMode?: string; dragMode?: string;
hoverLabelEl: HTMLDivElement; hoverLabelEl: HTMLDivElement;
hoverPick: HoverPickResult | undefined; hoverPick: HoverPickResult | undefined;
activeSystemId?: string; activeSystemId?: string;
zoomLevel: ZoomLevel; povLevel: PovLevel;
world?: WorldState; world?: WorldState;
point: THREE.Vector2; point: THREE.Vector2;
camera: THREE.Camera;
}) { }) {
const { const {
dragMode, dragMode,
hoverLabelEl, hoverLabelEl,
hoverPick, hoverPick,
activeSystemId, activeSystemId,
zoomLevel, povLevel,
world, world,
point, point,
camera,
} = params; } = params;
if (dragMode) { if (dragMode) {
@@ -73,14 +94,14 @@ export function updateHoverLabel(params: {
return; return;
} }
const { selection, object } = hoverPick; const { selection, object, camera } = hoverPick;
const label = describeHoverLabel(world, selection); const label = describeHoverLabel(world, selection);
if (!label) { if (!label) {
hoverLabelEl.hidden = true; hoverLabelEl.hidden = true;
return; return;
} }
const distance = formatHoverDistance(camera, object, selection, zoomLevel, activeSystemId); const distance = formatHoverDistance(camera, object, selection, povLevel, activeSystemId);
hoverLabelEl.hidden = false; hoverLabelEl.hidden = false;
hoverLabelEl.textContent = `${label}\n${distance}`; hoverLabelEl.textContent = `${label}\n${distance}`;
@@ -92,7 +113,7 @@ function formatHoverDistance(
camera: THREE.Camera, camera: THREE.Camera,
object: THREE.Object3D, object: THREE.Object3D,
selection: Selectable, selection: Selectable,
zoomLevel: ZoomLevel, povLevel: PovLevel,
activeSystemId?: string, activeSystemId?: string,
) { ) {
const worldPosition = object.getWorldPosition(new THREE.Vector3()); const worldPosition = object.getWorldPosition(new THREE.Vector3());
@@ -107,14 +128,13 @@ function formatHoverDistance(
: selection.kind === "ship" : selection.kind === "ship"
|| selection.kind === "station" || selection.kind === "station"
|| selection.kind === "node" || selection.kind === "node"
|| selection.kind === "spatial-node" || selection.kind === "celestial"
|| selection.kind === "bubble"
|| selection.kind === "claim" || selection.kind === "claim"
|| selection.kind === "construction-site"; || selection.kind === "construction-site";
if (inActiveSystem && activeSystemId) { if (inActiveSystem && activeSystemId) {
const kilometers = displayDistance / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE); const kilometers = displayDistance / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
return zoomLevel === "system" return povLevel === "system"
? formatSystemDistance(kilometers / KILOMETERS_PER_AU) ? formatSystemDistance(kilometers / KILOMETERS_PER_AU)
: formatAdaptiveDistanceFromKilometers(kilometers); : formatAdaptiveDistanceFromKilometers(kilometers);
} }
@@ -145,17 +165,17 @@ export function hideMarqueeBox(marqueeEl: HTMLDivElement) {
export function completeMarqueeSelection(params: { export function completeMarqueeSelection(params: {
renderer: THREE.WebGLRenderer; renderer: THREE.WebGLRenderer;
camera: THREE.Camera; systemCamera: THREE.Camera;
dragStart: THREE.Vector2; dragStart: THREE.Vector2;
dragLast: THREE.Vector2; dragLast: THREE.Vector2;
selectableTargets: Map<THREE.Object3D, Selectable>; systemSelectableTargets: Map<THREE.Object3D, Selectable>;
}) { }) {
const { const {
renderer, renderer,
camera, systemCamera,
dragStart, dragStart,
dragLast, dragLast,
selectableTargets, systemSelectableTargets,
} = params; } = params;
const bounds = renderer.domElement.getBoundingClientRect(); const bounds = renderer.domElement.getBoundingClientRect();
@@ -165,7 +185,7 @@ export function completeMarqueeSelection(params: {
const maxY = Math.max(dragStart.y, dragLast.y); const maxY = Math.max(dragStart.y, dragLast.y);
const grouped = new Map<SelectionGroup, Selectable[]>(); const grouped = new Map<SelectionGroup, Selectable[]>();
for (const [object, selectable] of selectableTargets.entries()) { for (const [object, selectable] of systemSelectableTargets.entries()) {
if (object instanceof THREE.Sprite && !object.visible) { if (object instanceof THREE.Sprite && !object.visible) {
continue; continue;
} }
@@ -175,7 +195,7 @@ export function completeMarqueeSelection(params: {
const worldPosition = new THREE.Vector3(); const worldPosition = new THREE.Vector3();
object.getWorldPosition(worldPosition); object.getWorldPosition(worldPosition);
worldPosition.project(camera); worldPosition.project(systemCamera);
const screenX = ((worldPosition.x + 1) * 0.5) * bounds.width; const screenX = ((worldPosition.x + 1) * 0.5) * bounds.width;
const screenY = ((1 - worldPosition.y) * 0.5) * bounds.height; const screenY = ((1 - worldPosition.y) * 0.5) * bounds.height;
if (screenX < minX || screenX > maxX || screenY < minY || screenY > maxY) { if (screenX < minX || screenX > maxX || screenY < minY || screenY > maxY) {

View File

@@ -10,7 +10,7 @@ import {
import { import {
applyKeyboardControl, applyKeyboardControl,
toggleCameraMode, toggleCameraMode,
zoomFromWheel, navigateFromWheel,
} from "./viewerControls"; } from "./viewerControls";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController"; import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import type { import type {
@@ -18,21 +18,23 @@ import type {
DragMode, DragMode,
Selectable, Selectable,
WorldState, WorldState,
ZoomLevel, PovLevel,
} from "./viewerTypes"; } from "./viewerTypes";
export interface ViewerInteractionContext { export interface ViewerInteractionContext {
renderer: THREE.WebGLRenderer; renderer: THREE.WebGLRenderer;
raycaster: THREE.Raycaster; raycaster: THREE.Raycaster;
mouse: THREE.Vector2; mouse: THREE.Vector2;
camera: THREE.PerspectiveCamera; galaxyCamera: THREE.PerspectiveCamera;
selectableTargets: Map<THREE.Object3D, Selectable>; systemCamera: THREE.PerspectiveCamera;
galaxySelectableTargets: Map<THREE.Object3D, Selectable>;
systemSelectableTargets: Map<THREE.Object3D, Selectable>;
hoverLabelEl: HTMLDivElement; hoverLabelEl: HTMLDivElement;
marqueeEl: HTMLDivElement; marqueeEl: HTMLDivElement;
keyState: Set<string>; keyState: Set<string>;
getWorld: () => WorldState | undefined; getWorld: () => WorldState | undefined;
getActiveSystemId: () => string | undefined; getActiveSystemId: () => string | undefined;
getZoomLevel: () => ZoomLevel; getPovLevel: () => PovLevel;
getSelectedItems: () => Selectable[]; getSelectedItems: () => Selectable[];
setSelectedItems: (items: Selectable[]) => void; setSelectedItems: (items: Selectable[]) => void;
getDragMode: () => DragMode | undefined; getDragMode: () => DragMode | undefined;
@@ -235,7 +237,7 @@ export class ViewerInteractionController {
readonly onWheel = (event: WheelEvent) => { readonly onWheel = (event: WheelEvent) => {
event.preventDefault(); event.preventDefault();
this.context.setDesiredDistance(zoomFromWheel(this.context.getDesiredDistance(), event.deltaY)); this.context.setDesiredDistance(navigateFromWheel(this.context.getDesiredDistance(), event.deltaY));
this.context.updateGamePanel("Live"); this.context.updateGamePanel("Live");
}; };
@@ -269,10 +271,9 @@ export class ViewerInteractionController {
hoverLabelEl: this.context.hoverLabelEl, hoverLabelEl: this.context.hoverLabelEl,
hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY), hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY),
activeSystemId: this.context.getActiveSystemId(), activeSystemId: this.context.getActiveSystemId(),
zoomLevel: this.context.getZoomLevel(), povLevel: this.context.getPovLevel(),
world: this.context.getWorld(), world: this.context.getWorld(),
point: this.context.screenPointFromClient(event.clientX, event.clientY), point: this.context.screenPointFromClient(event.clientX, event.clientY),
camera: this.context.camera,
}); });
} }
@@ -300,8 +301,10 @@ export class ViewerInteractionController {
this.context.renderer, this.context.renderer,
this.context.raycaster, this.context.raycaster,
this.context.mouse, this.context.mouse,
this.context.camera, this.context.galaxyCamera,
this.context.selectableTargets, this.context.galaxySelectableTargets,
this.context.systemCamera,
this.context.systemSelectableTargets,
clientX, clientX,
clientY, clientY,
); );
@@ -312,8 +315,10 @@ export class ViewerInteractionController {
this.context.renderer, this.context.renderer,
this.context.raycaster, this.context.raycaster,
this.context.mouse, this.context.mouse,
this.context.camera, this.context.galaxyCamera,
this.context.selectableTargets, this.context.galaxySelectableTargets,
this.context.systemCamera,
this.context.systemSelectableTargets,
clientX, clientX,
clientY, clientY,
); );
@@ -322,10 +327,10 @@ export class ViewerInteractionController {
private completeMarqueeSelection() { private completeMarqueeSelection() {
const selection = completeMarqueeSelection({ const selection = completeMarqueeSelection({
renderer: this.context.renderer, renderer: this.context.renderer,
camera: this.context.camera, systemCamera: this.context.systemCamera,
dragStart: this.context.dragStart, dragStart: this.context.dragStart,
dragLast: this.context.dragLast, dragLast: this.context.dragLast,
selectableTargets: this.context.selectableTargets, systemSelectableTargets: this.context.systemSelectableTargets,
}); });
this.context.setSelectedItems(selection); this.context.setSelectedItems(selection);
this.context.syncFollowStateFromSelection(); this.context.syncFollowStateFromSelection();

View File

@@ -0,0 +1,24 @@
import * as THREE from "three";
/**
* Local rendering layer.
* Scene coordinate unit: reserved for future close-up detail.
* Camera far plane covers immediate surroundings.
* Currently empty — populated when local-space objects are introduced.
*/
export class LocalLayer {
readonly scene = new THREE.Scene();
readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 2000);
private static readonly ORIGIN = new THREE.Vector3(0, 0, 0);
updateCamera(orbitOffset: THREE.Vector3) {
this.camera.position.copy(orbitOffset);
this.camera.lookAt(LocalLayer.ORIGIN);
}
onResize(aspect: number) {
this.camera.aspect = aspect;
this.camera.updateProjectionMatrix();
}
}

View File

@@ -9,7 +9,7 @@ import type {
import type { import type {
OrbitalAnchor, OrbitalAnchor,
WorldState, WorldState,
ZoomLevel, PovLevel,
} from "./viewerTypes"; } from "./viewerTypes";
import type { ZoomBlend } from "./viewerConstants"; import type { ZoomBlend } from "./viewerConstants";
@@ -112,19 +112,19 @@ export function computeZoomBlend(distance: number): ZoomBlend {
return { return {
localWeight: 1 - localToSystem, localWeight: 1 - localToSystem,
systemWeight: Math.min(localToSystem, 1 - systemToUniverse), systemWeight: Math.min(localToSystem, 1 - systemToUniverse),
universeWeight: systemToUniverse, galaxyWeight: systemToUniverse,
}; };
} }
export function classifyZoomLevel(distance: number): ZoomLevel { export function classifyPovLevel(distance: number): PovLevel {
const blend = computeZoomBlend(distance); const blend = computeZoomBlend(distance);
if (blend.localWeight >= blend.systemWeight && blend.localWeight >= blend.universeWeight) { if (blend.localWeight >= blend.systemWeight && blend.localWeight >= blend.galaxyWeight) {
return "local"; return "local";
} }
if (blend.systemWeight >= blend.universeWeight) { if (blend.systemWeight >= blend.galaxyWeight) {
return "system"; return "system";
} }
return "universe"; return "galaxy";
} }
export function toThreeVector(vector: Vector3Dto): THREE.Vector3 { export function toThreeVector(vector: Vector3Dto): THREE.Vector3 {

View File

@@ -3,6 +3,7 @@ import {
determineActiveSystemId, determineActiveSystemId,
focusOnSelection, focusOnSelection,
getCameraFocusWorldPosition, getCameraFocusWorldPosition,
getSystemCameraFocus,
resolveSelectionPosition, resolveSelectionPosition,
seedSystemFocusLocal, seedSystemFocusLocal,
toDisplayLocalPosition, toDisplayLocalPosition,
@@ -10,9 +11,8 @@ import {
import { import {
syncFollowStateFromSelection, syncFollowStateFromSelection,
updateFollowCamera, updateFollowCamera,
updateSystemDetailVisibility,
} from "./viewerControls"; } from "./viewerControls";
import { computeNodeLocalPosition, resolveBubblePosition, resolvePointPosition } from "./viewerWorldPresentation"; import { computeNodeLocalPosition, resolvePointPosition } from "./viewerWorldPresentation";
import { getAnimatedShipLocalPosition, resolveShipHeading } from "./viewerPresentation"; import { getAnimatedShipLocalPosition, resolveShipHeading } from "./viewerPresentation";
import type { import type {
CameraMode, CameraMode,
@@ -22,7 +22,7 @@ import type {
ShipVisual, ShipVisual,
SystemVisual, SystemVisual,
WorldState, WorldState,
ZoomLevel, PovLevel,
} from "./viewerTypes"; } from "./viewerTypes";
export interface ViewerNavigationContext { export interface ViewerNavigationContext {
@@ -30,17 +30,19 @@ export interface ViewerNavigationContext {
getWorldTimeSyncMs: () => number; getWorldTimeSyncMs: () => number;
getActiveSystemId: () => string | undefined; getActiveSystemId: () => string | undefined;
setActiveSystemId: (value: string | undefined) => void; setActiveSystemId: (value: string | undefined) => void;
onActiveSystemChanged: (oldId: string | undefined, newId: string | undefined) => void;
getCameraMode: () => CameraMode; getCameraMode: () => CameraMode;
setCameraMode: (value: CameraMode) => void; setCameraMode: (value: CameraMode) => void;
getCameraTargetShipId: () => string | undefined; getCameraTargetShipId: () => string | undefined;
setCameraTargetShipId: (value: string | undefined) => void; setCameraTargetShipId: (value: string | undefined) => void;
getCurrentDistance: () => number; getCurrentDistance: () => number;
getZoomLevel: () => ZoomLevel; getPovLevel: () => PovLevel;
getSelectedItems: () => Selectable[]; getSelectedItems: () => Selectable[];
getOrbitYaw: () => number; getOrbitYaw: () => number;
galaxyFocus: THREE.Vector3; galaxyAnchor: THREE.Vector3;
systemFocusLocal: THREE.Vector3; systemAnchor: THREE.Vector3;
camera: THREE.PerspectiveCamera; galaxyCamera: THREE.PerspectiveCamera;
systemCamera: THREE.PerspectiveCamera;
shipVisuals: Map<string, ShipVisual>; shipVisuals: Map<string, ShipVisual>;
nodeVisuals: Map<string, NodeVisual>; nodeVisuals: Map<string, NodeVisual>;
planetVisuals: PlanetVisual[]; planetVisuals: PlanetVisual[];
@@ -66,14 +68,10 @@ export class ViewerNavigationController {
nodeVisuals: this.context.nodeVisuals, nodeVisuals: this.context.nodeVisuals,
planetVisuals: this.context.planetVisuals, planetVisuals: this.context.planetVisuals,
computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds), computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds),
resolveBubblePosition: (bubbleId) => { resolvePointPosition: (systemId, celestialId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, celestialId),
const bubble = this.context.getWorld()?.localBubbles.get(bubbleId);
return bubble ? resolveBubblePosition(this.context.createWorldPresentationContext(), bubble) : undefined;
},
resolvePointPosition: (systemId, nodeId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, nodeId),
activeSystemId: this.context.getActiveSystemId(), activeSystemId: this.context.getActiveSystemId(),
galaxyFocus: this.context.galaxyFocus, galaxyAnchor: this.context.galaxyAnchor,
systemFocusLocal: this.context.systemFocusLocal, systemAnchor: this.context.systemAnchor,
}); });
} }
@@ -85,11 +83,7 @@ export class ViewerNavigationController {
nodeVisuals: this.context.nodeVisuals, nodeVisuals: this.context.nodeVisuals,
planetVisuals: this.context.planetVisuals, planetVisuals: this.context.planetVisuals,
computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds), computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds),
resolveBubblePosition: (bubbleId) => { resolvePointPosition: (systemId, celestialId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, celestialId),
const bubble = this.context.getWorld()?.localBubbles.get(bubbleId);
return bubble ? resolveBubblePosition(this.context.createWorldPresentationContext(), bubble) : undefined;
},
resolvePointPosition: (systemId, nodeId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, nodeId),
}); });
} }
@@ -100,9 +94,10 @@ export class ViewerNavigationController {
cameraTargetShipId: this.context.getCameraTargetShipId(), cameraTargetShipId: this.context.getCameraTargetShipId(),
currentDistance: this.context.getCurrentDistance(), currentDistance: this.context.getCurrentDistance(),
selectedItems: this.context.getSelectedItems(), selectedItems: this.context.getSelectedItems(),
galaxyFocus: this.context.galaxyFocus, galaxyAnchor: this.context.galaxyAnchor,
}); });
if (nextActiveSystemId === this.context.getActiveSystemId()) { const previousSystemId = this.context.getActiveSystemId();
if (nextActiveSystemId === previousSystemId) {
return; return;
} }
@@ -111,7 +106,7 @@ export class ViewerNavigationController {
} }
this.context.setActiveSystemId(nextActiveSystemId); this.context.setActiveSystemId(nextActiveSystemId);
this.updateSystemDetailVisibility(); this.context.onActiveSystemChanged(previousSystemId, nextActiveSystemId);
this.context.updatePanels(); this.context.updatePanels();
this.context.updateGamePanel("Live"); this.context.updateGamePanel("Live");
} }
@@ -123,16 +118,16 @@ export class ViewerNavigationController {
cameraTargetShipId: this.context.getCameraTargetShipId(), cameraTargetShipId: this.context.getCameraTargetShipId(),
shipVisuals: this.context.shipVisuals, shipVisuals: this.context.shipVisuals,
currentDistance: this.context.getCurrentDistance(), currentDistance: this.context.getCurrentDistance(),
camera: this.context.camera, camera: this.context.systemCamera,
followCameraPosition: this.context.followCameraPosition, followCameraPosition: this.context.followCameraPosition,
followCameraFocus: this.context.followCameraFocus, followCameraFocus: this.context.followCameraFocus,
followCameraDirection: this.context.followCameraDirection, followCameraDirection: this.context.followCameraDirection,
followCameraDesiredDirection: this.context.followCameraDesiredDirection, followCameraDesiredDirection: this.context.followCameraDesiredDirection,
followCameraOffset: this.context.followCameraOffset, followCameraOffset: this.context.followCameraOffset,
systemFocusLocal: this.context.systemFocusLocal, systemAnchor: this.context.systemAnchor,
delta, delta,
getAnimatedShipLocalPosition, getAnimatedShipLocalPosition,
toDisplayLocalPosition: (localPosition, systemId) => this.toDisplayLocalPosition(localPosition, systemId), toDisplayLocalPosition: (localPosition) => toDisplayLocalPosition(localPosition),
resolveShipHeading: (visual, worldPosition) => resolveShipHeading(visual, worldPosition, this.context.getOrbitYaw()), resolveShipHeading: (visual, worldPosition) => resolveShipHeading(visual, worldPosition, this.context.getOrbitYaw()),
}); });
this.context.setCameraMode(nextState.cameraMode); this.context.setCameraMode(nextState.cameraMode);
@@ -150,19 +145,16 @@ export class ViewerNavigationController {
this.context.setCameraTargetShipId(nextState.cameraTargetShipId); this.context.setCameraTargetShipId(nextState.cameraTargetShipId);
} }
updateSystemDetailVisibility() {
updateSystemDetailVisibility(this.context.systemVisuals, this.context.getActiveSystemId(), this.context.getZoomLevel());
}
getCameraFocusWorldPosition() { getCameraFocusWorldPosition() {
return getCameraFocusWorldPosition({ return getCameraFocusWorldPosition({
world: this.context.getWorld(), galaxyAnchor: this.context.galaxyAnchor,
activeSystemId: this.context.getActiveSystemId(),
galaxyFocus: this.context.galaxyFocus,
systemFocusLocal: this.context.systemFocusLocal,
}); });
} }
getSystemCameraFocus() {
return getSystemCameraFocus(this.context.systemAnchor);
}
seedSystemFocusLocal(systemId: string) { seedSystemFocusLocal(systemId: string) {
seedSystemFocusLocal({ seedSystemFocusLocal({
world: this.context.getWorld(), world: this.context.getWorld(),
@@ -170,26 +162,21 @@ export class ViewerNavigationController {
cameraMode: this.context.getCameraMode(), cameraMode: this.context.getCameraMode(),
cameraTargetShipId: this.context.getCameraTargetShipId(), cameraTargetShipId: this.context.getCameraTargetShipId(),
selectedItems: this.context.getSelectedItems(), selectedItems: this.context.getSelectedItems(),
systemFocusLocal: this.context.systemFocusLocal, systemAnchor: this.context.systemAnchor,
worldTimeSyncMs: this.context.getWorldTimeSyncMs(), worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
nodeVisuals: this.context.nodeVisuals, nodeVisuals: this.context.nodeVisuals,
planetVisuals: this.context.planetVisuals, planetVisuals: this.context.planetVisuals,
computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds), computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds),
resolveBubblePosition: (bubbleId) => { resolvePointPosition: (systemIdValue, celestialId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemIdValue, celestialId),
const bubble = this.context.getWorld()?.localBubbles.get(bubbleId);
return bubble ? resolveBubblePosition(this.context.createWorldPresentationContext(), bubble) : undefined;
},
resolvePointPosition: (systemIdValue, nodeId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemIdValue, nodeId),
}); });
} }
toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) { toDisplayLocalPosition(localPosition: THREE.Vector3) {
return toDisplayLocalPosition({ return toDisplayLocalPosition(localPosition);
world: this.context.getWorld(), }
systemId,
activeSystemId: this.context.getActiveSystemId(), /** Returns a display position for the system camera, derived from a raw local position in km. */
localPosition, toSystemDisplayPosition(localPosition: THREE.Vector3) {
systemFocusLocal: this.context.systemFocusLocal, return toDisplayLocalPosition(localPosition);
});
} }
} }

View File

@@ -2,7 +2,7 @@ import type { StationSnapshot } from "./contractsInfrastructure";
import type { FactionSnapshot } from "./contractsFactions"; import type { FactionSnapshot } from "./contractsFactions";
import { inventoryAmount } from "./viewerMath"; import { inventoryAmount } from "./viewerMath";
import { describeShipCurrentAction, describeShipLocation, describeShipObjective, describeShipState } from "./viewerSelection"; import { describeShipCurrentAction, describeShipLocation, describeShipObjective, describeShipState } from "./viewerSelection";
import type { CameraMode, Selectable, WorldState, ZoomLevel } from "./viewerTypes"; import type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes";
function renderFactionCard(faction: FactionSnapshot): string { function renderFactionCard(faction: FactionSnapshot): string {
const state = faction.goapState; const state = faction.goapState;
@@ -71,14 +71,14 @@ export function renderOpsStrip(
selectedItems: Selectable[], selectedItems: Selectable[],
cameraMode: CameraMode, cameraMode: CameraMode,
cameraTargetShipId?: string, cameraTargetShipId?: string,
zoomLevel?: ZoomLevel, povLevel?: PovLevel,
activeSystemId?: string, activeSystemId?: string,
) { ) {
if (!world) { if (!world) {
return ""; return "";
} }
const isSystemFiltered = zoomLevel !== "universe" && activeSystemId != null; const isSystemFiltered = povLevel !== "galaxy" && activeSystemId != null;
const factionCards = [...world.factions.values()] const factionCards = [...world.factions.values()]
.sort((a, b) => a.label.localeCompare(b.label)) .sort((a, b) => a.label.localeCompare(b.label))

View File

@@ -5,7 +5,7 @@ import {
formatSystemDistance, formatSystemDistance,
inventoryAmount, inventoryAmount,
} from "./viewerMath"; } from "./viewerMath";
import { describeOrbitalParent, describeSelectable, describeShipCurrentAction, describeShipObjective, describeShipState, describeSpatialNodePathWithinSystem, getSelectionGroup, renderSystemDetails } from "./viewerSelection"; import { describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipCurrentAction, describeShipObjective, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
import type { import type {
CameraMode, CameraMode,
HistoryWindowState, HistoryWindowState,
@@ -20,7 +20,7 @@ import type {
interface DetailPanelParams { interface DetailPanelParams {
world: WorldState; world: WorldState;
selectedItems: Selectable[]; selectedItems: Selectable[];
zoomLevel: string; povLevel: string;
cameraMode: CameraMode; cameraMode: CameraMode;
cameraTargetShipId?: string; cameraTargetShipId?: string;
worldLabel: string; worldLabel: string;
@@ -156,7 +156,7 @@ export function updateDetailPanel(
const { const {
world, world,
selectedItems, selectedItems,
zoomLevel, povLevel,
cameraMode, cameraMode,
cameraTargetShipId, cameraTargetShipId,
worldLabel, worldLabel,
@@ -166,10 +166,9 @@ export function updateDetailPanel(
if (selectedItems.length === 0) { if (selectedItems.length === 0) {
detailTitleEl.textContent = worldLabel; detailTitleEl.textContent = worldLabel;
detailBodyEl.innerHTML = ` detailBodyEl.innerHTML = `
Zoom ${zoomLevel}<br> Zoom ${povLevel}<br>
Systems ${world.systems.size}<br> Systems ${world.systems.size}<br>
Spatial nodes ${world.spatialNodes.size}<br> Celestials ${world.celestials.size}<br>
Bubbles ${world.localBubbles.size}<br>
Stations ${world.stations.size}<br> Stations ${world.stations.size}<br>
Claims ${world.claims.size}<br> Claims ${world.claims.size}<br>
Construction ${world.constructionSites.size}<br> Construction ${world.constructionSites.size}<br>
@@ -294,34 +293,17 @@ export function updateDetailPanel(
return; return;
} }
if (selected.kind === "spatial-node") { if (selected.kind === "celestial") {
const node = world.spatialNodes.get(selected.id); const celestial = world.celestials.get(selected.id);
if (!node) { if (!celestial) {
return; return;
} }
const bubble = world.localBubbles.get(node.bubbleId); detailTitleEl.textContent = `${celestial.kind} celestial`;
detailTitleEl.textContent = `${node.kind} node`;
detailBodyEl.innerHTML = ` detailBodyEl.innerHTML = `
<p>${node.systemId}</p> <p>${celestial.systemId}</p>
<p>Bubble ${node.bubbleId}</p> <p>Parent ${celestial.parentNodeId ?? "none"}<br>Orbit ref ${celestial.orbitReferenceId ?? "none"}</p>
<p>Parent ${node.parentNodeId ?? "none"}<br>Orbit ref ${node.orbitReferenceId ?? "none"}</p> <p>Occupying structure ${celestial.occupyingStructureId ?? "none"}</p>
<p>Occupying structure ${node.occupyingStructureId ?? "none"}</p> <p>Local space radius ${celestial.localSpaceRadius.toFixed(0)} km</p>
<p>Bubble occupants ${bubble ? bubble.occupantShipIds.length + bubble.occupantStationIds.length : 0}</p>
`;
return;
}
if (selected.kind === "bubble") {
const bubble = world.localBubbles.get(selected.id);
if (!bubble) {
return;
}
detailTitleEl.textContent = `Bubble ${bubble.id}`;
detailBodyEl.innerHTML = `
<p>${bubble.systemId}</p>
<p>Anchor node ${bubble.nodeId}<br>Radius ${formatLocalDistance(bubble.radius)}</p>
<p>Ships ${bubble.occupantShipIds.length}<br>Stations ${bubble.occupantStationIds.length}</p>
<p>Claims ${bubble.occupantClaimIds.length}<br>Construction sites ${bubble.occupantConstructionSiteIds.length}</p>
`; `;
return; return;
} }
@@ -334,7 +316,7 @@ export function updateDetailPanel(
detailTitleEl.textContent = `Claim ${claim.id}`; detailTitleEl.textContent = `Claim ${claim.id}`;
detailBodyEl.innerHTML = ` detailBodyEl.innerHTML = `
<p>${claim.systemId}</p> <p>${claim.systemId}</p>
<p>Node ${claim.nodeId}<br>Bubble ${claim.bubbleId}</p> <p>Celestial ${claim.celestialId}</p>
<p>State ${claim.state}<br>Health ${claim.health.toFixed(0)}</p> <p>State ${claim.state}<br>Health ${claim.health.toFixed(0)}</p>
<p>Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}</p> <p>Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}</p>
`; `;
@@ -350,7 +332,7 @@ export function updateDetailPanel(
detailTitleEl.textContent = `Construction ${site.id}`; detailTitleEl.textContent = `Construction ${site.id}`;
detailBodyEl.innerHTML = ` detailBodyEl.innerHTML = `
<p>${site.systemId}</p> <p>${site.systemId}</p>
<p>Node ${site.nodeId}<br>Bubble ${site.bubbleId}</p> <p>Celestial ${site.celestialId}</p>
<p>${site.targetKind} ${site.targetDefinitionId}</p> <p>${site.targetKind} ${site.targetDefinitionId}</p>
<p>State ${site.state}<br>Progress ${(site.progress * 100).toFixed(0)}%</p> <p>State ${site.state}<br>Progress ${(site.progress * 100).toFixed(0)}%</p>
<p>Orders ${orderCount}<br>Assigned constructors ${site.assignedConstructorShipIds.length}</p> <p>Orders ${orderCount}<br>Assigned constructors ${site.assignedConstructorShipIds.length}</p>
@@ -445,8 +427,8 @@ export function describeSelectionParent(
return "unknown"; return "unknown";
} }
return station.anchorNodeId return station.celestialId
? describeSpatialNodePathWithinSystem(world, station.systemId, station.anchorNodeId) ?? `${station.systemId} network` ? describeCelestialPathWithinSystem(world, station.systemId, station.celestialId) ?? `${station.systemId} network`
: "unknown"; : "unknown";
} }
if (selection.kind === "node") { if (selection.kind === "node") {
@@ -454,18 +436,15 @@ export function describeSelectionParent(
const visual = node ? nodeVisuals.get(selection.id) : undefined; const visual = node ? nodeVisuals.get(selection.id) : undefined;
return describeOrbitalParent(world, node?.systemId, visual?.anchor); return describeOrbitalParent(world, node?.systemId, visual?.anchor);
} }
if (selection.kind === "spatial-node") { if (selection.kind === "celestial") {
const node = world.spatialNodes.get(selection.id); const celestial = world.celestials.get(selection.id);
return node?.parentNodeId ?? `${node?.systemId ?? "unknown"} network`; return celestial?.parentNodeId ?? `${celestial?.systemId ?? "unknown"} network`;
}
if (selection.kind === "bubble") {
return `${world.localBubbles.get(selection.id)?.nodeId ?? "unknown"} node`;
} }
if (selection.kind === "claim") { if (selection.kind === "claim") {
return world.claims.get(selection.id)?.nodeId ?? "unknown"; return world.claims.get(selection.id)?.celestialId ?? "unknown";
} }
if (selection.kind === "construction-site") { if (selection.kind === "construction-site") {
return world.constructionSites.get(selection.id)?.nodeId ?? "unknown"; return world.constructionSites.get(selection.id)?.celestialId ?? "unknown";
} }
return "unknown"; return "unknown";

View File

@@ -1,8 +1,7 @@
import * as THREE from "three"; import * as THREE from "three";
import { ACTIVE_SYSTEM_DETAIL_SCALE, PROJECTED_GALAXY_RADIUS } from "./viewerConstants"; import { ACTIVE_SYSTEM_DETAIL_SCALE, PROJECTED_GALAXY_RADIUS } from "./viewerConstants";
import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds, scaleLocalVector } from "./viewerMath"; import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds, scaleLocalVector } from "./viewerMath";
import { rawObject } from "./viewerScenePrimitives"; import type { PlanetVisual, ShipVisual, SystemVisual, WorldState } from "./viewerTypes";
import type { PlanetVisual, ShipVisual, SystemSummaryVisual, SystemVisual, WorldState } from "./viewerTypes";
export function getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) { export function getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) {
const elapsedMs = now - visual.receivedAtMs; const elapsedMs = now - visual.receivedAtMs;
@@ -26,23 +25,17 @@ export function resolveShipHeading(visual: ShipVisual, worldPosition: THREE.Vect
export function updatePlanetPresentation( export function updatePlanetPresentation(
world: WorldState | undefined, world: WorldState | undefined,
worldTimeSyncMs: number, worldTimeSyncMs: number,
activeSystemId: string | undefined,
systemFocusLocal: THREE.Vector3,
planetVisuals: PlanetVisual[], planetVisuals: PlanetVisual[],
) { ) {
const nowSeconds = currentWorldTimeSeconds(world, worldTimeSyncMs); const nowSeconds = currentWorldTimeSeconds(world, worldTimeSyncMs);
// In systemScene all positions use scaleLocalVector * ACTIVE_SYSTEM_DETAIL_SCALE.
// Star is always at origin (0,0,0); orbits are centered there.
for (const visual of planetVisuals) { for (const visual of planetVisuals) {
const scale = visual.systemId === activeSystemId ? ACTIVE_SYSTEM_DETAIL_SCALE : 1; const position = scaleLocalVector(computePlanetLocalPosition(visual.planet, nowSeconds))
const localPosition = scaleLocalVector(computePlanetLocalPosition(visual.planet, nowSeconds)); .multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE);
const orbitOffset = visual.systemId === activeSystemId
? systemFocusLocal.clone().multiplyScalar(-scale)
: new THREE.Vector3();
const position = visual.systemId === activeSystemId
? localPosition.clone().sub(systemFocusLocal).multiplyScalar(scale)
: localPosition.multiplyScalar(scale);
visual.orbit.setScaleScalar(scale); visual.orbit.setScaleScalar(ACTIVE_SYSTEM_DETAIL_SCALE);
visual.orbit.setPosition(orbitOffset); visual.orbit.setPosition(new THREE.Vector3(0, 0, 0));
visual.mesh.setPosition(position); visual.mesh.setPosition(position);
visual.icon.setPosition(position); visual.icon.setPosition(position);
if (visual.ring) { if (visual.ring) {
@@ -51,56 +44,45 @@ export function updatePlanetPresentation(
for (const [moonIndex, moon] of visual.moons.entries()) { for (const [moonIndex, moon] of visual.moons.entries()) {
moon.orbit.setPosition(position); moon.orbit.setPosition(position);
moon.orbit.setScaleScalar(scale); moon.orbit.setScaleScalar(ACTIVE_SYSTEM_DETAIL_SCALE);
moon.mesh.setPosition( moon.mesh.setPosition(
position.clone().add( position.clone().add(
scaleLocalVector(computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds, world?.seed ?? 1)).multiplyScalar(scale), scaleLocalVector(computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds, world?.seed ?? 1))
.multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE),
), ),
); );
} }
} }
} }
export function updateSystemSummaryPresentation(
systemSummaryVisuals: Map<string, SystemSummaryVisual>,
camera: THREE.PerspectiveCamera,
activeSystemId?: string,
) {
const distanceScale = activeSystemId ? 0.05 : 0.085;
for (const [systemId, visual] of systemSummaryVisuals.entries()) {
const worldPosition = visual.sprite.getWorldPosition(new THREE.Vector3());
const distance = camera.position.distanceTo(worldPosition);
const minimumScale = activeSystemId && systemId !== activeSystemId ? 1200 : 1400;
const scale = Math.max(minimumScale, distance * distanceScale);
rawObject(visual.sprite).scale.set(scale, scale * 0.3125, 1);
}
}
export function updateSystemStarPresentation( export function updateSystemStarPresentation(
systemVisuals: Map<string, SystemVisual>, systemVisuals: Map<string, SystemVisual>,
activeSystemId: string | undefined, activeSystemId: string | undefined,
systemFocusLocal: THREE.Vector3, galaxyCamera: THREE.PerspectiveCamera,
camera: THREE.PerspectiveCamera,
setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], opacity: number) => void, setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], opacity: number) => void,
) { ) {
const activeSystem = activeSystemId ? systemVisuals.get(activeSystemId) : undefined; const activeSystem = activeSystemId ? systemVisuals.get(activeSystemId) : undefined;
for (const [systemId, visual] of systemVisuals.entries()) { for (const [systemId, visual] of systemVisuals.entries()) {
visual.root.setPosition(visual.galaxyPosition); // galaxyRoot is always at the galaxy position of this system
visual.galaxyRoot.setPosition(visual.galaxyPosition);
visual.shellReticle.setScaleScalar(visual.shellReticleBaseScale); visual.shellReticle.setScaleScalar(visual.shellReticleBaseScale);
if (!activeSystem) { if (!activeSystem) {
visual.starCluster.setPosition(new THREE.Vector3(0, 0, 0)); // Galaxy view: show star dot, hide shell reticle
visual.icon.setPosition(new THREE.Vector3(0, 0, 0)); visual.icon.setPosition(new THREE.Vector3(0, 0, 0));
visual.icon.setVisible(true); visual.icon.setVisible(true);
visual.shellReticle.setPosition(new THREE.Vector3(0, 0, 0)); visual.shellReticle.setPosition(new THREE.Vector3(0, 0, 0));
visual.shellReticle.setVisible(false); visual.shellReticle.setVisible(false);
setShellReticleOpacity(visual.shellReticle, 0); setShellReticleOpacity(visual.shellReticle, 0);
const dotWorldPos = visual.icon.getWorldPosition(new THREE.Vector3());
visual.icon.setScaleScalar(galaxyCamera.position.distanceTo(dotWorldPos) * 0.01);
continue; continue;
} }
if (systemId !== activeSystemId) { if (systemId !== activeSystemId) {
visual.starCluster.setPosition(new THREE.Vector3(0, 0, 0)); // Other systems in galaxy view while a system is active: show shell reticle projected to edge
visual.icon.setPosition(new THREE.Vector3(0, 0, 0)); visual.icon.setPosition(new THREE.Vector3(0, 0, 0));
visual.icon.setVisible(false); visual.icon.setVisible(false);
visual.shellReticle.setPosition(new THREE.Vector3(0, 0, 0)); visual.shellReticle.setPosition(new THREE.Vector3(0, 0, 0));
@@ -108,20 +90,19 @@ export function updateSystemStarPresentation(
setShellReticleOpacity(visual.shellReticle, 1); setShellReticleOpacity(visual.shellReticle, 1);
const direction = visual.galaxyPosition.clone().sub(activeSystem.galaxyPosition); const direction = visual.galaxyPosition.clone().sub(activeSystem.galaxyPosition);
if (direction.lengthSq() > 0.0001) { if (direction.lengthSq() > 0.0001) {
visual.root.setPosition( visual.galaxyRoot.setPosition(
activeSystem.galaxyPosition.clone().add(direction.normalize().multiplyScalar(PROJECTED_GALAXY_RADIUS)), activeSystem.galaxyPosition.clone().add(direction.normalize().multiplyScalar(PROJECTED_GALAXY_RADIUS)),
); );
} }
const reticleWorldPosition = visual.root.getWorldPosition(new THREE.Vector3()); const reticleWorldPosition = visual.galaxyRoot.getWorldPosition(new THREE.Vector3());
const reticleDistance = camera.position.distanceTo(reticleWorldPosition); const reticleDistance = galaxyCamera.position.distanceTo(reticleWorldPosition);
const reticleScale = Math.max(900, reticleDistance * 0.032); const reticleScale = Math.max(900, reticleDistance * 0.032);
visual.shellReticle.setScaleScalar(reticleScale); visual.shellReticle.setScaleScalar(reticleScale);
continue; continue;
} }
const offset = systemFocusLocal.clone().multiplyScalar(-ACTIVE_SYSTEM_DETAIL_SCALE); // Active system in galaxy view: show star dot, hide shell reticle
visual.starCluster.setPosition(offset); visual.icon.setPosition(new THREE.Vector3(0, 0, 0));
visual.icon.setPosition(offset);
visual.icon.setVisible(true); visual.icon.setVisible(true);
visual.shellReticle.setVisible(false); visual.shellReticle.setVisible(false);
setShellReticleOpacity(visual.shellReticle, 0); setShellReticleOpacity(visual.shellReticle, 0);

View File

@@ -14,8 +14,11 @@ import type { OrbitLineVisual, Selectable } from "./viewerTypes";
export interface ViewerPresentationContext { export interface ViewerPresentationContext {
renderer: THREE.WebGLRenderer; renderer: THREE.WebGLRenderer;
scene: THREE.Scene; galaxyScene: THREE.Scene;
camera: THREE.PerspectiveCamera; galaxyCamera: THREE.PerspectiveCamera;
systemCamera: THREE.PerspectiveCamera;
galaxyAnchor: THREE.Vector3;
systemAnchor: THREE.Vector3;
ambienceGroup: THREE.Group; ambienceGroup: THREE.Group;
gameSummaryEl: HTMLSpanElement; gameSummaryEl: HTMLSpanElement;
networkSummaryEl: HTMLSpanElement; networkSummaryEl: HTMLSpanElement;
@@ -32,14 +35,11 @@ export interface ViewerPresentationContext {
getActiveSystemId: () => string | undefined; getActiveSystemId: () => string | undefined;
getCameraMode: () => any; getCameraMode: () => any;
getCameraTargetShipId: () => string | undefined; getCameraTargetShipId: () => string | undefined;
getZoomLevel: () => any; getPovLevel: () => any;
getSelectedItems: () => Selectable[]; getSelectedItems: () => Selectable[];
getWorldTimeSyncMs: () => number; getWorldTimeSyncMs: () => number;
getCurrentDistance: () => number; getCurrentDistance: () => number;
systemFocusLocal: THREE.Vector3;
planetVisuals: any[]; planetVisuals: any[];
systemSummaryVisuals: Map<any, any>;
presentationEntries: any[];
orbitLines: OrbitLineVisual[]; orbitLines: OrbitLineVisual[];
systemVisuals: Map<any, any>; systemVisuals: Map<any, any>;
createWorldPresentationContext: () => any; createWorldPresentationContext: () => any;
@@ -55,43 +55,25 @@ export class ViewerPresentationController {
} }
updateAmbience(delta: number) { updateAmbience(delta: number) {
this.context.ambienceGroup.position.copy(this.context.camera.position); const activeCamera = this.context.getPovLevel() === "galaxy"
? this.context.galaxyCamera
: this.context.systemCamera;
this.context.ambienceGroup.position.copy(activeCamera.position);
this.context.ambienceGroup.rotation.y += delta * 0.005; this.context.ambienceGroup.rotation.y += delta * 0.005;
this.context.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015; this.context.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015;
} }
applyZoomPresentation() { applyZoomPresentation() {
const activeSystemId = this.context.getActiveSystemId(); const activeSystemId = this.context.getActiveSystemId();
const zoomLevel = this.context.getZoomLevel(); const povLevel = this.context.getPovLevel();
const isUniverse = zoomLevel === "universe";
for (const entry of this.context.presentationEntries) {
const systemId = entry.systemId;
const isActiveDetail = !systemId || systemId === activeSystemId;
const detailAlpha = entry.hideDetailInUniverse
? (!isUniverse && isActiveDetail ? 1 : 0)
: 1;
const iconAlpha = entry.hideIconInUniverse
? (isUniverse ? 1 : 0)
: (isUniverse ? 1 : 0);
entry.detail.setOpacity(detailAlpha);
entry.icon.setOpacity(iconAlpha);
}
// Orbit lines: only show for active system in system/local zoom
for (const orbitLine of this.context.orbitLines) { for (const orbitLine of this.context.orbitLines) {
const alpha = this.resolveOrbitLineOpacity(orbitLine, zoomLevel, activeSystemId); const alpha = this.resolveOrbitLineOpacity(orbitLine, povLevel, activeSystemId);
orbitLine.line.setOpacity(alpha); orbitLine.line.setOpacity(alpha);
} }
for (const [systemId, summaryVisual] of this.context.systemSummaryVisuals.entries()) { this.context.galaxyScene.fog = new THREE.FogExp2(0x040912, 0.000035);
const summaryOpacity = isUniverse
? 0.96
: 0;
summaryVisual.sprite.setOpacity(summaryOpacity);
}
this.context.scene.fog = new THREE.FogExp2(0x040912, 0.000035);
} }
updateNetworkPanel() { updateNetworkPanel() {
@@ -117,14 +99,12 @@ export class ViewerPresentationController {
updatePlanetPresentation( updatePlanetPresentation(
world, world,
this.context.getWorldTimeSyncMs(), this.context.getWorldTimeSyncMs(),
this.context.getActiveSystemId(),
this.context.systemFocusLocal,
this.context.planetVisuals, this.context.planetVisuals,
); );
} }
updateSystemSummaries() { updateSystemSummaries() {
updateSystemSummaries(this.context.getWorld(), this.context.systemSummaryVisuals); updateSystemSummaries(this.context.getWorld(), new Map());
} }
renderRecentEvents(entityKind: string, entityId: string) { renderRecentEvents(entityKind: string, entityId: string) {
@@ -138,9 +118,11 @@ export class ViewerPresentationController {
world: this.context.getWorld(), world: this.context.getWorld(),
activeSystemId: this.context.getActiveSystemId(), activeSystemId: this.context.getActiveSystemId(),
cameraMode: this.context.getCameraMode(), cameraMode: this.context.getCameraMode(),
zoomLevel: this.context.getZoomLevel(), povLevel: this.context.getPovLevel(),
selectedItems: this.context.getSelectedItems(), selectedItems: this.context.getSelectedItems(),
mode, mode,
galaxyAnchor: this.context.galaxyAnchor,
systemAnchor: this.context.systemAnchor,
}); });
} }
@@ -166,14 +148,14 @@ export class ViewerPresentationController {
return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top); return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top);
} }
private resolveOrbitLineOpacity(orbitLine: OrbitLineVisual, zoomLevel: "local" | "system" | "universe", activeSystemId?: string) { private resolveOrbitLineOpacity(orbitLine: OrbitLineVisual, povLevel: "local" | "system" | "galaxy", activeSystemId?: string) {
if (zoomLevel === "universe" || !activeSystemId || orbitLine.systemId !== activeSystemId) { if (povLevel === "galaxy" || !activeSystemId || orbitLine.systemId !== activeSystemId) {
return 0; return 0;
} }
const selected = this.context.getSelectedItems(); const selected = this.context.getSelectedItems();
const selectedItem = selected.length === 1 ? selected[0] : undefined; const selectedItem = selected.length === 1 ? selected[0] : undefined;
const baseAlpha = zoomLevel === "local" ? 0.55 : 0.9; const baseAlpha = povLevel === "local" ? 0.55 : 0.9;
if (selectedItem?.kind === "planet" && selectedItem.systemId === activeSystemId) { if (selectedItem?.kind === "planet" && selectedItem.systemId === activeSystemId) {
return orbitLine.kind === "moon" && orbitLine.planetIndex === selectedItem.planetIndex return orbitLine.kind === "moon" && orbitLine.planetIndex === selectedItem.planetIndex

View File

@@ -1,12 +1,18 @@
import * as THREE from "three"; import * as THREE from "three";
import { classifyZoomLevel } from "./viewerMath"; import { classifyPovLevel } from "./viewerMath";
import type { PerformanceStats } from "./viewerTypes"; import type { PovLevel, PerformanceStats } from "./viewerTypes";
export interface RenderFrameParams { export interface RenderFrameParams {
clock: THREE.Clock; clock: THREE.Clock;
renderer: THREE.WebGLRenderer; renderer: THREE.WebGLRenderer;
scene: THREE.Scene; universeScene: THREE.Scene;
camera: THREE.PerspectiveCamera; galaxyScene: THREE.Scene;
galaxyCamera: THREE.PerspectiveCamera;
systemScene: THREE.Scene;
systemCamera: THREE.PerspectiveCamera;
localScene: THREE.Scene;
localCamera: THREE.PerspectiveCamera;
getPovLevel: () => PovLevel;
updateCamera: (delta: number) => void; updateCamera: (delta: number) => void;
updateAmbience: (delta: number) => void; updateAmbience: (delta: number) => void;
updatePlanetPresentation: () => void; updatePlanetPresentation: () => void;
@@ -19,7 +25,9 @@ export interface RenderFrameParams {
export interface ResizeParams { export interface ResizeParams {
renderer: THREE.WebGLRenderer; renderer: THREE.WebGLRenderer;
camera: THREE.PerspectiveCamera; galaxyCamera: THREE.PerspectiveCamera;
systemCamera: THREE.PerspectiveCamera;
localCamera: THREE.PerspectiveCamera;
} }
export interface CameraStepParams { export interface CameraStepParams {
@@ -38,7 +46,26 @@ export function renderFrame(params: RenderFrameParams) {
params.updateShipPresentation(); params.updateShipPresentation();
params.updateNetworkPanel(); params.updateNetworkPanel();
params.applyZoomPresentation(); params.applyZoomPresentation();
params.renderer.render(params.scene, params.camera);
const povLevel = params.getPovLevel();
const activeCamera = povLevel === "galaxy" ? params.galaxyCamera : params.systemCamera;
params.renderer.autoClear = false;
params.renderer.clear();
// Universe backdrop — always first, rendered with the active camera so it aligns with the foreground
params.renderer.render(params.universeScene, activeCamera);
params.renderer.clearDepth();
if (povLevel === "galaxy") {
// Galaxy map on top of universe backdrop
params.renderer.render(params.galaxyScene, params.galaxyCamera);
} else if (povLevel === "system") {
params.renderer.render(params.systemScene, params.systemCamera);
} else {
// local: system as mid-ground backdrop, then local on top
params.renderer.render(params.systemScene, params.systemCamera);
params.renderer.clearDepth();
params.renderer.render(params.localScene, params.localCamera);
}
params.recordPerformanceStats(performance.now() - frameStartedAtMs); params.recordPerformanceStats(performance.now() - frameStartedAtMs);
params.updatePerformancePanel(); params.updatePerformancePanel();
} }
@@ -46,14 +73,16 @@ export function renderFrame(params: RenderFrameParams) {
export function resizeViewer(params: ResizeParams) { export function resizeViewer(params: ResizeParams) {
const width = window.innerWidth; const width = window.innerWidth;
const height = window.innerHeight; const height = window.innerHeight;
params.camera.aspect = width / height; for (const camera of [params.galaxyCamera, params.systemCamera, params.localCamera]) {
params.camera.updateProjectionMatrix(); camera.aspect = width / height;
camera.updateProjectionMatrix();
}
params.renderer.setSize(width, height); params.renderer.setSize(width, height);
} }
export function stepCamera(params: CameraStepParams) { export function stepCamera(params: CameraStepParams) {
const currentDistance = THREE.MathUtils.damp(params.currentDistance, params.desiredDistance, 7.5, params.delta); const currentDistance = THREE.MathUtils.damp(params.currentDistance, params.desiredDistance, 7.5, params.delta);
const zoomLevel = classifyZoomLevel(currentDistance); const povLevel = classifyPovLevel(currentDistance);
const orbitPitch = THREE.MathUtils.clamp(params.orbitPitch, 0.18, 1.3); const orbitPitch = THREE.MathUtils.clamp(params.orbitPitch, 0.18, 1.3);
return { currentDistance, zoomLevel, orbitPitch }; return { currentDistance, povLevel, orbitPitch };
} }

View File

@@ -43,7 +43,7 @@ export function shipPresentationColor(ship: ShipSnapshot) {
return shipColor(ship.kind); return shipColor(ship.kind);
} }
export function spatialNodeColor(kind: string) { export function celestialColor(kind: string) {
if (kind.includes("lagrange")) { if (kind.includes("lagrange")) {
return "#7fe8ff"; return "#7fe8ff";
} }

View File

@@ -1,55 +1,49 @@
import * as THREE from "three"; import * as THREE from "three";
import { import {
applyCelestialDeltas as applyCelestialDeltaUpdates,
applyClaimDeltas as applyClaimDeltaUpdates, applyClaimDeltas as applyClaimDeltaUpdates,
applyConstructionSiteDeltas as applyConstructionSiteDeltaUpdates, applyConstructionSiteDeltas as applyConstructionSiteDeltaUpdates,
applyLocalBubbleDeltas as applyLocalBubbleDeltaUpdates,
applyNodeDeltas as applyNodeDeltaUpdates, applyNodeDeltas as applyNodeDeltaUpdates,
applyShipDeltas as applyShipDeltaUpdates, applyShipDeltas as applyShipDeltaUpdates,
applySpatialNodeDeltas as applySpatialNodeDeltaUpdates,
applyStationDeltas as applyStationDeltaUpdates, applyStationDeltas as applyStationDeltaUpdates,
rebuildSystems as rebuildSystemScene, rebuildSystems as rebuildSystemScene,
syncCelestials as syncCelestialScene,
syncClaims as syncClaimScene, syncClaims as syncClaimScene,
syncConstructionSites as syncConstructionSiteScene, syncConstructionSites as syncConstructionSiteScene,
syncLocalBubbles as syncBubbleScene,
syncNodes as syncNodeScene, syncNodes as syncNodeScene,
syncShips as syncShipScene, syncShips as syncShipScene,
syncSpatialNodes as syncSpatialNodeScene,
syncStations as syncStationScene, syncStations as syncStationScene,
} from "./viewerSceneSync"; } from "./viewerSceneSync";
import { import {
deriveNodeOrbital, deriveNodeOrbital,
deriveOrbitalFromLocalPosition, deriveOrbitalFromLocalPosition,
resolveBubblePosition,
resolveOrbitalAnchor, resolveOrbitalAnchor,
resolvePointPosition, resolvePointPosition,
setBubbleVisualState,
} from "./viewerWorldPresentation"; } from "./viewerWorldPresentation";
import { import {
createCirclePoints, createCirclePoints,
shipLength, shipLength,
shipPresentationColor, shipPresentationColor,
shipSize, shipSize,
spatialNodeColor, celestialColor,
} from "./viewerSceneAppearance"; } from "./viewerSceneAppearance";
import type { import type {
CelestialDelta,
CelestialSnapshot,
ClaimDelta, ClaimDelta,
ClaimSnapshot, ClaimSnapshot,
ConstructionSiteDelta, ConstructionSiteDelta,
ConstructionSiteSnapshot, ConstructionSiteSnapshot,
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta, ResourceNodeDelta,
ResourceNodeSnapshot, ResourceNodeSnapshot,
ShipDelta, ShipDelta,
ShipSnapshot, ShipSnapshot,
SpatialNodeDelta,
SpatialNodeSnapshot,
StationDelta, StationDelta,
StationSnapshot, StationSnapshot,
SystemSnapshot, SystemSnapshot,
} from "./contracts"; } from "./contracts";
import type { OrbitLineVisual, OrbitalAnchor } from "./viewerTypes"; import type { OrbitLineVisual, OrbitalAnchor, Selectable } from "./viewerTypes";
import type { SceneNode } from "./viewerScenePrimitives"; import { rawObject } from "./viewerScenePrimitives";
export interface ViewerSceneDataContext { export interface ViewerSceneDataContext {
documentRef: Document; documentRef: Document;
@@ -58,102 +52,162 @@ export interface ViewerSceneDataContext {
getWorldSeed: () => number; getWorldSeed: () => number;
getWorldTimeSyncMs: () => number; getWorldTimeSyncMs: () => number;
getWorldPresentationContext: () => any; getWorldPresentationContext: () => any;
systemGroup: THREE.Group; getActiveSystemId: () => string | undefined;
spatialNodeGroup: THREE.Group; galaxySystemGroup: THREE.Group;
bubbleGroup: THREE.Group; systemScene: THREE.Scene;
celestialGroup: THREE.Group;
nodeGroup: THREE.Group; nodeGroup: THREE.Group;
stationGroup: THREE.Group; stationGroup: THREE.Group;
claimGroup: THREE.Group; claimGroup: THREE.Group;
constructionSiteGroup: THREE.Group; constructionSiteGroup: THREE.Group;
shipGroup: THREE.Group; shipGroup: THREE.Group;
selectableTargets: Map<any, any>; galaxySelectableTargets: Map<THREE.Object3D, Selectable>;
presentationEntries: any[]; systemSelectableTargets: Map<THREE.Object3D, Selectable>;
systemVisuals: Map<any, any>; systemVisuals: Map<any, any>;
systemSummaryVisuals: Map<any, any>;
planetVisuals: any[]; planetVisuals: any[];
orbitLines: OrbitLineVisual[]; orbitLines: OrbitLineVisual[];
spatialNodeVisuals: Map<any, any>; celestialVisuals: Map<any, any>;
bubbleVisuals: Map<any, any>;
nodeVisuals: Map<any, any>; nodeVisuals: Map<any, any>;
stationVisuals: Map<any, any>; stationVisuals: Map<any, any>;
claimVisuals: Map<any, any>; claimVisuals: Map<any, any>;
constructionSiteVisuals: Map<any, any>; constructionSiteVisuals: Map<any, any>;
shipVisuals: Map<any, any>; shipVisuals: Map<any, any>;
registerPresentation: (detail: SceneNode, icon: SceneNode, hideDetailInUniverse: boolean, hideIconInUniverse?: boolean, systemId?: string) => void;
} }
export class ViewerSceneDataController { export class ViewerSceneDataController {
private activeSystemRootInScene: THREE.Object3D | undefined;
constructor(private readonly context: ViewerSceneDataContext) {} constructor(private readonly context: ViewerSceneDataContext) {}
rebuildSystems(systems: SystemSnapshot[]) { rebuildSystems(systems: SystemSnapshot[]) {
this.activeSystemRootInScene = undefined;
rebuildSystemScene(this.createSceneSyncContext(), systems); rebuildSystemScene(this.createSceneSyncContext(), systems);
// Re-activate the current active system if any
const activeId = this.context.getActiveSystemId();
if (activeId) {
this.activateSystemRoot(activeId);
}
} }
syncSpatialNodes(nodes: SpatialNodeSnapshot[]) { syncCelestials(celestials: CelestialSnapshot[]) {
syncSpatialNodeScene(this.createSceneSyncContext(), nodes); syncCelestialScene(this.createSceneSyncContext(), celestials, this.context.getActiveSystemId());
}
syncLocalBubbles(bubbles: LocalBubbleSnapshot[]) {
syncBubbleScene(this.createSceneSyncContext(), bubbles);
} }
syncNodes(nodes: ResourceNodeSnapshot[]) { syncNodes(nodes: ResourceNodeSnapshot[]) {
syncNodeScene(this.createSceneSyncContext(), nodes); syncNodeScene(this.createSceneSyncContext(), nodes, this.context.getActiveSystemId());
} }
syncStations(stations: StationSnapshot[]) { syncStations(stations: StationSnapshot[]) {
syncStationScene(this.createSceneSyncContext(), stations); syncStationScene(this.createSceneSyncContext(), stations, this.context.getActiveSystemId());
} }
syncClaims(claims: ClaimSnapshot[]) { syncClaims(claims: ClaimSnapshot[]) {
syncClaimScene(this.createSceneSyncContext(), claims); syncClaimScene(this.createSceneSyncContext(), claims, this.context.getActiveSystemId());
} }
syncConstructionSites(sites: ConstructionSiteSnapshot[]) { syncConstructionSites(sites: ConstructionSiteSnapshot[]) {
syncConstructionSiteScene(this.createSceneSyncContext(), sites); syncConstructionSiteScene(this.createSceneSyncContext(), sites, this.context.getActiveSystemId());
} }
syncShips(ships: ShipSnapshot[], tickIntervalMs: number) { syncShips(ships: ShipSnapshot[], tickIntervalMs: number) {
syncShipScene(this.createSceneSyncContext(), ships, tickIntervalMs); syncShipScene(this.createSceneSyncContext(), ships, tickIntervalMs, this.context.getActiveSystemId());
} }
applySpatialNodeDeltas(nodes: SpatialNodeDelta[]) { applyCelestialDeltas(celestials: CelestialDelta[]) {
applySpatialNodeDeltaUpdates(this.createSceneSyncContext(), nodes); applyCelestialDeltaUpdates(this.createSceneSyncContext(), celestials, this.context.getActiveSystemId());
}
applyLocalBubbleDeltas(bubbles: LocalBubbleDelta[]) {
applyLocalBubbleDeltaUpdates(this.createSceneSyncContext(), bubbles);
} }
applyNodeDeltas(nodes: ResourceNodeDelta[]) { applyNodeDeltas(nodes: ResourceNodeDelta[]) {
applyNodeDeltaUpdates(this.createSceneSyncContext(), nodes); applyNodeDeltaUpdates(this.createSceneSyncContext(), nodes, this.context.getActiveSystemId());
} }
applyStationDeltas(stations: StationDelta[]) { applyStationDeltas(stations: StationDelta[]) {
applyStationDeltaUpdates(this.createSceneSyncContext(), stations); applyStationDeltaUpdates(this.createSceneSyncContext(), stations, this.context.getActiveSystemId());
} }
applyClaimDeltas(claims: ClaimDelta[]) { applyClaimDeltas(claims: ClaimDelta[]) {
applyClaimDeltaUpdates(this.createSceneSyncContext(), claims); applyClaimDeltaUpdates(this.createSceneSyncContext(), claims, this.context.getActiveSystemId());
} }
applyConstructionSiteDeltas(sites: ConstructionSiteDelta[]) { applyConstructionSiteDeltas(sites: ConstructionSiteDelta[]) {
applyConstructionSiteDeltaUpdates(this.createSceneSyncContext(), sites); applyConstructionSiteDeltaUpdates(this.createSceneSyncContext(), sites, this.context.getActiveSystemId());
} }
applyShipDeltas(ships: ShipDelta[], tickIntervalMs: number) { applyShipDeltas(ships: ShipDelta[], tickIntervalMs: number) {
applyShipDeltaUpdates(this.createSceneSyncContext(), ships, tickIntervalMs); applyShipDeltaUpdates(this.createSceneSyncContext(), ships, tickIntervalMs, this.context.getActiveSystemId());
}
/**
* Called when the active system changes. Swaps which system's root is in systemScene
* and updates visibility of all system-filtered objects.
*/
onActiveSystemChanged(oldSystemId: string | undefined, newSystemId: string | undefined) {
// Remove old system's root from systemScene
if (this.activeSystemRootInScene) {
this.context.systemScene.remove(this.activeSystemRootInScene);
this.activeSystemRootInScene = undefined;
}
// Add new system's root to systemScene
if (newSystemId) {
this.activateSystemRoot(newSystemId);
}
// Update visibility of all system-filtered objects
this.updateSystemObjectVisibility(newSystemId);
}
private activateSystemRoot(systemId: string) {
const visual = this.context.systemVisuals.get(systemId);
if (!visual) {
return;
}
const threeObj = rawObject(visual.systemRoot);
this.context.systemScene.add(threeObj);
this.activeSystemRootInScene = threeObj;
}
private updateSystemObjectVisibility(activeSystemId: string | undefined) {
for (const visual of this.context.celestialVisuals.values()) {
const isActive = visual.systemId === activeSystemId;
visual.mesh.setVisible(isActive);
visual.icon.setVisible(isActive);
}
for (const visual of this.context.nodeVisuals.values()) {
const isActive = visual.systemId === activeSystemId;
visual.mesh.setVisible(isActive);
visual.icon.setVisible(isActive);
}
for (const visual of this.context.stationVisuals.values()) {
const isActive = visual.systemId === activeSystemId;
visual.mesh.setVisible(isActive);
visual.icon.setVisible(isActive);
}
for (const visual of this.context.claimVisuals.values()) {
const isActive = visual.systemId === activeSystemId;
visual.mesh.setVisible(isActive);
visual.icon.setVisible(isActive);
}
for (const visual of this.context.constructionSiteVisuals.values()) {
const isActive = visual.systemId === activeSystemId;
visual.mesh.setVisible(isActive);
visual.icon.setVisible(isActive);
}
for (const visual of this.context.shipVisuals.values()) {
const isActive = visual.systemId === activeSystemId;
visual.mesh.setVisible(isActive);
visual.icon.setVisible(isActive);
}
} }
createWorldPresentationContext(overrides: { createWorldPresentationContext(overrides: {
world: any; world: any;
activeSystemId?: string; activeSystemId?: string;
zoomLevel: any; povLevel: any;
orbitYaw: number; orbitYaw: number;
camera: THREE.PerspectiveCamera; systemCamera: THREE.PerspectiveCamera;
systemFocusLocal: THREE.Vector3; systemAnchor: THREE.Vector3;
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3; toDisplayLocalPosition: (localPosition: THREE.Vector3) => THREE.Vector3;
updateSystemDetailVisibility: () => void;
setShellReticleOpacity: (sprite: any, opacity: number) => void; setShellReticleOpacity: (sprite: any, opacity: number) => void;
}) { }) {
return { return {
@@ -161,21 +215,20 @@ export class ViewerSceneDataController {
worldTimeSyncMs: this.context.getWorldTimeSyncMs(), worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
worldSeed: this.context.getWorldSeed(), worldSeed: this.context.getWorldSeed(),
activeSystemId: overrides.activeSystemId, activeSystemId: overrides.activeSystemId,
zoomLevel: overrides.zoomLevel, povLevel: overrides.povLevel,
orbitYaw: overrides.orbitYaw, orbitYaw: overrides.orbitYaw,
camera: overrides.camera, camera: overrides.systemCamera,
systemFocusLocal: overrides.systemFocusLocal, systemAnchor: overrides.systemAnchor,
shipVisuals: this.context.shipVisuals, shipVisuals: this.context.shipVisuals,
nodeVisuals: this.context.nodeVisuals, nodeVisuals: this.context.nodeVisuals,
spatialNodeVisuals: this.context.spatialNodeVisuals, celestialVisuals: this.context.celestialVisuals,
bubbleVisuals: this.context.bubbleVisuals,
stationVisuals: this.context.stationVisuals, stationVisuals: this.context.stationVisuals,
claimVisuals: this.context.claimVisuals, claimVisuals: this.context.claimVisuals,
constructionSiteVisuals: this.context.constructionSiteVisuals, constructionSiteVisuals: this.context.constructionSiteVisuals,
systemVisuals: this.context.systemVisuals, systemVisuals: this.context.systemVisuals,
systemSummaryVisuals: this.context.systemSummaryVisuals, systemSummaryVisuals: new Map(),
toDisplayLocalPosition: overrides.toDisplayLocalPosition, toDisplayLocalPosition: overrides.toDisplayLocalPosition,
updateSystemDetailVisibility: overrides.updateSystemDetailVisibility, updateSystemDetailVisibility: () => {},
setShellReticleOpacity: overrides.setShellReticleOpacity, setShellReticleOpacity: overrides.setShellReticleOpacity,
}; };
} }
@@ -187,39 +240,33 @@ export class ViewerSceneDataController {
orbitalSimulationSpeed: this.context.getOrbitalSimulationSpeed(), orbitalSimulationSpeed: this.context.getOrbitalSimulationSpeed(),
worldSeed: this.context.getWorldSeed(), worldSeed: this.context.getWorldSeed(),
worldTimeSyncMs: this.context.getWorldTimeSyncMs(), worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
systemGroup: this.context.systemGroup, galaxySystemGroup: this.context.galaxySystemGroup,
spatialNodeGroup: this.context.spatialNodeGroup, celestialGroup: this.context.celestialGroup,
bubbleGroup: this.context.bubbleGroup,
nodeGroup: this.context.nodeGroup, nodeGroup: this.context.nodeGroup,
stationGroup: this.context.stationGroup, stationGroup: this.context.stationGroup,
claimGroup: this.context.claimGroup, claimGroup: this.context.claimGroup,
constructionSiteGroup: this.context.constructionSiteGroup, constructionSiteGroup: this.context.constructionSiteGroup,
shipGroup: this.context.shipGroup, shipGroup: this.context.shipGroup,
selectableTargets: this.context.selectableTargets, galaxySelectableTargets: this.context.galaxySelectableTargets,
presentationEntries: this.context.presentationEntries, systemSelectableTargets: this.context.systemSelectableTargets,
systemVisuals: this.context.systemVisuals, systemVisuals: this.context.systemVisuals,
systemSummaryVisuals: this.context.systemSummaryVisuals,
planetVisuals: this.context.planetVisuals, planetVisuals: this.context.planetVisuals,
orbitLines: this.context.orbitLines, orbitLines: this.context.orbitLines,
spatialNodeVisuals: this.context.spatialNodeVisuals, celestialVisuals: this.context.celestialVisuals,
bubbleVisuals: this.context.bubbleVisuals,
nodeVisuals: this.context.nodeVisuals, nodeVisuals: this.context.nodeVisuals,
stationVisuals: this.context.stationVisuals, stationVisuals: this.context.stationVisuals,
claimVisuals: this.context.claimVisuals, claimVisuals: this.context.claimVisuals,
constructionSiteVisuals: this.context.constructionSiteVisuals, constructionSiteVisuals: this.context.constructionSiteVisuals,
shipVisuals: this.context.shipVisuals, shipVisuals: this.context.shipVisuals,
registerPresentation: this.context.registerPresentation,
shipSize, shipSize,
shipLength, shipLength,
shipPresentationColor, shipPresentationColor,
spatialNodeColor, celestialColor,
createCirclePoints, createCirclePoints,
resolveBubblePosition: (bubble: LocalBubbleSnapshot | LocalBubbleDelta) => resolveBubblePosition(this.context.getWorldPresentationContext(), bubble), resolvePointPosition: (systemId: string, celestialId?: string | null) => resolvePointPosition(this.context.getWorldPresentationContext(), systemId, celestialId),
resolvePointPosition: (systemId: string, nodeId?: string | null) => resolvePointPosition(this.context.getWorldPresentationContext(), systemId, nodeId),
resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => resolveOrbitalAnchor(this.context.getWorldPresentationContext(), systemId, localPosition), resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => resolveOrbitalAnchor(this.context.getWorldPresentationContext(), systemId, localPosition),
deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: OrbitalAnchor) => deriveNodeOrbital(this.context.getWorldPresentationContext(), node, anchor), deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: OrbitalAnchor) => deriveNodeOrbital(this.context.getWorldPresentationContext(), node, anchor),
deriveOrbitalFromLocalPosition: (localPosition: THREE.Vector3, systemId: string, anchor: OrbitalAnchor) => deriveOrbitalFromLocalPosition(this.context.getWorldPresentationContext(), localPosition, systemId, anchor), deriveOrbitalFromLocalPosition: (localPosition: THREE.Vector3, systemId: string, anchor: OrbitalAnchor) => deriveOrbitalFromLocalPosition(this.context.getWorldPresentationContext(), localPosition, systemId, anchor),
setBubbleVisualState,
}; };
} }
} }

View File

@@ -5,17 +5,16 @@ import {
STAR_RENDER_SCALE, STAR_RENDER_SCALE,
} from "./viewerConstants"; } from "./viewerConstants";
import type { import type {
CelestialSnapshot,
ClaimSnapshot, ClaimSnapshot,
ConstructionSiteSnapshot, ConstructionSiteSnapshot,
LocalBubbleSnapshot,
PlanetSnapshot, PlanetSnapshot,
ResourceNodeSnapshot, ResourceNodeSnapshot,
ShipSnapshot, ShipSnapshot,
SpatialNodeSnapshot,
StationSnapshot, StationSnapshot,
SystemSnapshot, SystemSnapshot,
} from "./contracts"; } from "./contracts";
import type { MoonVisual, SystemSummaryVisual } from "./viewerTypes"; import type { MoonVisual } from "./viewerTypes";
import { import {
celestialRenderRadius, celestialRenderRadius,
computeMoonOrbitRadius, computeMoonOrbitRadius,
@@ -46,10 +45,10 @@ export function createNodeMesh(node: ResourceNodeSnapshot): SceneNode {
return createSceneNode(mesh); return createSceneNode(mesh);
} }
export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColor: (kind: string) => string): SceneNode { export function createCelestialMesh(node: CelestialSnapshot, celestialColor: (kind: string) => string): SceneNode {
const color = spatialNodeColor(node.kind); const color = celestialColor(node.kind);
return createSceneNode(new THREE.Mesh( return createSceneNode(new THREE.Mesh(
new THREE.OctahedronGeometry(10, 0), new THREE.OctahedronGeometry(0.08, 0),
new THREE.MeshStandardMaterial({ new THREE.MeshStandardMaterial({
color, color,
emissive: new THREE.Color(color).multiplyScalar(0.16), emissive: new THREE.Color(color).multiplyScalar(0.16),
@@ -59,23 +58,6 @@ export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColo
)); ));
} }
export function createBubbleRing(
bubble: LocalBubbleSnapshot,
localPosition: THREE.Vector3,
createCirclePoints: (radius: number, segments: number) => THREE.Vector3[],
): SceneNode {
const ring = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(createCirclePoints(Math.max(bubble.radius, 60), 64)),
new THREE.LineBasicMaterial({
color: 0x6ed6ff,
transparent: true,
opacity: 0.32,
}),
);
ring.position.copy(localPosition);
return createSceneNode(ring);
}
export function createClaimMesh(claim: ClaimSnapshot): SceneNode { export function createClaimMesh(claim: ClaimSnapshot): SceneNode {
return createSceneNode(new THREE.Mesh( return createSceneNode(new THREE.Mesh(
new THREE.ConeGeometry(9, 20, 4), new THREE.ConeGeometry(9, 20, 4),
@@ -363,20 +345,34 @@ export function createTacticalIcon(documentRef: Document, color: string, size: n
return createSceneNode(sprite); return createSceneNode(sprite);
} }
export function createSystemSummaryVisual(documentRef: Document, anchor: THREE.Vector3): SystemSummaryVisual {
export function createStarDot(documentRef: Document, color: string): SceneNode {
const canvas = documentRef.createElement("canvas"); const canvas = documentRef.createElement("canvas");
canvas.width = 512; canvas.width = 32;
canvas.height = 160; canvas.height = 32;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Unable to create star dot canvas");
}
context.clearRect(0, 0, 32, 32);
context.fillStyle = color;
context.beginPath();
context.arc(16, 16, 12, 0, Math.PI * 2);
context.fill();
const texture = new THREE.CanvasTexture(canvas); const texture = new THREE.CanvasTexture(canvas);
const sprite = createSceneNode(new THREE.Sprite(new THREE.SpriteMaterial({ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture, map: texture,
transparent: true, transparent: true,
depthWrite: false, depthWrite: false,
depthTest: false, depthTest: false,
}))); color: "#ffffff",
sprite.object.scale.set(520, 160, 1); fog: false,
sprite.setVisible(false); }));
return { sprite, texture, anchor }; sprite.scale.setScalar(4);
sprite.visible = false;
return createSceneNode(sprite);
} }
export function createShellReticle(documentRef: Document, color: string, size: number): SceneNode { export function createShellReticle(documentRef: Document, color: string, size: number): SceneNode {

View File

@@ -1,36 +1,33 @@
import * as THREE from "three"; import * as THREE from "three";
import { import {
ACTIVE_SYSTEM_DETAIL_SCALE,
PLANET_RENDER_SCALE, PLANET_RENDER_SCALE,
STAR_RENDER_SCALE, STAR_RENDER_SCALE,
} from "./viewerConstants"; } from "./viewerConstants";
import { DISPLAY_UNITS_PER_KILOMETER } from "./viewerMath";
import type { import type {
BubbleVisual, CelestialVisual,
ClaimVisual, ClaimVisual,
ConstructionSiteVisual, ConstructionSiteVisual,
NodeVisual, NodeVisual,
OrbitLineVisual, OrbitLineVisual,
PlanetVisual, PlanetVisual,
PresentationEntry,
Selectable, Selectable,
ShipVisual, ShipVisual,
SpatialNodeVisual,
StructureVisual, StructureVisual,
SystemSummaryVisual,
SystemVisual, SystemVisual,
} from "./viewerTypes"; } from "./viewerTypes";
import type { import type {
CelestialDelta,
CelestialSnapshot,
ClaimDelta, ClaimDelta,
ClaimSnapshot, ClaimSnapshot,
ConstructionSiteDelta, ConstructionSiteDelta,
ConstructionSiteSnapshot, ConstructionSiteSnapshot,
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta, ResourceNodeDelta,
ResourceNodeSnapshot, ResourceNodeSnapshot,
ShipDelta, ShipDelta,
ShipSnapshot, ShipSnapshot,
SpatialNodeDelta,
SpatialNodeSnapshot,
StationDelta, StationDelta,
StationSnapshot, StationSnapshot,
SystemSnapshot, SystemSnapshot,
@@ -45,7 +42,6 @@ import {
} from "./viewerMath"; } from "./viewerMath";
import { getAnimatedShipLocalPosition } from "./viewerPresentation"; import { getAnimatedShipLocalPosition } from "./viewerPresentation";
import { import {
createBubbleRing,
createClaimMesh, createClaimMesh,
createConstructionSiteMesh, createConstructionSiteMesh,
createMoonVisuals, createMoonVisuals,
@@ -54,10 +50,10 @@ import {
createPlanetRing, createPlanetRing,
createShellReticle, createShellReticle,
createShipMesh, createShipMesh,
createSpatialNodeMesh, createCelestialMesh,
createStarCluster, createStarCluster,
createStarDot,
createStationMesh, createStationMesh,
createSystemSummaryVisual,
createTacticalIcon, createTacticalIcon,
} from "./viewerSceneFactory"; } from "./viewerSceneFactory";
import { import {
@@ -68,47 +64,41 @@ import {
} from "./viewerScenePrimitives"; } from "./viewerScenePrimitives";
import type { SceneNode } from "./viewerScenePrimitives"; import type { SceneNode } from "./viewerScenePrimitives";
/** Scale a local km position to system-scene display coordinates. */
function toSystemPos(localPosition: THREE.Vector3): THREE.Vector3 {
return localPosition.clone().multiplyScalar(DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
}
interface SceneSyncContext { interface SceneSyncContext {
documentRef: Document; documentRef: Document;
worldOrbitalTimeSeconds?: number; worldOrbitalTimeSeconds?: number;
orbitalSimulationSpeed: number; orbitalSimulationSpeed: number;
worldSeed: number; worldSeed: number;
worldTimeSyncMs: number; worldTimeSyncMs: number;
systemGroup: THREE.Group; galaxySystemGroup: THREE.Group;
spatialNodeGroup: THREE.Group; celestialGroup: THREE.Group;
bubbleGroup: THREE.Group;
nodeGroup: THREE.Group; nodeGroup: THREE.Group;
stationGroup: THREE.Group; stationGroup: THREE.Group;
claimGroup: THREE.Group; claimGroup: THREE.Group;
constructionSiteGroup: THREE.Group; constructionSiteGroup: THREE.Group;
shipGroup: THREE.Group; shipGroup: THREE.Group;
selectableTargets: Map<THREE.Object3D, Selectable>; galaxySelectableTargets: Map<THREE.Object3D, Selectable>;
presentationEntries: PresentationEntry[]; systemSelectableTargets: Map<THREE.Object3D, Selectable>;
systemVisuals: Map<string, SystemVisual>; systemVisuals: Map<string, SystemVisual>;
systemSummaryVisuals: Map<string, SystemSummaryVisual>;
planetVisuals: PlanetVisual[]; planetVisuals: PlanetVisual[];
orbitLines: OrbitLineVisual[]; orbitLines: OrbitLineVisual[];
spatialNodeVisuals: Map<string, SpatialNodeVisual>; celestialVisuals: Map<string, CelestialVisual>;
bubbleVisuals: Map<string, BubbleVisual>;
nodeVisuals: Map<string, NodeVisual>; nodeVisuals: Map<string, NodeVisual>;
stationVisuals: Map<string, StructureVisual>; stationVisuals: Map<string, StructureVisual>;
claimVisuals: Map<string, ClaimVisual>; claimVisuals: Map<string, ClaimVisual>;
constructionSiteVisuals: Map<string, ConstructionSiteVisual>; constructionSiteVisuals: Map<string, ConstructionSiteVisual>;
shipVisuals: Map<string, ShipVisual>; shipVisuals: Map<string, ShipVisual>;
registerPresentation: (
detail: SceneNode,
icon: SceneNode,
hideDetailInUniverse: boolean,
hideIconInUniverse?: boolean,
systemId?: string,
) => void;
shipSize: (ship: ShipSnapshot) => number; shipSize: (ship: ShipSnapshot) => number;
shipLength: (ship: ShipSnapshot) => number; shipLength: (ship: ShipSnapshot) => number;
shipPresentationColor: (ship: ShipSnapshot) => string; shipPresentationColor: (ship: ShipSnapshot) => string;
spatialNodeColor: (kind: string) => string; celestialColor: (kind: string) => string;
createCirclePoints: (radius: number, segments: number) => THREE.Vector3[]; createCirclePoints: (radius: number, segments: number) => THREE.Vector3[];
resolveBubblePosition: (bubble: LocalBubbleSnapshot | LocalBubbleDelta) => THREE.Vector3; resolvePointPosition: (systemId: string, celestialId?: string | null) => THREE.Vector3;
resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3;
resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => NodeVisual["anchor"]; resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => NodeVisual["anchor"];
deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: NodeVisual["anchor"]) => { deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: NodeVisual["anchor"]) => {
radius: number; radius: number;
@@ -120,7 +110,6 @@ interface SceneSyncContext {
phase: number; phase: number;
inclination: number; inclination: number;
}; };
setBubbleVisualState: (visual: BubbleVisual, bubble: LocalBubbleSnapshot | LocalBubbleDelta) => void;
} }
export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapshot[]) { export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapshot[]) {
@@ -128,44 +117,37 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
? context.worldOrbitalTimeSeconds + ((performance.now() - context.worldTimeSyncMs) / 1000 * context.orbitalSimulationSpeed) ? context.worldOrbitalTimeSeconds + ((performance.now() - context.worldTimeSyncMs) / 1000 * context.orbitalSimulationSpeed)
: 0; : 0;
context.systemGroup.clear(); context.galaxySystemGroup.clear();
context.selectableTargets.clear(); context.galaxySelectableTargets.clear();
context.presentationEntries.length = 0; context.systemSelectableTargets.clear();
context.planetVisuals.length = 0; context.planetVisuals.length = 0;
context.orbitLines.length = 0; context.orbitLines.length = 0;
context.systemVisuals.clear(); context.systemVisuals.clear();
context.systemSummaryVisuals.clear();
for (const system of systems) { for (const system of systems) {
const root = createSceneNode(new THREE.Group()); // Galaxy root: star dot + shell reticle — lives in galaxyScene
root.setPosition(toDisplayGalaxyVector(system.galaxyPosition)); const galaxyRoot = createSceneNode(new THREE.Group());
const detailGroup = createSceneNode(new THREE.Group()); galaxyRoot.setPosition(toDisplayGalaxyVector(system.galaxyPosition));
const renderedStarSize = celestialRenderRadius(system.starSize, 0.00018, 0.16, 0.62);
const starCluster = createStarCluster(system); const systemIcon = createStarDot(context.documentRef, system.starColor);
const systemIcon = createTacticalIcon(context.documentRef, system.starColor, 96);
const shellReticle = createShellReticle(context.documentRef, "#ff3b30", 400); const shellReticle = createShellReticle(context.documentRef, "#ff3b30", 400);
const summaryVisual = createSystemSummaryVisual( galaxyRoot.add(systemIcon, shellReticle);
context.documentRef,
toDisplayGalaxyVector(system.galaxyPosition).add(new THREE.Vector3(0, renderedStarSize + 140, 0)), registerSelectableTarget(context.galaxySelectableTargets, systemIcon, { kind: "system", id: system.id });
); registerSelectableTarget(context.galaxySelectableTargets, shellReticle, { kind: "system", id: system.id });
summaryVisual.sprite.setPosition(new THREE.Vector3(0, renderedStarSize + 110, 0));
root.add(starCluster, systemIcon, shellReticle, summaryVisual.sprite, detailGroup); // System root: star cluster + planet detail group — added to systemScene only when this system is active
context.registerPresentation(starCluster, systemIcon, true); const systemRoot = createSceneNode(new THREE.Group());
context.systemVisuals.set(system.id, { const detailGroup = createSceneNode(new THREE.Group());
root, const starCluster = createStarCluster(system);
systemRoot.add(starCluster, detailGroup);
registerSelectableDescendants(
context.systemSelectableTargets,
starCluster, starCluster,
icon: systemIcon, { kind: "system", id: system.id },
shellReticle, (child) => child instanceof THREE.Mesh,
shellReticleBaseScale: 400, );
detailGroup,
summary: summaryVisual,
galaxyPosition: toDisplayGalaxyVector(system.galaxyPosition),
});
context.systemSummaryVisuals.set(system.id, summaryVisual);
registerSelectableDescendants(context.selectableTargets, starCluster, { kind: "system", id: system.id }, (child) => child instanceof THREE.Mesh);
registerSelectableTarget(context.selectableTargets, systemIcon, { kind: "system", id: system.id });
registerSelectableTarget(context.selectableTargets, shellReticle, { kind: "system", id: system.id });
for (const [planetIndex, planet] of system.planets.entries()) { for (const [planetIndex, planet] of system.planets.entries()) {
const orbit = createPlanetOrbit(planet); const orbit = createPlanetOrbit(planet);
@@ -179,12 +161,13 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
emissive: new THREE.Color(planet.color).multiplyScalar(0.04), emissive: new THREE.Color(planet.color).multiplyScalar(0.04),
}), }),
)); ));
planetMesh.setPosition(scaleLocalVector(computePlanetLocalPosition(planet, worldTimeSeconds))); const initialPos = toSystemPos(computePlanetLocalPosition(planet, worldTimeSeconds));
planetMesh.setPosition(initialPos);
const planetIcon = createTacticalIcon(context.documentRef, planet.color, Math.max(24, renderedPlanetRadius * 2)); const planetIcon = createTacticalIcon(context.documentRef, planet.color, Math.max(24, renderedPlanetRadius * 2));
planetIcon.setPosition(rawObject(planetMesh).position.clone()); planetIcon.setPosition(initialPos);
const ring = planet.hasRing ? createPlanetRing(planet) : undefined; const ring = planet.hasRing ? createPlanetRing(planet) : undefined;
if (ring) { if (ring) {
ring.setPosition(rawObject(planetMesh).position.clone()); ring.setPosition(initialPos);
} }
const moons = createMoonVisuals(planet, context.worldSeed); const moons = createMoonVisuals(planet, context.worldSeed);
detailGroup.add(orbit, planetMesh, planetIcon); detailGroup.add(orbit, planetMesh, planetIcon);
@@ -194,8 +177,8 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
for (const moon of moons) { for (const moon of moons) {
moon.systemId = system.id; moon.systemId = system.id;
moon.planetIndex = planetIndex; moon.planetIndex = planetIndex;
moon.orbit.setPosition(rawObject(planetMesh).position.clone()); moon.orbit.setPosition(initialPos);
moon.mesh.setPosition(rawObject(planetMesh).position.clone()); moon.mesh.setPosition(initialPos);
detailGroup.add(moon.orbit, moon.mesh); detailGroup.add(moon.orbit, moon.mesh);
context.orbitLines.push({ context.orbitLines.push({
line: moon.orbit, line: moon.orbit,
@@ -203,7 +186,6 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
kind: "moon", kind: "moon",
planetIndex, planetIndex,
}); });
context.registerPresentation(moon.mesh, planetIcon, true, true, system.id);
} }
context.orbitLines.push({ context.orbitLines.push({
line: orbit, line: orbit,
@@ -211,68 +193,73 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
kind: "planet", kind: "planet",
planetIndex, planetIndex,
}); });
context.registerPresentation(planetMesh, planetIcon, true, true, system.id);
if (ring) {
context.registerPresentation(ring, planetIcon, true, true, system.id);
}
context.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, ring, moons }); context.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, ring, moons });
registerSelectableTarget(context.selectableTargets, planetMesh, { kind: "planet", systemId: system.id, planetIndex }); registerSelectableTarget(context.systemSelectableTargets, planetMesh, { kind: "planet", systemId: system.id, planetIndex });
registerSelectableTarget(context.selectableTargets, planetIcon, { kind: "planet", systemId: system.id, planetIndex }); registerSelectableTarget(context.systemSelectableTargets, planetIcon, { kind: "planet", systemId: system.id, planetIndex });
} }
context.systemGroup.add(rawObject(root)); context.systemVisuals.set(system.id, {
galaxyRoot,
systemRoot,
starCluster,
icon: systemIcon,
shellReticle,
shellReticleBaseScale: 400,
detailGroup,
galaxyPosition: toDisplayGalaxyVector(system.galaxyPosition),
});
context.galaxySystemGroup.add(rawObject(galaxyRoot));
} }
} }
export function syncSpatialNodes(context: SceneSyncContext, nodes: SpatialNodeSnapshot[]) { export function syncCelestials(context: SceneSyncContext, celestials: CelestialSnapshot[], activeSystemId?: string) {
context.spatialNodeGroup.clear(); context.celestialGroup.clear();
context.spatialNodeVisuals.clear(); context.celestialVisuals.clear();
for (const node of nodes) { for (const celestial of celestials) {
const mesh = createSpatialNodeMesh(node, context.spatialNodeColor); // Stars, planets, and moons are already rendered by rebuildSystems via SystemSnapshot.
const icon = createTacticalIcon(context.documentRef, context.spatialNodeColor(node.kind), 18); // Only create visual objects for kinds not covered by the system builder.
const localPosition = toThreeVector(node.localPosition); if (celestial.kind === "star" || celestial.kind === "planet" || celestial.kind === "moon") {
mesh.setPosition(localPosition); continue;
icon.setPosition(localPosition); }
context.spatialNodeVisuals.set(node.id, {
id: node.id, const mesh = createCelestialMesh(celestial, context.celestialColor);
systemId: node.systemId, const icon = createTacticalIcon(context.documentRef, context.celestialColor(celestial.kind), 18);
const orbitalAnchor = toSystemPos(toThreeVector(celestial.orbitalAnchor));
mesh.setPosition(orbitalAnchor);
icon.setPosition(orbitalAnchor);
const isActive = celestial.systemId === activeSystemId;
mesh.setVisible(isActive);
icon.setVisible(isActive);
context.celestialVisuals.set(celestial.id, {
id: celestial.id,
systemId: celestial.systemId,
mesh, mesh,
icon, icon,
kind: node.kind, kind: celestial.kind,
localPosition, orbitalAnchor,
}); });
context.spatialNodeGroup.add(rawObject(mesh), rawObject(icon)); context.celestialGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, node.systemId); registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "celestial", id: celestial.id });
registerSelectableTarget(context.selectableTargets, mesh, { kind: "spatial-node", id: node.id }); registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "celestial", id: celestial.id });
registerSelectableTarget(context.selectableTargets, icon, { kind: "spatial-node", id: node.id });
} }
} }
export function syncLocalBubbles(context: SceneSyncContext, bubbles: LocalBubbleSnapshot[]) { export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot[], activeSystemId?: string) {
context.bubbleGroup.clear();
context.bubbleVisuals.clear();
for (const bubble of bubbles) {
const localPosition = context.resolveBubblePosition(bubble);
const mesh = createBubbleRing(bubble, localPosition, context.createCirclePoints);
const visual = { id: bubble.id, systemId: bubble.systemId, mesh, localPosition, radius: bubble.radius };
context.setBubbleVisualState(visual, bubble);
context.bubbleVisuals.set(bubble.id, visual);
context.bubbleGroup.add(rawObject(mesh));
registerSelectableTarget(context.selectableTargets, mesh, { kind: "bubble", id: bubble.id });
}
}
export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot[]) {
context.nodeGroup.clear(); context.nodeGroup.clear();
context.nodeVisuals.clear(); context.nodeVisuals.clear();
for (const node of nodes) { for (const node of nodes) {
const mesh = createNodeMesh(node); const mesh = createNodeMesh(node);
const icon = createTacticalIcon(context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20); const icon = createTacticalIcon(context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20);
icon.setPosition(rawObject(mesh).position.clone());
const localPosition = toThreeVector(node.localPosition); const localPosition = toThreeVector(node.localPosition);
const displayPos = toSystemPos(localPosition);
mesh.setPosition(displayPos);
icon.setPosition(displayPos);
const isActive = node.systemId === activeSystemId;
mesh.setVisible(isActive);
icon.setVisible(isActive);
const anchor = context.resolveOrbitalAnchor(node.systemId, localPosition); const anchor = context.resolveOrbitalAnchor(node.systemId, localPosition);
const orbital = context.deriveNodeOrbital(node, anchor); const orbital = context.deriveNodeOrbital(node, anchor);
context.nodeVisuals.set(node.id, { context.nodeVisuals.set(node.id, {
@@ -287,21 +274,25 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot
orbitInclination: orbital.inclination, orbitInclination: orbital.inclination,
}); });
context.nodeGroup.add(rawObject(mesh), rawObject(icon)); context.nodeGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, node.systemId); registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "node", id: node.id });
registerSelectableTarget(context.selectableTargets, mesh, { kind: "node", id: node.id }); registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "node", id: node.id });
registerSelectableTarget(context.selectableTargets, icon, { kind: "node", id: node.id });
} }
} }
export function syncStations(context: SceneSyncContext, stations: StationSnapshot[]) { export function syncStations(context: SceneSyncContext, stations: StationSnapshot[], activeSystemId?: string) {
context.stationGroup.clear(); context.stationGroup.clear();
context.stationVisuals.clear(); context.stationVisuals.clear();
for (const station of stations) { for (const station of stations) {
const mesh = createStationMesh(station); const mesh = createStationMesh(station);
const icon = createTacticalIcon(context.documentRef, station.color, 26); const icon = createTacticalIcon(context.documentRef, station.color, 26);
icon.setPosition(rawObject(mesh).position.clone());
const localPosition = toThreeVector(station.localPosition); const localPosition = toThreeVector(station.localPosition);
const displayPos = toSystemPos(localPosition);
mesh.setPosition(displayPos);
icon.setPosition(displayPos);
const isActive = station.systemId === activeSystemId;
mesh.setVisible(isActive);
icon.setVisible(isActive);
const anchor = context.resolveOrbitalAnchor(station.systemId, localPosition); const anchor = context.resolveOrbitalAnchor(station.systemId, localPosition);
const orbital = context.deriveOrbitalFromLocalPosition(localPosition, station.systemId, anchor); const orbital = context.deriveOrbitalFromLocalPosition(localPosition, station.systemId, anchor);
context.stationVisuals.set(station.id, { context.stationVisuals.set(station.id, {
@@ -316,63 +307,68 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho
localPosition, localPosition,
}); });
context.stationGroup.add(rawObject(mesh), rawObject(icon)); context.stationGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, station.systemId); registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "station", id: station.id });
registerSelectableTarget(context.selectableTargets, mesh, { kind: "station", id: station.id }); registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "station", id: station.id });
registerSelectableTarget(context.selectableTargets, icon, { kind: "station", id: station.id });
} }
} }
export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[]) { export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[], activeSystemId?: string) {
context.claimGroup.clear(); context.claimGroup.clear();
context.claimVisuals.clear(); context.claimVisuals.clear();
for (const claim of claims) { for (const claim of claims) {
const localPosition = context.resolvePointPosition(claim.systemId, claim.nodeId); const localPosition = context.resolvePointPosition(claim.systemId, claim.celestialId);
const displayPos = toSystemPos(localPosition);
const mesh = createClaimMesh(claim); const mesh = createClaimMesh(claim);
const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 18); const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 18);
mesh.setPosition(localPosition); mesh.setPosition(displayPos);
icon.setPosition(localPosition); icon.setPosition(displayPos);
const isActive = claim.systemId === activeSystemId;
mesh.setVisible(isActive);
icon.setVisible(isActive);
context.claimVisuals.set(claim.id, { context.claimVisuals.set(claim.id, {
id: claim.id, id: claim.id,
nodeId: claim.nodeId, celestialId: claim.celestialId,
systemId: claim.systemId, systemId: claim.systemId,
mesh, mesh,
icon, icon,
localPosition, localPosition,
}); });
context.claimGroup.add(rawObject(mesh), rawObject(icon)); context.claimGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, claim.systemId); registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "claim", id: claim.id });
registerSelectableTarget(context.selectableTargets, mesh, { kind: "claim", id: claim.id }); registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "claim", id: claim.id });
registerSelectableTarget(context.selectableTargets, icon, { kind: "claim", id: claim.id });
} }
} }
export function syncConstructionSites(context: SceneSyncContext, sites: ConstructionSiteSnapshot[]) { export function syncConstructionSites(context: SceneSyncContext, sites: ConstructionSiteSnapshot[], activeSystemId?: string) {
context.constructionSiteGroup.clear(); context.constructionSiteGroup.clear();
context.constructionSiteVisuals.clear(); context.constructionSiteVisuals.clear();
for (const site of sites) { for (const site of sites) {
const localPosition = context.resolvePointPosition(site.systemId, site.nodeId); const localPosition = context.resolvePointPosition(site.systemId, site.celestialId);
const displayPos = toSystemPos(localPosition);
const mesh = createConstructionSiteMesh(site); const mesh = createConstructionSiteMesh(site);
const icon = createTacticalIcon(context.documentRef, "#9df29c", 18); const icon = createTacticalIcon(context.documentRef, "#9df29c", 18);
mesh.setPosition(localPosition); mesh.setPosition(displayPos);
icon.setPosition(localPosition); icon.setPosition(displayPos);
const isActive = site.systemId === activeSystemId;
mesh.setVisible(isActive);
icon.setVisible(isActive);
context.constructionSiteVisuals.set(site.id, { context.constructionSiteVisuals.set(site.id, {
id: site.id, id: site.id,
nodeId: site.nodeId, celestialId: site.celestialId,
systemId: site.systemId, systemId: site.systemId,
mesh, mesh,
icon, icon,
localPosition, localPosition,
}); });
context.constructionSiteGroup.add(rawObject(mesh), rawObject(icon)); context.constructionSiteGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, site.systemId); registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "construction-site", id: site.id });
registerSelectableTarget(context.selectableTargets, mesh, { kind: "construction-site", id: site.id }); registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "construction-site", id: site.id });
registerSelectableTarget(context.selectableTargets, icon, { kind: "construction-site", id: site.id });
} }
} }
export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tickIntervalMs: number) { export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tickIntervalMs: number, activeSystemId?: string) {
context.shipGroup.clear(); context.shipGroup.clear();
context.shipVisuals.clear(); context.shipVisuals.clear();
@@ -380,19 +376,23 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick
const mesh = createShipMesh(ship, context.shipSize(ship), context.shipLength(ship), context.shipPresentationColor(ship)); const mesh = createShipMesh(ship, context.shipSize(ship), context.shipLength(ship), context.shipPresentationColor(ship));
const shipColor = context.shipPresentationColor(ship); const shipColor = context.shipPresentationColor(ship);
const icon = createTacticalIcon(context.documentRef, shipColor, 18); const icon = createTacticalIcon(context.documentRef, shipColor, 18);
const position = toThreeVector(ship.localPosition); const localPosition = toThreeVector(ship.localPosition);
icon.setPosition(position); const displayPos = toSystemPos(localPosition);
mesh.setPosition(displayPos);
icon.setPosition(displayPos);
icon.setColor(shipColor); icon.setColor(shipColor);
const isActive = ship.systemId === activeSystemId;
mesh.setVisible(isActive);
icon.setVisible(isActive);
context.shipGroup.add(rawObject(mesh), rawObject(icon)); context.shipGroup.add(rawObject(mesh), rawObject(icon));
registerSelectableTarget(context.selectableTargets, mesh, { kind: "ship", id: ship.id }); registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "ship", id: ship.id });
registerSelectableTarget(context.selectableTargets, icon, { kind: "ship", id: ship.id }); registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "ship", id: ship.id });
context.registerPresentation(mesh, icon, true, true, ship.systemId);
context.shipVisuals.set(ship.id, { context.shipVisuals.set(ship.id, {
systemId: ship.systemId, systemId: ship.systemId,
mesh, mesh,
icon, icon,
startPosition: position.clone(), startPosition: localPosition.clone(),
authoritativePosition: position.clone(), authoritativePosition: localPosition.clone(),
targetPosition: toThreeVector(ship.targetLocalPosition), targetPosition: toThreeVector(ship.targetLocalPosition),
velocity: toThreeVector(ship.localVelocity), velocity: toThreeVector(ship.localVelocity),
receivedAtMs: performance.now(), receivedAtMs: performance.now(),
@@ -401,39 +401,30 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick
} }
} }
export function applySpatialNodeDeltas(context: SceneSyncContext, nodes: SpatialNodeDelta[]) { export function applyCelestialDeltas(context: SceneSyncContext, celestials: CelestialDelta[], activeSystemId?: string) {
for (const node of nodes) { for (const celestial of celestials) {
const visual = context.spatialNodeVisuals.get(node.id); if (celestial.kind === "star" || celestial.kind === "planet" || celestial.kind === "moon") {
continue;
}
const visual = context.celestialVisuals.get(celestial.id);
if (!visual) { if (!visual) {
continue; continue;
} }
visual.systemId = node.systemId; visual.systemId = celestial.systemId;
visual.kind = node.kind; visual.kind = celestial.kind;
visual.localPosition.copy(toThreeVector(node.localPosition)); visual.orbitalAnchor.copy(toSystemPos(toThreeVector(celestial.orbitalAnchor)));
visual.mesh.setPosition(visual.localPosition); visual.mesh.setPosition(visual.orbitalAnchor);
visual.icon.setPosition(visual.localPosition); visual.icon.setPosition(visual.orbitalAnchor);
visual.mesh.setColor(context.spatialNodeColor(node.kind)); visual.mesh.setColor(context.celestialColor(celestial.kind));
const isActive = visual.systemId === activeSystemId;
visual.mesh.setVisible(isActive);
visual.icon.setVisible(isActive);
} }
} }
export function applyLocalBubbleDeltas(context: SceneSyncContext, bubbles: LocalBubbleDelta[]) { export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDelta[], activeSystemId?: string) {
for (const bubble of bubbles) {
const visual = context.bubbleVisuals.get(bubble.id);
if (!visual) {
continue;
}
visual.systemId = bubble.systemId;
visual.radius = bubble.radius;
visual.localPosition.copy(context.resolveBubblePosition(bubble));
visual.mesh.setPosition(visual.localPosition);
visual.mesh.setScaleScalar(Math.max(bubble.radius, 60));
context.setBubbleVisualState(visual, bubble);
}
}
export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDelta[]) {
for (const node of nodes) { for (const node of nodes) {
const visual = context.nodeVisuals.get(node.id); const visual = context.nodeVisuals.get(node.id);
if (!visual) { if (!visual) {
@@ -449,10 +440,13 @@ export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDe
visual.orbitPhase = orbital.phase; visual.orbitPhase = orbital.phase;
visual.orbitInclination = orbital.inclination; visual.orbitInclination = orbital.inclination;
visual.mesh.setScaleScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); visual.mesh.setScaleScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
const isActive = visual.systemId === activeSystemId;
visual.mesh.setVisible(isActive);
visual.icon.setVisible(isActive);
} }
} }
export function applyStationDeltas(context: SceneSyncContext, stations: StationDelta[]) { export function applyStationDeltas(context: SceneSyncContext, stations: StationDelta[], activeSystemId?: string) {
for (const station of stations) { for (const station of stations) {
const visual = context.stationVisuals.get(station.id); const visual = context.stationVisuals.get(station.id);
if (!visual) { if (!visual) {
@@ -468,10 +462,13 @@ export function applyStationDeltas(context: SceneSyncContext, stations: StationD
visual.orbitInclination = orbital.inclination; visual.orbitInclination = orbital.inclination;
visual.mesh.setColor(station.color); visual.mesh.setColor(station.color);
visual.mesh.setEmissive(station.color, 0.1); visual.mesh.setEmissive(station.color, 0.1);
const isActive = visual.systemId === activeSystemId;
visual.mesh.setVisible(isActive);
visual.icon.setVisible(isActive);
} }
} }
export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[]) { export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[], activeSystemId?: string) {
for (const claim of claims) { for (const claim of claims) {
const visual = context.claimVisuals.get(claim.id); const visual = context.claimVisuals.get(claim.id);
if (!visual) { if (!visual) {
@@ -479,15 +476,19 @@ export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[]
} }
visual.systemId = claim.systemId; visual.systemId = claim.systemId;
visual.localPosition.copy(context.resolvePointPosition(claim.systemId, claim.nodeId)); visual.localPosition.copy(context.resolvePointPosition(claim.systemId, claim.celestialId));
visual.mesh.setPosition(visual.localPosition); const displayPos = toSystemPos(visual.localPosition);
visual.icon.setPosition(visual.localPosition); visual.mesh.setPosition(displayPos);
visual.icon.setPosition(displayPos);
visual.mesh.setColor(claim.state === "active" ? "#ff7f50" : "#ff5b5b"); visual.mesh.setColor(claim.state === "active" ? "#ff7f50" : "#ff5b5b");
visual.mesh.setEmissive(claim.state === "active" ? "#ffb27d" : "#7a2020"); visual.mesh.setEmissive(claim.state === "active" ? "#ffb27d" : "#7a2020");
const isActive = visual.systemId === activeSystemId;
visual.mesh.setVisible(isActive);
visual.icon.setVisible(isActive);
} }
} }
export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: ConstructionSiteDelta[]) { export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: ConstructionSiteDelta[], activeSystemId?: string) {
for (const site of sites) { for (const site of sites) {
const visual = context.constructionSiteVisuals.get(site.id); const visual = context.constructionSiteVisuals.get(site.id);
if (!visual) { if (!visual) {
@@ -495,15 +496,19 @@ export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: Co
} }
visual.systemId = site.systemId; visual.systemId = site.systemId;
visual.localPosition.copy(context.resolvePointPosition(site.systemId, site.nodeId)); visual.localPosition.copy(context.resolvePointPosition(site.systemId, site.celestialId));
visual.mesh.setPosition(visual.localPosition); const displayPos = toSystemPos(visual.localPosition);
visual.icon.setPosition(visual.localPosition); visual.mesh.setPosition(displayPos);
visual.icon.setPosition(displayPos);
visual.mesh.setColor(site.state === "completed" ? "#46d37f" : "#9df29c"); visual.mesh.setColor(site.state === "completed" ? "#46d37f" : "#9df29c");
visual.mesh.setScaleScalar(0.75 + site.progress * 0.35); visual.mesh.setScaleScalar(0.75 + site.progress * 0.35);
const isActive = visual.systemId === activeSystemId;
visual.mesh.setVisible(isActive);
visual.icon.setVisible(isActive);
} }
} }
export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], tickIntervalMs: number) { export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], tickIntervalMs: number, activeSystemId?: string) {
for (const ship of ships) { for (const ship of ships) {
const visual = context.shipVisuals.get(ship.id); const visual = context.shipVisuals.get(ship.id);
if (!visual) { if (!visual) {
@@ -521,5 +526,8 @@ export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], t
visual.mesh.setColor(shipColor); visual.mesh.setColor(shipColor);
visual.mesh.setEmissive(shipColor, 0.18); visual.mesh.setEmissive(shipColor, 0.18);
visual.icon.setColor(shipColor); visual.icon.setColor(shipColor);
const isActive = visual.systemId === activeSystemId;
visual.mesh.setVisible(isActive);
visual.icon.setVisible(isActive);
} }
} }

View File

@@ -1,4 +1,4 @@
import type { ShipSnapshot, SpatialNodeSnapshot, SystemSnapshot } from "./contracts"; import type { CelestialSnapshot, ShipSnapshot, SystemSnapshot } from "./contracts";
import type { import type {
CameraMode, CameraMode,
OrbitalAnchor, OrbitalAnchor,
@@ -21,11 +21,8 @@ export function describeSelectable(world: WorldState | undefined, item: Selectab
if (item.kind === "node") { if (item.kind === "node") {
return item.id; return item.id;
} }
if (item.kind === "spatial-node") { if (item.kind === "celestial") {
return `${world.spatialNodes.get(item.id)?.kind ?? "node"} ${item.id}`; return `${world.celestials.get(item.id)?.kind ?? "celestial"} ${item.id}`;
}
if (item.kind === "bubble") {
return `bubble ${item.id}`;
} }
if (item.kind === "claim") { if (item.kind === "claim") {
return `claim ${item.id}`; return `claim ${item.id}`;
@@ -53,7 +50,29 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab
} }
if (item.kind === "system") { if (item.kind === "system") {
return world.systems.get(item.id)?.label ?? item.id; const system = world.systems.get(item.id);
if (!system) {
return item.id;
}
const starLabel = system.starCount > 1 ? `${system.starCount}× ${system.starKind}` : system.starKind;
const planetCount = system.planets.length;
const shipCount = [...world.ships.values()].filter((s) => s.systemId === item.id).length;
const stationCount = [...world.stations.values()].filter((s) => s.systemId === item.id).length;
const lines = [
system.label,
`${starLabel} · ${planetCount} planet${planetCount !== 1 ? "s" : ""}`,
];
const parts: string[] = [];
if (shipCount > 0) {
parts.push(`${shipCount} ship${shipCount !== 1 ? "s" : ""}`);
}
if (stationCount > 0) {
parts.push(`${stationCount} station${stationCount !== 1 ? "s" : ""}`);
}
if (parts.length > 0) {
lines.push(parts.join(" · "));
}
return lines.join("\n");
} }
if (item.kind === "planet") { if (item.kind === "planet") {
@@ -68,46 +87,38 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab
return item.id; return item.id;
} }
const anchorPath = node.anchorNodeId const anchorPath = node.celestialId
? describeSpatialNodePathWithinSystem(world, node.systemId, node.anchorNodeId) ? describeCelestialPathWithinSystem(world, node.systemId, node.celestialId)
: undefined; : undefined;
return anchorPath ? `${anchorPath} / ${node.itemId}` : `${node.systemId} / ${node.itemId}`; return anchorPath ? `${anchorPath} / ${node.itemId}` : `${node.systemId} / ${node.itemId}`;
} }
if (item.kind === "spatial-node") { if (item.kind === "celestial") {
const node = world.spatialNodes.get(item.id); const celestial = world.celestials.get(item.id);
if (!node) { if (!celestial) {
return item.id; return item.id;
} }
if (node.kind === "star") { if (celestial.kind === "star") {
const system = world.systems.get(node.systemId); const system = world.systems.get(celestial.systemId);
return system ? `${system.label} star` : `${node.systemId} star`; return system ? `${system.label} star` : `${celestial.systemId} star`;
} }
return describeSpatialNodePathWithinSystem(world, node.systemId, node.id) ?? `${node.systemId} / ${node.kind}`; return describeCelestialPathWithinSystem(world, celestial.systemId, celestial.id) ?? `${celestial.systemId} / ${celestial.kind}`;
}
if (item.kind === "bubble") {
const bubble = world.localBubbles.get(item.id);
const anchorPath = bubble?.nodeId
? describeSpatialNodePathWithinSystem(world, bubble.systemId, bubble.nodeId)
: undefined;
return anchorPath ? `${anchorPath} bubble` : `Bubble ${item.id}`;
} }
if (item.kind === "claim") { if (item.kind === "claim") {
const claim = world.claims.get(item.id); const claim = world.claims.get(item.id);
const anchorPath = claim?.nodeId const anchorPath = claim?.celestialId
? describeSpatialNodePathWithinSystem(world, claim.systemId, claim.nodeId) ? describeCelestialPathWithinSystem(world, claim.systemId, claim.celestialId)
: undefined; : undefined;
return anchorPath ? `${anchorPath} claim` : `Claim ${item.id}`; return anchorPath ? `${anchorPath} claim` : `Claim ${item.id}`;
} }
if (item.kind === "construction-site") { if (item.kind === "construction-site") {
const site = world.constructionSites.get(item.id); const site = world.constructionSites.get(item.id);
const anchorPath = site?.nodeId const anchorPath = site?.celestialId
? describeSpatialNodePathWithinSystem(world, site.systemId, site.nodeId) ? describeCelestialPathWithinSystem(world, site.systemId, site.celestialId)
: undefined; : undefined;
const siteLabel = site ? (site.blueprintId ?? site.targetDefinitionId) : item.id; const siteLabel = site ? (site.blueprintId ?? site.targetDefinitionId) : item.id;
return anchorPath ? `${anchorPath} / ${siteLabel}` : `Construction ${siteLabel}`; return anchorPath ? `${anchorPath} / ${siteLabel}` : `Construction ${siteLabel}`;
@@ -123,8 +134,6 @@ export function getSelectionGroup(item: Selectable): SelectionGroup {
if ( if (
item.kind === "station" item.kind === "station"
|| item.kind === "node" || item.kind === "node"
|| item.kind === "spatial-node"
|| item.kind === "bubble"
|| item.kind === "claim" || item.kind === "claim"
|| item.kind === "construction-site" || item.kind === "construction-site"
) { ) {
@@ -147,11 +156,8 @@ export function resolveSelectableSystemId(world: WorldState | undefined, selecti
if (selection.kind === "node") { if (selection.kind === "node") {
return world.nodes.get(selection.id)?.systemId; return world.nodes.get(selection.id)?.systemId;
} }
if (selection.kind === "spatial-node") { if (selection.kind === "celestial") {
return world.spatialNodes.get(selection.id)?.systemId; return world.celestials.get(selection.id)?.systemId;
}
if (selection.kind === "bubble") {
return world.localBubbles.get(selection.id)?.systemId;
} }
if (selection.kind === "claim") { if (selection.kind === "claim") {
return world.claims.get(selection.id)?.systemId; return world.claims.get(selection.id)?.systemId;
@@ -165,29 +171,26 @@ export function resolveSelectableSystemId(world: WorldState | undefined, selecti
return selection.id; return selection.id;
} }
export function resolveFocusedBubbleId(world: WorldState | undefined, selectedItems: Selectable[]): string | undefined { export function resolveFocusedCelestialId(world: WorldState | undefined, selectedItems: Selectable[]): string | undefined {
if (!world || selectedItems.length !== 1) { if (!world || selectedItems.length !== 1) {
return undefined; return undefined;
} }
const selected = selectedItems[0]; const selected = selectedItems[0];
if (selected.kind === "bubble") { if (selected.kind === "celestial") {
return selected.id; return selected.id;
} }
if (selected.kind === "ship") { if (selected.kind === "ship") {
return world.ships.get(selected.id)?.bubbleId ?? world.ships.get(selected.id)?.spatialState.currentBubbleId ?? undefined; return world.ships.get(selected.id)?.spatialState.currentCelestialId ?? world.ships.get(selected.id)?.celestialId ?? undefined;
} }
if (selected.kind === "station") { if (selected.kind === "station") {
return world.stations.get(selected.id)?.bubbleId ?? undefined; return world.stations.get(selected.id)?.celestialId ?? undefined;
}
if (selected.kind === "spatial-node") {
return world.spatialNodes.get(selected.id)?.bubbleId ?? undefined;
} }
if (selected.kind === "claim") { if (selected.kind === "claim") {
return world.claims.get(selected.id)?.bubbleId ?? undefined; return world.claims.get(selected.id)?.celestialId ?? undefined;
} }
if (selected.kind === "construction-site") { if (selected.kind === "construction-site") {
return world.constructionSites.get(selected.id)?.bubbleId ?? undefined; return world.constructionSites.get(selected.id)?.celestialId ?? undefined;
} }
return undefined; return undefined;
} }
@@ -232,8 +235,7 @@ export function renderSystemDetails(
let shipCount = 0; let shipCount = 0;
let stationCount = 0; let stationCount = 0;
let nodeCount = 0; let nodeCount = 0;
let spatialNodeCount = 0; let celestialCount = 0;
let bubbleCount = 0;
let claimCount = 0; let claimCount = 0;
let constructionCount = 0; let constructionCount = 0;
let moonCount = 0; let moonCount = 0;
@@ -253,14 +255,9 @@ export function renderSystemDetails(
nodeCount += 1; nodeCount += 1;
} }
} }
for (const node of world.spatialNodes.values()) { for (const celestial of world.celestials.values()) {
if (node.systemId === system.id) { if (celestial.systemId === system.id) {
spatialNodeCount += 1; celestialCount += 1;
}
}
for (const bubble of world.localBubbles.values()) {
if (bubble.systemId === system.id) {
bubbleCount += 1;
} }
} }
for (const claim of world.claims.values()) { for (const claim of world.claims.values()) {
@@ -285,7 +282,7 @@ export function renderSystemDetails(
<p>${system.id}${activeContext ? " · active system" : ""}</p> <p>${system.id}${activeContext ? " · active system" : ""}</p>
<p>${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}</p> <p>${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}</p>
<p>Planets ${system.planets.length}<br>Moons ${moonCount}<br>Ships ${shipCount}<br>Stations ${stationCount}</p> <p>Planets ${system.planets.length}<br>Moons ${moonCount}<br>Ships ${shipCount}<br>Stations ${stationCount}</p>
<p>Spatial nodes ${spatialNodeCount}<br>Resource nodes ${nodeCount}<br>Bubbles ${bubbleCount}</p> <p>Celestials ${celestialCount}<br>Resource nodes ${nodeCount}</p>
<p>Claims ${claimCount}<br>Construction sites ${constructionCount}</p> <p>Claims ${claimCount}<br>Construction sites ${constructionCount}</p>
<p>Height ${formatGalaxyDistance(system.galaxyPosition.y)}</p> <p>Height ${formatGalaxyDistance(system.galaxyPosition.y)}</p>
<p>${system.planets.slice(0, 8).map((planet) => `${planet.label} (${planet.planetType})`).join("<br>")}</p> <p>${system.planets.slice(0, 8).map((planet) => `${planet.label} (${planet.planetType})`).join("<br>")}</p>
@@ -308,18 +305,18 @@ export function describeShipState(world: WorldState | undefined, ship: ShipSnaps
return baseState; return baseState;
} }
const destinationNode = world.spatialNodes.get(destinationNodeId); const destinationCelestial = world.celestials.get(destinationNodeId);
if (!destinationNode) { if (!destinationCelestial) {
return `${baseState} -> ${destinationNodeId}`; return `${baseState} -> ${destinationNodeId}`;
} }
if (baseState === "warping" || baseState === "spooling-warp") { if (baseState === "warping" || baseState === "spooling-warp") {
const destinationPath = describeSpatialNodePathWithinSystem(world, destinationNode.systemId, destinationNodeId); const destinationPath = describeCelestialPathWithinSystem(world, destinationCelestial.systemId, destinationNodeId);
return `${baseState} -> ${destinationPath ?? destinationNodeId}`; return `${baseState} -> ${destinationPath ?? destinationNodeId}`;
} }
const destinationSystem = world.systems.get(destinationNode.systemId); const destinationSystem = world.systems.get(destinationCelestial.systemId);
return `${baseState} -> ${destinationSystem?.label ?? destinationNode.systemId}`; return `${baseState} -> ${destinationSystem?.label ?? destinationCelestial.systemId}`;
} }
function describeControllerTask(taskKind: string): string { function describeControllerTask(taskKind: string): string {
@@ -381,8 +378,8 @@ export function describeShipLocation(world: WorldState | undefined, ship: ShipSn
if (ship.dockedStationId) { if (ship.dockedStationId) {
const station = world.stations.get(ship.dockedStationId); const station = world.stations.get(ship.dockedStationId);
if (station) { if (station) {
const anchorPath = station.anchorNodeId const anchorPath = station.celestialId
? describeSpatialNodePathWithinSystem(world, station.systemId, station.anchorNodeId) ? describeCelestialPathWithinSystem(world, station.systemId, station.celestialId)
: undefined; : undefined;
return { return {
system: systemLabel, system: systemLabel,
@@ -391,22 +388,11 @@ export function describeShipLocation(world: WorldState | undefined, ship: ShipSn
} }
} }
const currentNodeId = ship.spatialState.currentNodeId ?? ship.nodeId; const currentCelestialId = ship.spatialState.currentCelestialId ?? ship.celestialId;
if (currentNodeId) { if (currentCelestialId) {
const nodePath = describeSpatialNodePathWithinSystem(world, systemId, currentNodeId); const celestialPath = describeCelestialPathWithinSystem(world, systemId, currentCelestialId);
if (nodePath) { if (celestialPath) {
return { system: systemLabel, local: nodePath }; return { system: systemLabel, local: celestialPath };
}
}
const currentBubbleId = ship.spatialState.currentBubbleId ?? ship.bubbleId;
if (currentBubbleId) {
const bubble = world.localBubbles.get(currentBubbleId);
if (bubble?.nodeId) {
const nodePath = describeSpatialNodePathWithinSystem(world, systemId, bubble.nodeId);
if (nodePath) {
return { system: systemLabel, local: nodePath };
}
} }
} }
@@ -415,11 +401,11 @@ export function describeShipLocation(world: WorldState | undefined, ship: ShipSn
export function describeActiveSpace( export function describeActiveSpace(
world: WorldState | undefined, world: WorldState | undefined,
zoomLevel: "local" | "system" | "universe", povLevel: "local" | "system" | "galaxy",
activeSystemId: string | undefined, activeSystemId: string | undefined,
selectedItems: Selectable[], selectedItems: Selectable[],
): string { ): string {
if (!world || zoomLevel === "universe") { if (!world || povLevel === "galaxy") {
return "deep-space"; return "deep-space";
} }
@@ -428,16 +414,13 @@ export function describeActiveSpace(
return "deep-space"; return "deep-space";
} }
if (zoomLevel !== "local") { if (povLevel !== "local") {
return activeSystem.label; return activeSystem.label;
} }
const bubbleId = resolveFocusedBubbleId(world, selectedItems); const celestialId = resolveFocusedCelestialId(world, selectedItems);
if (bubbleId) { if (celestialId) {
const bubble = world.localBubbles.get(bubbleId); const localPath = describeCelestialPathWithinSystem(world, activeSystem.id, celestialId);
const localPath = bubble?.nodeId
? describeSpatialNodePathWithinSystem(world, activeSystem.id, bubble.nodeId)
: undefined;
return localPath return localPath
? `${activeSystem.label} / ${localPath}` ? `${activeSystem.label} / ${localPath}`
: activeSystem.label; : activeSystem.label;
@@ -454,51 +437,43 @@ export function describeActiveSpace(
return activeSystem.label; return activeSystem.label;
} }
export function describeSpatialNodePathWithinSystem(world: WorldState, systemId: string, nodeId: string): string | undefined { export function describeCelestialPathWithinSystem(world: WorldState, systemId: string, celestialId: string): string | undefined {
const node = world.spatialNodes.get(nodeId); const celestial = world.celestials.get(celestialId);
const system = world.systems.get(systemId); const system = world.systems.get(systemId);
if (!node || !system) { if (!celestial || !system) {
return undefined; return undefined;
} }
if (node.parentNodeId) { if (celestial.parentNodeId) {
const parentPath = describeSpatialNodePathWithinSystem(world, systemId, node.parentNodeId); const parentPath = describeCelestialPathWithinSystem(world, systemId, celestial.parentNodeId);
const segment = describeSpatialNodeSegment(world, system, node); const segment = describeCelestialSegment(system, celestial);
return parentPath ? `${parentPath}/${segment}` : segment; return parentPath ? `${parentPath}/${segment}` : segment;
} }
if (node.kind === "star") { if (celestial.kind === "star") {
return undefined; return undefined;
} }
return describeSpatialNodeSegment(world, system, node); return describeCelestialSegment(system, celestial);
} }
function describeSpatialNodeSegment(world: WorldState, system: SystemSnapshot, node: SpatialNodeSnapshot): string { function describeCelestialSegment(system: SystemSnapshot, celestial: CelestialSnapshot): string {
const moonMatch = node.id.match(/-planet-(\d+)-moon-(\d+)$/); const moonMatch = celestial.id.match(/-planet-(\d+)-moon-(\d+)$/);
if (moonMatch) { if (moonMatch) {
const moonIndex = Number.parseInt(moonMatch[2], 10); const moonIndex = Number.parseInt(moonMatch[2], 10);
return `Moon ${moonIndex}`; return `Moon ${moonIndex}`;
} }
const lagrangeMatch = node.id.match(/-planet-\d+-(l[1-5])$/); const lagrangeMatch = celestial.id.match(/-planet-\d+-(l[1-5])$/);
if (lagrangeMatch) { if (lagrangeMatch) {
return lagrangeMatch[1].toUpperCase(); return lagrangeMatch[1].toUpperCase();
} }
const planetMatch = node.id.match(/-planet-(\d+)$/); const planetMatch = celestial.id.match(/-planet-(\d+)$/);
if (planetMatch) { if (planetMatch) {
const planetIndex = Number.parseInt(planetMatch[1], 10) - 1; const planetIndex = Number.parseInt(planetMatch[1], 10) - 1;
return system.planets[planetIndex]?.label ?? `Planet ${planetMatch[1]}`; return system.planets[planetIndex]?.label ?? `Planet ${planetMatch[1]}`;
} }
if (node.kind === "station" && node.occupyingStructureId) { return celestial.orbitReferenceId ?? celestial.kind;
return world.stations.get(node.occupyingStructureId)?.label ?? node.occupyingStructureId;
}
if (node.kind === "resource-site") {
return node.orbitReferenceId ?? "Resource Site";
}
return node.orbitReferenceId ?? node.kind;
} }

View File

@@ -40,8 +40,7 @@ export function createWorldState(snapshot: WorldSnapshot): WorldState {
orbitalSimulation: snapshot.orbitalSimulation, orbitalSimulation: snapshot.orbitalSimulation,
generatedAtUtc: snapshot.generatedAtUtc, generatedAtUtc: snapshot.generatedAtUtc,
systems: new Map(snapshot.systems.map((system) => [system.id, system])), systems: new Map(snapshot.systems.map((system) => [system.id, system])),
spatialNodes: new Map(snapshot.spatialNodes.map((node) => [node.id, node])), celestials: new Map(snapshot.celestials.map((celestial) => [celestial.id, celestial])),
localBubbles: new Map(snapshot.localBubbles.map((bubble) => [bubble.id, bubble])),
nodes: new Map(snapshot.nodes.map((node) => [node.id, node])), nodes: new Map(snapshot.nodes.map((node) => [node.id, node])),
stations: new Map(snapshot.stations.map((station) => [station.id, station])), stations: new Map(snapshot.stations.map((station) => [station.id, station])),
claims: new Map(snapshot.claims.map((claim) => [claim.id, claim])), claims: new Map(snapshot.claims.map((claim) => [claim.id, claim])),
@@ -62,11 +61,8 @@ export function applyDeltaToWorld(world: WorldState, delta: WorldDelta): boolean
world.generatedAtUtc = delta.generatedAtUtc; world.generatedAtUtc = delta.generatedAtUtc;
world.recentEvents = [...delta.events, ...world.recentEvents].slice(0, 18); world.recentEvents = [...delta.events, ...world.recentEvents].slice(0, 18);
for (const node of delta.spatialNodes) { for (const celestial of delta.celestials) {
world.spatialNodes.set(node.id, node); world.celestials.set(celestial.id, celestial);
}
for (const bubble of delta.localBubbles) {
world.localBubbles.set(bubble.id, bubble);
} }
for (const node of delta.nodes) { for (const node of delta.nodes) {
world.nodes.set(node.id, node); world.nodes.set(node.id, node);
@@ -100,8 +96,7 @@ export function recordDeltaStats(networkStats: NetworkStats, delta: WorldDelta,
const changedEntities = delta.ships.length const changedEntities = delta.ships.length
+ delta.stations.length + delta.stations.length
+ delta.nodes.length + delta.nodes.length
+ delta.spatialNodes.length + delta.celestials.length
+ delta.localBubbles.length
+ delta.claims.length + delta.claims.length
+ delta.constructionSites.length + delta.constructionSites.length
+ delta.marketOrders.length + delta.marketOrders.length

View File

@@ -0,0 +1,47 @@
import * as THREE from "three";
import type { Selectable } from "./viewerTypes";
/**
* System rendering layer.
* Scene coordinate unit: km * DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE.
* Camera far plane covers a solar system.
* Only the active system's objects are visible; inactive system objects are hidden in place.
*/
export class SystemLayer {
readonly scene = new THREE.Scene();
readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 50000);
readonly celestialGroup = new THREE.Group();
readonly nodeGroup = new THREE.Group();
readonly stationGroup = new THREE.Group();
readonly claimGroup = new THREE.Group();
readonly constructionSiteGroup = new THREE.Group();
readonly shipGroup = new THREE.Group();
readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
constructor() {
this.scene.add(new THREE.AmbientLight(0x90a6c0, 0.55));
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3);
keyLight.position.set(1000, 1200, 800);
this.scene.add(keyLight);
this.scene.add(
this.celestialGroup,
this.nodeGroup,
this.stationGroup,
this.claimGroup,
this.constructionSiteGroup,
this.shipGroup,
);
}
updateCamera(systemFocus: THREE.Vector3, orbitOffset: THREE.Vector3) {
this.camera.position.copy(systemFocus).add(orbitOffset);
this.camera.lookAt(systemFocus);
}
onResize(aspect: number) {
this.camera.aspect = aspect;
this.camera.updateProjectionMatrix();
}
}

View File

@@ -1,23 +1,22 @@
import * as THREE from "three"; import * as THREE from "three";
import type { SceneNode } from "./viewerScenePrimitives"; import type { SceneNode } from "./viewerScenePrimitives";
import type { import type {
CelestialSnapshot,
ClaimSnapshot, ClaimSnapshot,
ConstructionSiteSnapshot, ConstructionSiteSnapshot,
FactionSnapshot, FactionSnapshot,
LocalBubbleSnapshot,
MarketOrderSnapshot, MarketOrderSnapshot,
PlanetSnapshot, PlanetSnapshot,
PolicySetSnapshot, PolicySetSnapshot,
ResourceNodeSnapshot, ResourceNodeSnapshot,
ShipSnapshot, ShipSnapshot,
SimulationEventRecord, SimulationEventRecord,
SpatialNodeSnapshot,
StationSnapshot, StationSnapshot,
SystemSnapshot, SystemSnapshot,
OrbitalSimulationSnapshot, OrbitalSimulationSnapshot,
} from "./contracts"; } from "./contracts";
export type ZoomLevel = "local" | "system" | "universe"; export type PovLevel = "local" | "system" | "galaxy";
export type SelectionGroup = "ships" | "structures" | "celestials"; export type SelectionGroup = "ships" | "structures" | "celestials";
export type DragMode = "orbit" | "marquee"; export type DragMode = "orbit" | "marquee";
export type CameraMode = "tactical" | "follow"; export type CameraMode = "tactical" | "follow";
@@ -26,8 +25,7 @@ export type Selectable =
| { kind: "ship"; id: string } | { kind: "ship"; id: string }
| { kind: "station"; id: string } | { kind: "station"; id: string }
| { kind: "node"; id: string } | { kind: "node"; id: string }
| { kind: "spatial-node"; id: string } | { kind: "celestial"; id: string }
| { kind: "bubble"; id: string }
| { kind: "claim"; id: string } | { kind: "claim"; id: string }
| { kind: "construction-site"; id: string } | { kind: "construction-site"; id: string }
| { kind: "system"; id: string } | { kind: "system"; id: string }
@@ -86,26 +84,18 @@ export interface NodeVisual {
orbitInclination: number; orbitInclination: number;
} }
export interface SpatialNodeVisual { export interface CelestialVisual {
id: string; id: string;
systemId: string; systemId: string;
mesh: SceneNode; mesh: SceneNode;
icon: SceneNode; icon: SceneNode;
kind: string; kind: string;
localPosition: THREE.Vector3; orbitalAnchor: THREE.Vector3;
}
export interface BubbleVisual {
id: string;
systemId: string;
mesh: SceneNode;
localPosition: THREE.Vector3;
radius: number;
} }
export interface ClaimVisual { export interface ClaimVisual {
id: string; id: string;
nodeId: string; celestialId: string;
systemId: string; systemId: string;
mesh: SceneNode; mesh: SceneNode;
icon: SceneNode; icon: SceneNode;
@@ -114,7 +104,7 @@ export interface ClaimVisual {
export interface ConstructionSiteVisual { export interface ConstructionSiteVisual {
id: string; id: string;
nodeId: string; celestialId: string;
systemId: string; systemId: string;
mesh: SceneNode; mesh: SceneNode;
icon: SceneNode; icon: SceneNode;
@@ -134,13 +124,13 @@ export interface StructureVisual {
} }
export interface SystemVisual { export interface SystemVisual {
root: SceneNode; galaxyRoot: SceneNode; // lives in galaxyScene (star dot + shell reticle)
systemRoot: SceneNode; // added/removed from systemScene when system becomes active/inactive
starCluster: SceneNode; starCluster: SceneNode;
icon: SceneNode; icon: SceneNode; // star dot sprite (child of galaxyRoot)
shellReticle: SceneNode; shellReticle: SceneNode; // reticle sprite (child of galaxyRoot)
shellReticleBaseScale: number; shellReticleBaseScale: number;
detailGroup: SceneNode; detailGroup: SceneNode; // planets + moons (child of systemRoot)
summary: SystemSummaryVisual;
galaxyPosition: THREE.Vector3; galaxyPosition: THREE.Vector3;
} }
@@ -153,8 +143,7 @@ export interface WorldState {
orbitalSimulation: OrbitalSimulationSnapshot; orbitalSimulation: OrbitalSimulationSnapshot;
generatedAtUtc: string; generatedAtUtc: string;
systems: Map<string, SystemSnapshot>; systems: Map<string, SystemSnapshot>;
spatialNodes: Map<string, SpatialNodeSnapshot>; celestials: Map<string, CelestialSnapshot>;
localBubbles: Map<string, LocalBubbleSnapshot>;
nodes: Map<string, ResourceNodeSnapshot>; nodes: Map<string, ResourceNodeSnapshot>;
stations: Map<string, StationSnapshot>; stations: Map<string, StationSnapshot>;
claims: Map<string, ClaimSnapshot>; claims: Map<string, ClaimSnapshot>;
@@ -195,20 +184,6 @@ export interface PerformanceStats {
lastPanelUpdateAtMs: number; lastPanelUpdateAtMs: number;
} }
export interface PresentationEntry {
detail: SceneNode;
icon: SceneNode;
systemId?: string;
hideDetailInUniverse?: boolean;
hideIconInUniverse?: boolean;
}
export interface SystemSummaryVisual {
sprite: SceneNode;
texture: THREE.CanvasTexture;
anchor: THREE.Vector3;
}
export interface HistoryWindowState { export interface HistoryWindowState {
id: string; id: string;
target: Selectable; target: Selectable;

View File

@@ -0,0 +1,25 @@
import * as THREE from "three";
/**
* Universe rendering layer — always the first layer rendered.
* Contains the infinite backdrop: backdrop stars and nebula clouds.
* Has no dedicated camera; rendered with whichever camera is active for the current POV
* so the backdrop always aligns with the foreground view.
*/
export class UniverseLayer {
readonly scene = new THREE.Scene();
/** Backdrop stars and nebula clouds. Follows the active camera to act as a skybox. */
readonly ambienceGroup = new THREE.Group();
constructor() {
this.scene.background = new THREE.Color(0x040912);
this.scene.add(this.ambienceGroup);
}
updateAmbience(activeCamera: THREE.Camera, delta: number) {
this.ambienceGroup.position.copy(activeCamera.position);
this.ambienceGroup.rotation.y += delta * 0.005;
this.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015;
}
}

View File

@@ -3,19 +3,17 @@ import { renderOpsStrip } from "./viewerOpsStrip";
import { updateDetailPanel } from "./viewerPanels"; import { updateDetailPanel } from "./viewerPanels";
import { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState"; import { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState";
import type { import type {
CelestialDelta,
CelestialSnapshot,
ClaimDelta, ClaimDelta,
ClaimSnapshot, ClaimSnapshot,
ConstructionSiteDelta, ConstructionSiteDelta,
ConstructionSiteSnapshot, ConstructionSiteSnapshot,
FactionSnapshot, FactionSnapshot,
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta, ResourceNodeDelta,
ResourceNodeSnapshot, ResourceNodeSnapshot,
ShipDelta, ShipDelta,
ShipSnapshot, ShipSnapshot,
SpatialNodeDelta,
SpatialNodeSnapshot,
StationDelta, StationDelta,
StationSnapshot, StationSnapshot,
SystemSnapshot, SystemSnapshot,
@@ -27,7 +25,7 @@ import type {
NetworkStats, NetworkStats,
Selectable, Selectable,
WorldState, WorldState,
ZoomLevel, PovLevel,
} from "./viewerTypes"; } from "./viewerTypes";
export interface ViewerWorldLifecycleContext { export interface ViewerWorldLifecycleContext {
@@ -41,7 +39,7 @@ export interface ViewerWorldLifecycleContext {
setStream: (stream: EventSource | undefined) => void; setStream: (stream: EventSource | undefined) => void;
getCurrentStreamScopeKey: () => string; getCurrentStreamScopeKey: () => string;
setCurrentStreamScopeKey: (value: string) => void; setCurrentStreamScopeKey: (value: string) => void;
getZoomLevel: () => ZoomLevel; getPovLevel: () => PovLevel;
getActiveSystemId: () => string | undefined; getActiveSystemId: () => string | undefined;
getSelectedItems: () => Selectable[]; getSelectedItems: () => Selectable[];
getCameraMode: () => CameraMode; getCameraMode: () => CameraMode;
@@ -54,22 +52,20 @@ export interface ViewerWorldLifecycleContext {
detailBodyEl: HTMLDivElement; detailBodyEl: HTMLDivElement;
worldLabel: () => string; worldLabel: () => string;
rebuildSystems: (systems: SystemSnapshot[]) => void; rebuildSystems: (systems: SystemSnapshot[]) => void;
syncSpatialNodes: (nodes: SpatialNodeSnapshot[]) => void; syncCelestials: (celestials: CelestialSnapshot[]) => void;
syncLocalBubbles: (bubbles: LocalBubbleSnapshot[]) => void;
syncNodes: (nodes: ResourceNodeSnapshot[]) => void; syncNodes: (nodes: ResourceNodeSnapshot[]) => void;
syncStations: (stations: StationSnapshot[]) => void; syncStations: (stations: StationSnapshot[]) => void;
syncClaims: (claims: ClaimSnapshot[]) => void; syncClaims: (claims: ClaimSnapshot[]) => void;
syncConstructionSites: (sites: ConstructionSiteSnapshot[]) => void; syncConstructionSites: (sites: ConstructionSiteSnapshot[]) => void;
syncShips: (ships: ShipSnapshot[], tickIntervalMs: number) => void; syncShips: (ships: ShipSnapshot[], tickIntervalMs: number) => void;
applySpatialNodeDeltas: (nodes: SpatialNodeDelta[]) => void; applyCelestialDeltas: (celestials: CelestialDelta[]) => void;
applyLocalBubbleDeltas: (bubbles: LocalBubbleDelta[]) => void;
applyNodeDeltas: (nodes: ResourceNodeDelta[]) => void; applyNodeDeltas: (nodes: ResourceNodeDelta[]) => void;
applyStationDeltas: (stations: StationDelta[]) => void; applyStationDeltas: (stations: StationDelta[]) => void;
applyClaimDeltas: (claims: ClaimDelta[]) => void; applyClaimDeltas: (claims: ClaimDelta[]) => void;
applyConstructionSiteDeltas: (sites: ConstructionSiteDelta[]) => void; applyConstructionSiteDeltas: (sites: ConstructionSiteDelta[]) => void;
applyShipDeltas: (ships: ShipDelta[], tickIntervalMs: number) => void; applyShipDeltas: (ships: ShipDelta[], tickIntervalMs: number) => void;
refreshHistoryWindows: () => void; refreshHistoryWindows: () => void;
resolveFocusedBubbleId: () => string | undefined; resolveFocusedCelestialId: () => string | undefined;
updateSystemSummaries: () => void; updateSystemSummaries: () => void;
applyZoomPresentation: () => void; applyZoomPresentation: () => void;
updateNetworkPanel: () => void; updateNetworkPanel: () => void;
@@ -161,8 +157,7 @@ export class ViewerWorldLifecycle {
this.context.rebuildSystems(snapshot.systems); this.context.rebuildSystems(snapshot.systems);
} }
this.context.syncSpatialNodes(snapshot.spatialNodes); this.context.syncCelestials(snapshot.celestials);
this.context.syncLocalBubbles(snapshot.localBubbles);
this.context.syncNodes(snapshot.nodes); this.context.syncNodes(snapshot.nodes);
this.context.syncStations(snapshot.stations); this.context.syncStations(snapshot.stations);
this.context.syncClaims(snapshot.claims); this.context.syncClaims(snapshot.claims);
@@ -182,8 +177,7 @@ export class ViewerWorldLifecycle {
this.context.setWorldTimeSyncMs(performance.now()); this.context.setWorldTimeSyncMs(performance.now());
applyDeltaToWorld(world, delta); applyDeltaToWorld(world, delta);
this.context.applySpatialNodeDeltas(delta.spatialNodes); this.context.applyCelestialDeltas(delta.celestials);
this.context.applyLocalBubbleDeltas(delta.localBubbles);
this.context.applyNodeDeltas(delta.nodes); this.context.applyNodeDeltas(delta.nodes);
this.context.applyStationDeltas(delta.stations); this.context.applyStationDeltas(delta.stations);
this.context.applyClaimDeltas(delta.claims); this.context.applyClaimDeltas(delta.claims);
@@ -199,7 +193,7 @@ export class ViewerWorldLifecycle {
this.context.getSelectedItems(), this.context.getSelectedItems(),
this.context.getCameraMode(), this.context.getCameraMode(),
this.context.getCameraTargetShipId(), this.context.getCameraTargetShipId(),
this.context.getZoomLevel(), this.context.getPovLevel(),
this.context.getActiveSystemId(), this.context.getActiveSystemId(),
); );
} }
@@ -216,7 +210,7 @@ export class ViewerWorldLifecycle {
updateDetailPanel(this.context.detailTitleEl, this.context.detailBodyEl, { updateDetailPanel(this.context.detailTitleEl, this.context.detailBodyEl, {
world, world,
selectedItems: this.context.getSelectedItems(), selectedItems: this.context.getSelectedItems(),
zoomLevel: this.context.getZoomLevel(), povLevel: this.context.getPovLevel(),
cameraMode: this.context.getCameraMode(), cameraMode: this.context.getCameraMode(),
cameraTargetShipId: this.context.getCameraTargetShipId(), cameraTargetShipId: this.context.getCameraTargetShipId(),
worldLabel: this.context.worldLabel(), worldLabel: this.context.worldLabel(),
@@ -226,16 +220,16 @@ export class ViewerWorldLifecycle {
private getPreferredStreamScope() { private getPreferredStreamScope() {
const activeSystemId = this.context.getActiveSystemId(); const activeSystemId = this.context.getActiveSystemId();
if (this.context.getZoomLevel() === "universe" || !activeSystemId) { if (this.context.getPovLevel() === "galaxy" || !activeSystemId) {
return { scopeKind: "universe" as const }; return { scopeKind: "universe" as const };
} }
const bubbleId = this.context.resolveFocusedBubbleId(); const celestialId = this.context.resolveFocusedCelestialId();
if (this.context.getZoomLevel() === "local" && bubbleId) { if (this.context.getPovLevel() === "local" && celestialId) {
return { return {
scopeKind: "local-bubble" as const, scopeKind: "local-celestial" as const,
systemId: activeSystemId, systemId: activeSystemId,
bubbleId, celestialId,
}; };
} }

View File

@@ -1,42 +1,40 @@
import * as THREE from "three"; import * as THREE from "three";
import { import {
DISPLAY_UNITS_PER_KILOMETER,
DISPLAY_UNITS_PER_LIGHT_YEAR,
KILOMETERS_PER_AU,
computeMoonLocalPosition, computeMoonLocalPosition,
computeMoonSize, computeMoonSize,
computePlanetLocalPosition, computePlanetLocalPosition,
currentWorldTimeSeconds, currentWorldTimeSeconds,
resolveOrbitalAnchorPosition, resolveOrbitalAnchorPosition,
toDisplayGalaxyVector,
toThreeVector, toThreeVector,
} from "./viewerMath"; } from "./viewerMath";
import { describeActiveSpace } from "./viewerSelection"; import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
import { describeActiveSpace, resolveFocusedCelestialId } from "./viewerSelection";
import { import {
resolveShipHeading, resolveShipHeading,
updateSystemStarPresentation, updateSystemStarPresentation,
updateSystemSummaryPresentation,
getAnimatedShipLocalPosition, getAnimatedShipLocalPosition,
} from "./viewerPresentation"; } from "./viewerPresentation";
import { rawObject } from "./viewerScenePrimitives"; import { rawObject } from "./viewerScenePrimitives";
import type { import type {
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta, ResourceNodeDelta,
ResourceNodeSnapshot, ResourceNodeSnapshot,
ShipSnapshot, ShipSnapshot,
} from "./contracts"; } from "./contracts";
import type { import type {
BubbleVisual, CelestialVisual,
ClaimVisual, ClaimVisual,
Selectable, Selectable,
ConstructionSiteVisual, ConstructionSiteVisual,
NodeVisual, NodeVisual,
OrbitalAnchor, OrbitalAnchor,
ShipVisual, ShipVisual,
SpatialNodeVisual,
StructureVisual, StructureVisual,
SystemSummaryVisual,
SystemVisual, SystemVisual,
WorldState, WorldState,
ZoomLevel, PovLevel,
CameraMode, CameraMode,
} from "./viewerTypes"; } from "./viewerTypes";
@@ -47,23 +45,22 @@ export interface WorldOrbitalContext {
worldTimeSyncMs: number; worldTimeSyncMs: number;
worldSeed: number; worldSeed: number;
nodeVisuals: Map<string, NodeVisual>; nodeVisuals: Map<string, NodeVisual>;
spatialNodeVisuals: Map<string, SpatialNodeVisual>; celestialVisuals: Map<string, CelestialVisual>;
bubbleVisuals: Map<string, BubbleVisual>;
stationVisuals: Map<string, StructureVisual>; stationVisuals: Map<string, StructureVisual>;
} }
export interface WorldPresentationContext extends WorldOrbitalContext { export interface WorldPresentationContext extends WorldOrbitalContext {
activeSystemId?: string; activeSystemId?: string;
zoomLevel: ZoomLevel; povLevel: PovLevel;
orbitYaw: number; orbitYaw: number;
camera: THREE.PerspectiveCamera; camera: THREE.PerspectiveCamera;
systemFocusLocal: THREE.Vector3; systemAnchor: THREE.Vector3;
shipVisuals: Map<string, ShipVisual>; shipVisuals: Map<string, ShipVisual>;
claimVisuals: Map<string, ClaimVisual>; claimVisuals: Map<string, ClaimVisual>;
constructionSiteVisuals: Map<string, ConstructionSiteVisual>; constructionSiteVisuals: Map<string, ConstructionSiteVisual>;
systemVisuals: Map<string, SystemVisual>; systemVisuals: Map<string, SystemVisual>;
systemSummaryVisuals: Map<string, SystemSummaryVisual>; systemSummaryVisuals: Map<string, any>;
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3; toDisplayLocalPosition: (localPosition: THREE.Vector3) => THREE.Vector3;
updateSystemDetailVisibility: () => void; updateSystemDetailVisibility: () => void;
setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], opacity: number) => void; setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], opacity: number) => void;
} }
@@ -74,15 +71,17 @@ export interface GameStatusParams {
world?: WorldState; world?: WorldState;
activeSystemId?: string; activeSystemId?: string;
cameraMode: CameraMode; cameraMode: CameraMode;
zoomLevel: ZoomLevel; povLevel: PovLevel;
selectedItems: Selectable[]; selectedItems: Selectable[];
mode: string; mode: string;
galaxyAnchor?: THREE.Vector3;
systemAnchor?: THREE.Vector3;
} }
export function updateWorldPresentation(context: WorldPresentationContext) { export function updateWorldPresentation(context: WorldPresentationContext) {
const now = performance.now(); const now = performance.now();
const worldTimeSeconds = currentWorldTimeSeconds(context.world, context.worldTimeSyncMs); 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()) { for (const [shipId, visual] of context.shipVisuals.entries()) {
const ship = context.world?.ships.get(shipId); const ship = context.world?.ships.get(shipId);
@@ -91,7 +90,7 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
} }
const worldPosition = getAnimatedShipLocalPosition(visual, now); const worldPosition = getAnimatedShipLocalPosition(visual, now);
const displayPosition = resolveShipWorldPosition(context, ship, visual, worldPosition); const displayPosition = context.toDisplayLocalPosition(worldPosition);
visual.mesh.setPosition(displayPosition); visual.mesh.setPosition(displayPosition);
visual.icon.setPosition(rawObject(visual.mesh).position.clone()); visual.icon.setPosition(rawObject(visual.mesh).position.clone());
const shipVisible = isShipVisible(renderMode, context.activeSystemId, ship); const shipVisible = isShipVisible(renderMode, context.activeSystemId, ship);
@@ -105,67 +104,51 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
for (const visual of context.nodeVisuals.values()) { for (const visual of context.nodeVisuals.values()) {
const animatedLocalPosition = computeNodeLocalPosition(context, visual, worldTimeSeconds); 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.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId); visual.mesh.setVisible(visual.systemId === context.activeSystemId);
} }
for (const visual of context.spatialNodeVisuals.values()) { for (const visual of context.celestialVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPosition(context, visual, worldTimeSeconds); const animatedLocalPosition = computeCelestialLocalPosition(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.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId); visual.mesh.setVisible(visual.systemId === context.activeSystemId);
visual.icon.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()) { for (const visual of context.stationVisuals.values()) {
const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds); 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.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId); visual.mesh.setVisible(visual.systemId === context.activeSystemId);
} }
for (const visual of context.claimVisuals.values()) { for (const visual of context.claimVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone(); const animatedLocalPosition = computeCelestialLocalPositionById(context, visual.celestialId, worldTimeSeconds) ?? visual.localPosition.clone();
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
visual.icon.setPosition(rawObject(visual.mesh).position.clone()); visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId); visual.mesh.setVisible(visual.systemId === context.activeSystemId);
visual.icon.setVisible(visual.systemId === context.activeSystemId); visual.icon.setVisible(visual.systemId === context.activeSystemId);
} }
for (const visual of context.constructionSiteVisuals.values()) { for (const visual of context.constructionSiteVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone(); const animatedLocalPosition = computeCelestialLocalPositionById(context, visual.celestialId, worldTimeSeconds) ?? visual.localPosition.clone();
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
visual.icon.setPosition(rawObject(visual.mesh).position.clone()); visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId); visual.mesh.setVisible(visual.systemId === context.activeSystemId);
visual.icon.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"; type RenderSpaceMode = "galaxy" | "system" | "local";
function resolveRenderSpaceMode(activeSystemId: string | undefined, zoomLevel: ZoomLevel): RenderSpaceMode { function resolveRenderSpaceMode(activeSystemId: string | undefined, povLevel: PovLevel): RenderSpaceMode {
if (!activeSystemId || zoomLevel === "universe") { if (!activeSystemId || povLevel === "galaxy") {
return "galaxy"; return "galaxy";
} }
return zoomLevel === "local" ? "local" : "system"; return povLevel === "local" ? "local" : "system";
} }
function isShipVisible(mode: RenderSpaceMode, activeSystemId: string | undefined, ship: ShipSnapshot) { function isShipVisible(mode: RenderSpaceMode, activeSystemId: string | undefined, ship: ShipSnapshot) {
@@ -186,22 +169,11 @@ export function resolveShipWorldPosition(
visual: ShipVisual, visual: ShipVisual,
animatedLocalPosition = getAnimatedShipLocalPosition(visual), animatedLocalPosition = getAnimatedShipLocalPosition(visual),
) { ) {
if (ship.spatialState.movementRegime === "ftl-transit") { // FTL ships are invisible in system scene; just return their last known local position.
const destinationNodeId = ship.spatialState.transit?.destinationNodeId; return context.toDisplayLocalPosition(animatedLocalPosition);
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);
} }
export function updateSystemSummaries(world: WorldState | undefined, systemSummaryVisuals: Map<string, SystemSummaryVisual>) { export function updateSystemSummaries(world: WorldState | undefined, systemSummaryVisuals: Map<string, any>) {
if (!world) { if (!world) {
return; return;
} }
@@ -275,26 +247,50 @@ export function renderRecentEvents(world: WorldState | undefined, entityKind: st
.join("<br>"); .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) { 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 sequence = world?.sequence ?? 0;
const generatedAt = world?.generatedAtUtc const generatedAt = world?.generatedAtUtc
? new Date(world.generatedAtUtc).toLocaleTimeString() ? new Date(world.generatedAtUtc).toLocaleTimeString()
: "n/a"; : "n/a";
const displayZoomLevel = activeSystemId ? zoomLevel : "universe"; const displayPovLevel = activeSystemId ? povLevel : "galaxy";
const activeSpace = describeActiveSpace(world, displayZoomLevel, activeSystemId, selectedItems); const activeSpace = describeActiveSpace(world, displayPovLevel, activeSystemId, selectedItems);
const cameraModeLabel = cameraMode === "follow" ? "follow" : "map"; 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 = [ statusEl.textContent = [
`mode: ${mode}`, `mode: ${mode}`,
`camera: ${cameraModeLabel}`, `camera: ${cameraModeLabel}`,
`zoom: ${displayZoomLevel}`, `zoom: ${displayPovLevel}`,
`space: ${activeSpace}`, `space: ${activeSpace}`,
galPos,
sysPos,
locPos,
`sequence: ${sequence}`, `sequence: ${sequence}`,
`snapshot: ${generatedAt}`, `snapshot: ${generatedAt}`,
].join("\n"); ].filter(Boolean).join("\n");
if (summaryEl) { 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; return bestAnchor;
} }
export function resolvePointPosition(context: WorldOrbitalContext, _systemId: string, nodeId?: string | null) { export function resolvePointPosition(context: WorldOrbitalContext, _systemId: string, celestialId?: string | null) {
if (nodeId) { if (celestialId) {
const spatialNode = context.world?.spatialNodes.get(nodeId); const celestial = context.world?.celestials.get(celestialId);
if (spatialNode) { if (celestial) {
return toThreeVector(spatialNode.localPosition); return toThreeVector(celestial.orbitalAnchor);
} }
} }
return new THREE.Vector3(0, 0, 0); return new THREE.Vector3(0, 0, 0);
} }
export function resolveBubblePosition(context: WorldOrbitalContext, bubble: LocalBubbleSnapshot | LocalBubbleDelta) { export function computeCelestialLocalPosition(context: WorldOrbitalContext, visual: CelestialVisual, timeSeconds: number) {
return resolvePointPosition(context, bubble.systemId, bubble.nodeId); return computeCelestialLocalPositionById(context, visual.id, timeSeconds) ?? visual.orbitalAnchor.clone();
} }
export function computeSpatialNodeLocalPosition(context: WorldOrbitalContext, visual: SpatialNodeVisual, timeSeconds: number) { export function computeCelestialLocalPositionById(
return computeSpatialNodeLocalPositionById(context, visual.id, timeSeconds) ?? visual.localPosition.clone();
}
export function computeSpatialNodeLocalPositionById(
context: WorldOrbitalContext, context: WorldOrbitalContext,
nodeId: string, celestialId: string,
timeSeconds: number, timeSeconds: number,
visiting = new Set<string>(), visiting = new Set<string>(),
): THREE.Vector3 | undefined { ): THREE.Vector3 | undefined {
if (!context.world || visiting.has(nodeId)) { if (!context.world || visiting.has(celestialId)) {
return undefined; return undefined;
} }
const node = context.world.spatialNodes.get(nodeId); const celestial = context.world.celestials.get(celestialId);
if (!node) { if (!celestial) {
return undefined; return undefined;
} }
const basePosition = toThreeVector(node.localPosition); const basePosition = toThreeVector(celestial.orbitalAnchor);
if (!node.parentNodeId) { if (!celestial.parentNodeId) {
return basePosition; return basePosition;
} }
const parentNode = context.world.spatialNodes.get(node.parentNodeId); const parentCelestial = context.world.celestials.get(celestial.parentNodeId);
if (!parentNode) { if (!parentCelestial) {
return basePosition; return basePosition;
} }
visiting.add(nodeId); visiting.add(celestialId);
const parentCurrentPosition = computeSpatialNodeLocalPositionById(context, node.parentNodeId, timeSeconds, visiting); const parentCurrentPosition = computeCelestialLocalPositionById(context, celestial.parentNodeId, timeSeconds, visiting);
visiting.delete(nodeId); visiting.delete(celestialId);
if (!parentCurrentPosition) { if (!parentCurrentPosition) {
return basePosition; return basePosition;
} }
const parentInitialPosition = toThreeVector(parentNode.localPosition); const parentInitialPosition = toThreeVector(parentCelestial.orbitalAnchor);
const relativeOffset = basePosition.clone().sub(parentInitialPosition); const relativeOffset = basePosition.clone().sub(parentInitialPosition);
const initialAngle = Math.atan2(parentInitialPosition.z, parentInitialPosition.x); const initialAngle = Math.atan2(parentInitialPosition.z, parentInitialPosition.x);
const currentAngle = Math.atan2(parentCurrentPosition.z, parentCurrentPosition.x); const currentAngle = Math.atan2(parentCurrentPosition.z, parentCurrentPosition.x);
@@ -431,13 +423,6 @@ export function computeSpatialNodeLocalPositionById(
return parentCurrentPosition.clone().add(rotatedOffset); 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( function drawCountIcon(
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
kind: SummaryIconKind, kind: SummaryIconKind,
@@ -504,24 +489,15 @@ function getOrbitalAnchorPosition(context: WorldOrbitalContext, systemId: string
return resolveOrbitalAnchorPosition(context.world, systemId, anchor, timeSeconds, context.worldSeed); 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) { function resolveStructureAnimatedLocalPosition(context: WorldOrbitalContext, visual: StructureVisual, timeSeconds: number) {
if (!context.world) { if (!context.world) {
return visual.localPosition.clone(); return visual.localPosition.clone();
} }
const station = context.world.stations.get(visual.id); const station = context.world.stations.get(visual.id);
if (!station?.nodeId) { if (!station?.celestialId) {
return computeStructureLocalPosition(context, visual, timeSeconds, 0.14); 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();
} }