feat: 3 scene rendering setup

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

View File

@@ -2,49 +2,15 @@ import * as THREE from "three";
import {
MAX_CAMERA_DISTANCE,
MIN_CAMERA_DISTANCE,
ZOOM_DISTANCE,
NAV_DISTANCE,
} from "./viewerConstants";
import { createViewerHud } from "./viewerHud";
import {
classifyZoomLevel,
computeZoomBlend,
formatBytes,
inventoryAmount,
smoothBand,
} from "./viewerMath";
import { updatePanFromKeyboard } from "./viewerCamera";
import {
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 { setShellReticleOpacity } from "./viewerControls";
import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop";
import { updatePlanetPresentation } from "./viewerPresentation";
import {
renderRecentEvents,
updateGameStatus,
updateSystemSummaries,
updateWorldPresentation,
} from "./viewerWorldPresentation";
import {
resolveFocusedBubbleId,
} from "./viewerSelection";
import { describeSelectionParent, updateSystemPanel } from "./viewerPanels";
import { updateSystemStarPresentation } from "./viewerPresentation";
import { resolveFocusedCelestialId } from "./viewerSelection";
import { describeSelectionParent } from "./viewerPanels";
import {
createInitialNetworkStats,
createInitialPerformanceStats,
@@ -55,68 +21,65 @@ import { ViewerNavigationController } from "./viewerNavigationController";
import { ViewerSceneDataController } from "./viewerSceneDataController";
import { ViewerPresentationController } from "./viewerPresentationController";
import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory";
import type { SceneNode } from "./viewerScenePrimitives";
import type { FactionSnapshot, ShipSnapshot } from "./contracts";
import { toDisplayLocalPosition, getSystemCameraFocus } from "./viewerCamera";
import { UniverseLayer } from "./viewerUniverseLayer";
import { GalaxyLayer } from "./viewerGalaxyLayer";
import { SystemLayer } from "./viewerSystemLayer";
import { LocalLayer } from "./viewerLocalLayer";
import type { FactionSnapshot } from "./contracts";
import type {
BubbleVisual,
CelestialVisual,
CameraMode,
ClaimVisual,
ConstructionSiteVisual,
DragMode,
HistoryWindowState,
MoonVisual,
NetworkStats,
NodeVisual,
OrbitLineVisual,
OrbitalAnchor,
PerformanceStats,
PlanetVisual,
PresentationEntry,
Selectable,
ShipVisual,
SpatialNodeVisual,
StructureVisual,
SystemSummaryVisual,
SystemVisual,
WorldState,
ZoomLevel,
PovLevel,
} from "./viewerTypes";
export class ViewerAppController {
private readonly container: HTMLElement;
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 raycaster = new THREE.Raycaster();
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 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 selectableTargets = new Map<THREE.Object3D, Selectable>();
private readonly presentationEntries: PresentationEntry[] = [];
private readonly nodeVisuals = new Map<string, NodeVisual>();
private readonly spatialNodeVisuals = new Map<string, SpatialNodeVisual>();
private readonly bubbleVisuals = new Map<string, BubbleVisual>();
private readonly celestialVisuals = new Map<string, CelestialVisual>();
private readonly stationVisuals = new Map<string, StructureVisual>();
private readonly claimVisuals = new Map<string, ClaimVisual>();
private readonly constructionSiteVisuals = new Map<string, ConstructionSiteVisual>();
private readonly shipVisuals = new Map<string, ShipVisual>();
private readonly systemVisuals = new Map<string, SystemVisual>();
private readonly systemSummaryVisuals = new Map<string, SystemSummaryVisual>();
private readonly planetVisuals: PlanetVisual[] = [];
private readonly nodeVisuals = new Map<string, NodeVisual>();
private readonly planetVisuals: any[] = [];
private readonly orbitLines: OrbitLineVisual[] = [];
private readonly statusEl: HTMLDivElement;
private readonly gameSummaryEl: HTMLSpanElement;
private readonly systemPanelEl: HTMLDivElement;
@@ -145,9 +108,9 @@ export class ViewerAppController {
private selectedItems: Selectable[] = [];
private worldSignature = "";
private zoomLevel: ZoomLevel = "system";
private currentDistance = ZOOM_DISTANCE.system;
private desiredDistance = ZOOM_DISTANCE.system;
private povLevel: PovLevel = "system";
private currentDistance = NAV_DISTANCE.system;
private desiredDistance = NAV_DISTANCE.system;
private orbitYaw = -2.3;
private orbitPitch = 0.62;
private cameraMode: CameraMode = "tactical";
@@ -181,23 +144,7 @@ export class ViewerAppController {
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
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);
this.gamePanelEl = hud.gamePanelEl;
this.statusEl = hud.statusEl;
@@ -263,12 +210,11 @@ export class ViewerAppController {
return this.sceneDataController.createWorldPresentationContext({
world: this.world,
activeSystemId: this.activeSystemId,
zoomLevel: this.zoomLevel,
povLevel: this.povLevel,
orbitYaw: this.orbitYaw,
camera: this.camera,
systemFocusLocal: this.systemFocusLocal,
toDisplayLocalPosition: this.toDisplayLocalPosition.bind(this),
updateSystemDetailVisibility: () => this.navigationController.updateSystemDetailVisibility(),
systemCamera: this.systemLayer.camera,
systemAnchor: this.systemAnchor,
toDisplayLocalPosition: (localPosition) => toDisplayLocalPosition(localPosition),
setShellReticleOpacity: (sprite, opacity) => this.setShellReticleOpacity(sprite, opacity),
});
}
@@ -285,8 +231,14 @@ export class ViewerAppController {
renderFrame({
clock: this.clock,
renderer: this.renderer,
scene: this.scene,
camera: this.camera,
universeScene: this.universeLayer.scene,
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),
updateAmbience: (delta) => this.presentationController.updateAmbience(delta),
updatePlanetPresentation: () => this.presentationController.updatePlanetPresentation(),
@@ -298,10 +250,13 @@ export class ViewerAppController {
});
}
private updateAmbience(delta: number) {
this.ambienceGroup.position.copy(this.camera.position);
this.ambienceGroup.rotation.y += delta * 0.005;
this.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015;
private computeOrbitOffset(): THREE.Vector3 {
const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch);
return new THREE.Vector3(
Math.cos(this.orbitYaw) * horizontalDistance,
this.currentDistance * Math.sin(this.orbitPitch),
Math.sin(this.orbitYaw) * horizontalDistance,
);
}
private updateCamera(delta: number) {
@@ -312,25 +267,38 @@ export class ViewerAppController {
delta,
});
this.currentDistance = nextState.currentDistance;
this.zoomLevel = nextState.zoomLevel;
this.povLevel = nextState.povLevel;
this.orbitPitch = nextState.orbitPitch;
this.navigationController.updateActiveSystem();
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;
}
this.updatePanFromKeyboard(delta);
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3);
const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch);
const focus = this.navigationController.getCameraFocusWorldPosition();
this.cameraOffset.set(
Math.cos(this.orbitYaw) * horizontalDistance,
this.currentDistance * Math.sin(this.orbitPitch),
Math.sin(this.orbitYaw) * horizontalDistance,
const orbitOffset = this.computeOrbitOffset();
this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset);
if (this.activeSystemId) {
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) {
@@ -338,10 +306,10 @@ export class ViewerAppController {
this.keyState,
this.orbitYaw,
this.currentDistance,
this.zoomLevel,
this.povLevel,
this.activeSystemId,
this.systemFocusLocal,
this.galaxyFocus,
this.systemAnchor,
this.galaxyAnchor,
delta,
MIN_CAMERA_DISTANCE,
MAX_CAMERA_DISTANCE,
@@ -352,16 +320,6 @@ export class ViewerAppController {
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) {
return this.presentationController.renderRecentEvents(entityKind, entityId);
}
@@ -378,14 +336,16 @@ export class ViewerAppController {
this.interactionController.refreshHistoryWindows();
}
private resolveFocusedBubbleId() {
return resolveFocusedBubbleId(this.world, this.selectedItems);
private resolveFocusedCelestialId() {
return resolveFocusedCelestialId(this.world, this.selectedItems);
}
private onResize = () => {
resizeViewer({
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) {
return this.navigationController.toDisplayLocalPosition(localPosition, systemId);
return toDisplayLocalPosition(localPosition);
}
private updateSystemPanel() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import * as THREE from "three";
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, ZOOM_DISTANCE } from "./viewerConstants";
import { scaleGalaxyVector, toDisplayGalaxyVector, toThreeVector } from "./viewerMath";
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE } from "./viewerConstants";
import { scaleGalaxyVector, toThreeVector } from "./viewerMath";
import { rawObject } from "./viewerScenePrimitives";
import { resolveShipWorldPosition } from "./viewerWorldPresentation";
import type {
@@ -9,7 +9,6 @@ import type {
ShipVisual,
SystemVisual,
WorldState,
ZoomLevel,
} from "./viewerTypes";
export function syncFollowStateFromSelection(
@@ -89,7 +88,7 @@ export function updateFollowCamera(params: {
followCameraDirection: THREE.Vector3;
followCameraDesiredDirection: THREE.Vector3;
followCameraOffset: THREE.Vector3;
systemFocusLocal: THREE.Vector3;
systemAnchor: THREE.Vector3;
delta: number;
getAnimatedShipLocalPosition: (visual: ShipVisual) => THREE.Vector3;
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
@@ -106,7 +105,7 @@ export function updateFollowCamera(params: {
followCameraDirection,
followCameraDesiredDirection,
followCameraOffset,
systemFocusLocal,
systemAnchor,
delta,
getAnimatedShipLocalPosition,
toDisplayLocalPosition,
@@ -143,10 +142,10 @@ export function updateFollowCamera(params: {
);
if (ship.spatialState.movementRegime === "ftl-transit") {
systemFocusLocal.set(0, 0, 0);
systemAnchor.set(0, 0, 0);
const destinationNodeId = ship.spatialState.transit?.destinationNodeId;
const destinationNode = destinationNodeId ? world.spatialNodes.get(destinationNodeId) : undefined;
const destinationSystem = destinationNode ? world.systems.get(destinationNode.systemId) : undefined;
const destinationCelestial = destinationNodeId ? world.celestials.get(destinationNodeId) : undefined;
const destinationSystem = destinationCelestial ? world.systems.get(destinationCelestial.systemId) : undefined;
const originSystem = world.systems.get(ship.systemId);
if (originSystem && destinationSystem) {
followCameraDesiredDirection
@@ -154,7 +153,7 @@ export function updateFollowCamera(params: {
.normalize();
}
} else {
systemFocusLocal.lerp(shipLocalPosition, 1 - Math.exp(-delta * 8));
systemAnchor.lerp(shipLocalPosition, 1 - Math.exp(-delta * 8));
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) {
sprite.setVisible(opacity > 0.02);
const material = (rawObject(sprite) as THREE.Sprite).material;
@@ -204,7 +196,7 @@ export function setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opa
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 zoomFactor = Math.exp(clampedDelta * 0.00135);
return THREE.MathUtils.clamp(desiredDistance * zoomFactor, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE);
@@ -226,21 +218,21 @@ export function applyKeyboardControl(params: {
cameraMode = "tactical";
}
if (key === "1") {
desiredDistance = ZOOM_DISTANCE.local;
desiredDistance = NAV_DISTANCE.local;
} else if (key === "2") {
desiredDistance = ZOOM_DISTANCE.system;
desiredDistance = NAV_DISTANCE.system;
} else if (key === "3") {
desiredDistance = ZOOM_DISTANCE.universe;
desiredDistance = NAV_DISTANCE.galaxy;
} else if (key === "=") {
desiredDistance = desiredDistance <= ZOOM_DISTANCE.system
? ZOOM_DISTANCE.local
: ZOOM_DISTANCE.system;
desiredDistance = desiredDistance <= NAV_DISTANCE.system
? NAV_DISTANCE.local
: NAV_DISTANCE.system;
} else if (key === "-") {
desiredDistance = desiredDistance >= ZOOM_DISTANCE.system
? ZOOM_DISTANCE.universe
: ZOOM_DISTANCE.system;
desiredDistance = desiredDistance >= NAV_DISTANCE.system
? NAV_DISTANCE.galaxy
: NAV_DISTANCE.system;
} else if (key === "/") {
desiredDistance = ZOOM_DISTANCE.system;
desiredDistance = NAV_DISTANCE.system;
}
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 { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
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 {
selection: Selectable;
object: THREE.Object3D;
/** Which camera was used for this pick (for distance calculation) */
camera: THREE.Camera;
}
export function pickSelectableAtClientPosition(
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(
function pickOneCamera(
renderer: THREE.WebGLRenderer,
raycaster: THREE.Raycaster,
mouse: THREE.Vector2,
@@ -38,29 +27,61 @@ export function pickSelectableHitAtClientPosition(
const hit = raycaster.intersectObjects([...selectableTargets.keys()], false)[0];
const selection = hit ? selectableTargets.get(hit.object) : undefined;
return hit && selection
? { selection, object: hit.object }
? { selection, object: hit.object, camera }
: 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: {
dragMode?: string;
hoverLabelEl: HTMLDivElement;
hoverPick: HoverPickResult | undefined;
activeSystemId?: string;
zoomLevel: ZoomLevel;
povLevel: PovLevel;
world?: WorldState;
point: THREE.Vector2;
camera: THREE.Camera;
}) {
const {
dragMode,
hoverLabelEl,
hoverPick,
activeSystemId,
zoomLevel,
povLevel,
world,
point,
camera,
} = params;
if (dragMode) {
@@ -73,14 +94,14 @@ export function updateHoverLabel(params: {
return;
}
const { selection, object } = hoverPick;
const { selection, object, camera } = hoverPick;
const label = describeHoverLabel(world, selection);
if (!label) {
hoverLabelEl.hidden = true;
return;
}
const distance = formatHoverDistance(camera, object, selection, zoomLevel, activeSystemId);
const distance = formatHoverDistance(camera, object, selection, povLevel, activeSystemId);
hoverLabelEl.hidden = false;
hoverLabelEl.textContent = `${label}\n${distance}`;
@@ -92,7 +113,7 @@ function formatHoverDistance(
camera: THREE.Camera,
object: THREE.Object3D,
selection: Selectable,
zoomLevel: ZoomLevel,
povLevel: PovLevel,
activeSystemId?: string,
) {
const worldPosition = object.getWorldPosition(new THREE.Vector3());
@@ -107,14 +128,13 @@ function formatHoverDistance(
: selection.kind === "ship"
|| selection.kind === "station"
|| selection.kind === "node"
|| selection.kind === "spatial-node"
|| selection.kind === "bubble"
|| selection.kind === "celestial"
|| selection.kind === "claim"
|| selection.kind === "construction-site";
if (inActiveSystem && activeSystemId) {
const kilometers = displayDistance / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
return zoomLevel === "system"
return povLevel === "system"
? formatSystemDistance(kilometers / KILOMETERS_PER_AU)
: formatAdaptiveDistanceFromKilometers(kilometers);
}
@@ -145,17 +165,17 @@ export function hideMarqueeBox(marqueeEl: HTMLDivElement) {
export function completeMarqueeSelection(params: {
renderer: THREE.WebGLRenderer;
camera: THREE.Camera;
systemCamera: THREE.Camera;
dragStart: THREE.Vector2;
dragLast: THREE.Vector2;
selectableTargets: Map<THREE.Object3D, Selectable>;
systemSelectableTargets: Map<THREE.Object3D, Selectable>;
}) {
const {
renderer,
camera,
systemCamera,
dragStart,
dragLast,
selectableTargets,
systemSelectableTargets,
} = params;
const bounds = renderer.domElement.getBoundingClientRect();
@@ -165,7 +185,7 @@ export function completeMarqueeSelection(params: {
const maxY = Math.max(dragStart.y, dragLast.y);
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) {
continue;
}
@@ -175,7 +195,7 @@ export function completeMarqueeSelection(params: {
const worldPosition = new THREE.Vector3();
object.getWorldPosition(worldPosition);
worldPosition.project(camera);
worldPosition.project(systemCamera);
const screenX = ((worldPosition.x + 1) * 0.5) * bounds.width;
const screenY = ((1 - worldPosition.y) * 0.5) * bounds.height;
if (screenX < minX || screenX > maxX || screenY < minY || screenY > maxY) {

View File

@@ -10,7 +10,7 @@ import {
import {
applyKeyboardControl,
toggleCameraMode,
zoomFromWheel,
navigateFromWheel,
} from "./viewerControls";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import type {
@@ -18,21 +18,23 @@ import type {
DragMode,
Selectable,
WorldState,
ZoomLevel,
PovLevel,
} from "./viewerTypes";
export interface ViewerInteractionContext {
renderer: THREE.WebGLRenderer;
raycaster: THREE.Raycaster;
mouse: THREE.Vector2;
camera: THREE.PerspectiveCamera;
selectableTargets: Map<THREE.Object3D, Selectable>;
galaxyCamera: THREE.PerspectiveCamera;
systemCamera: THREE.PerspectiveCamera;
galaxySelectableTargets: Map<THREE.Object3D, Selectable>;
systemSelectableTargets: Map<THREE.Object3D, Selectable>;
hoverLabelEl: HTMLDivElement;
marqueeEl: HTMLDivElement;
keyState: Set<string>;
getWorld: () => WorldState | undefined;
getActiveSystemId: () => string | undefined;
getZoomLevel: () => ZoomLevel;
getPovLevel: () => PovLevel;
getSelectedItems: () => Selectable[];
setSelectedItems: (items: Selectable[]) => void;
getDragMode: () => DragMode | undefined;
@@ -235,7 +237,7 @@ export class ViewerInteractionController {
readonly onWheel = (event: WheelEvent) => {
event.preventDefault();
this.context.setDesiredDistance(zoomFromWheel(this.context.getDesiredDistance(), event.deltaY));
this.context.setDesiredDistance(navigateFromWheel(this.context.getDesiredDistance(), event.deltaY));
this.context.updateGamePanel("Live");
};
@@ -269,10 +271,9 @@ export class ViewerInteractionController {
hoverLabelEl: this.context.hoverLabelEl,
hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY),
activeSystemId: this.context.getActiveSystemId(),
zoomLevel: this.context.getZoomLevel(),
povLevel: this.context.getPovLevel(),
world: this.context.getWorld(),
point: this.context.screenPointFromClient(event.clientX, event.clientY),
camera: this.context.camera,
});
}
@@ -300,8 +301,10 @@ export class ViewerInteractionController {
this.context.renderer,
this.context.raycaster,
this.context.mouse,
this.context.camera,
this.context.selectableTargets,
this.context.galaxyCamera,
this.context.galaxySelectableTargets,
this.context.systemCamera,
this.context.systemSelectableTargets,
clientX,
clientY,
);
@@ -312,8 +315,10 @@ export class ViewerInteractionController {
this.context.renderer,
this.context.raycaster,
this.context.mouse,
this.context.camera,
this.context.selectableTargets,
this.context.galaxyCamera,
this.context.galaxySelectableTargets,
this.context.systemCamera,
this.context.systemSelectableTargets,
clientX,
clientY,
);
@@ -322,10 +327,10 @@ export class ViewerInteractionController {
private completeMarqueeSelection() {
const selection = completeMarqueeSelection({
renderer: this.context.renderer,
camera: this.context.camera,
systemCamera: this.context.systemCamera,
dragStart: this.context.dragStart,
dragLast: this.context.dragLast,
selectableTargets: this.context.selectableTargets,
systemSelectableTargets: this.context.systemSelectableTargets,
});
this.context.setSelectedItems(selection);
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 {
OrbitalAnchor,
WorldState,
ZoomLevel,
PovLevel,
} from "./viewerTypes";
import type { ZoomBlend } from "./viewerConstants";
@@ -112,19 +112,19 @@ export function computeZoomBlend(distance: number): ZoomBlend {
return {
localWeight: 1 - localToSystem,
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);
if (blend.localWeight >= blend.systemWeight && blend.localWeight >= blend.universeWeight) {
if (blend.localWeight >= blend.systemWeight && blend.localWeight >= blend.galaxyWeight) {
return "local";
}
if (blend.systemWeight >= blend.universeWeight) {
if (blend.systemWeight >= blend.galaxyWeight) {
return "system";
}
return "universe";
return "galaxy";
}
export function toThreeVector(vector: Vector3Dto): THREE.Vector3 {

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
import * as THREE from "three";
import { ACTIVE_SYSTEM_DETAIL_SCALE, PROJECTED_GALAXY_RADIUS } from "./viewerConstants";
import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds, scaleLocalVector } from "./viewerMath";
import { rawObject } from "./viewerScenePrimitives";
import type { PlanetVisual, ShipVisual, SystemSummaryVisual, SystemVisual, WorldState } from "./viewerTypes";
import type { PlanetVisual, ShipVisual, SystemVisual, WorldState } from "./viewerTypes";
export function getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) {
const elapsedMs = now - visual.receivedAtMs;
@@ -26,23 +25,17 @@ export function resolveShipHeading(visual: ShipVisual, worldPosition: THREE.Vect
export function updatePlanetPresentation(
world: WorldState | undefined,
worldTimeSyncMs: number,
activeSystemId: string | undefined,
systemFocusLocal: THREE.Vector3,
planetVisuals: PlanetVisual[],
) {
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) {
const scale = visual.systemId === activeSystemId ? ACTIVE_SYSTEM_DETAIL_SCALE : 1;
const localPosition = scaleLocalVector(computePlanetLocalPosition(visual.planet, nowSeconds));
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);
const position = scaleLocalVector(computePlanetLocalPosition(visual.planet, nowSeconds))
.multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE);
visual.orbit.setScaleScalar(scale);
visual.orbit.setPosition(orbitOffset);
visual.orbit.setScaleScalar(ACTIVE_SYSTEM_DETAIL_SCALE);
visual.orbit.setPosition(new THREE.Vector3(0, 0, 0));
visual.mesh.setPosition(position);
visual.icon.setPosition(position);
if (visual.ring) {
@@ -51,56 +44,45 @@ export function updatePlanetPresentation(
for (const [moonIndex, moon] of visual.moons.entries()) {
moon.orbit.setPosition(position);
moon.orbit.setScaleScalar(scale);
moon.orbit.setScaleScalar(ACTIVE_SYSTEM_DETAIL_SCALE);
moon.mesh.setPosition(
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(
systemVisuals: Map<string, SystemVisual>,
activeSystemId: string | undefined,
systemFocusLocal: THREE.Vector3,
camera: THREE.PerspectiveCamera,
galaxyCamera: THREE.PerspectiveCamera,
setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], opacity: number) => void,
) {
const activeSystem = activeSystemId ? systemVisuals.get(activeSystemId) : undefined;
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);
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.setVisible(true);
visual.shellReticle.setPosition(new THREE.Vector3(0, 0, 0));
visual.shellReticle.setVisible(false);
setShellReticleOpacity(visual.shellReticle, 0);
const dotWorldPos = visual.icon.getWorldPosition(new THREE.Vector3());
visual.icon.setScaleScalar(galaxyCamera.position.distanceTo(dotWorldPos) * 0.01);
continue;
}
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.setVisible(false);
visual.shellReticle.setPosition(new THREE.Vector3(0, 0, 0));
@@ -108,20 +90,19 @@ export function updateSystemStarPresentation(
setShellReticleOpacity(visual.shellReticle, 1);
const direction = visual.galaxyPosition.clone().sub(activeSystem.galaxyPosition);
if (direction.lengthSq() > 0.0001) {
visual.root.setPosition(
visual.galaxyRoot.setPosition(
activeSystem.galaxyPosition.clone().add(direction.normalize().multiplyScalar(PROJECTED_GALAXY_RADIUS)),
);
}
const reticleWorldPosition = visual.root.getWorldPosition(new THREE.Vector3());
const reticleDistance = camera.position.distanceTo(reticleWorldPosition);
const reticleWorldPosition = visual.galaxyRoot.getWorldPosition(new THREE.Vector3());
const reticleDistance = galaxyCamera.position.distanceTo(reticleWorldPosition);
const reticleScale = Math.max(900, reticleDistance * 0.032);
visual.shellReticle.setScaleScalar(reticleScale);
continue;
}
const offset = systemFocusLocal.clone().multiplyScalar(-ACTIVE_SYSTEM_DETAIL_SCALE);
visual.starCluster.setPosition(offset);
visual.icon.setPosition(offset);
// Active system in galaxy view: show star dot, hide shell reticle
visual.icon.setPosition(new THREE.Vector3(0, 0, 0));
visual.icon.setVisible(true);
visual.shellReticle.setVisible(false);
setShellReticleOpacity(visual.shellReticle, 0);

View File

@@ -14,8 +14,11 @@ import type { OrbitLineVisual, Selectable } from "./viewerTypes";
export interface ViewerPresentationContext {
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
galaxyScene: THREE.Scene;
galaxyCamera: THREE.PerspectiveCamera;
systemCamera: THREE.PerspectiveCamera;
galaxyAnchor: THREE.Vector3;
systemAnchor: THREE.Vector3;
ambienceGroup: THREE.Group;
gameSummaryEl: HTMLSpanElement;
networkSummaryEl: HTMLSpanElement;
@@ -32,14 +35,11 @@ export interface ViewerPresentationContext {
getActiveSystemId: () => string | undefined;
getCameraMode: () => any;
getCameraTargetShipId: () => string | undefined;
getZoomLevel: () => any;
getPovLevel: () => any;
getSelectedItems: () => Selectable[];
getWorldTimeSyncMs: () => number;
getCurrentDistance: () => number;
systemFocusLocal: THREE.Vector3;
planetVisuals: any[];
systemSummaryVisuals: Map<any, any>;
presentationEntries: any[];
orbitLines: OrbitLineVisual[];
systemVisuals: Map<any, any>;
createWorldPresentationContext: () => any;
@@ -55,43 +55,25 @@ export class ViewerPresentationController {
}
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.x = Math.sin(performance.now() * 0.00003) * 0.015;
}
applyZoomPresentation() {
const activeSystemId = this.context.getActiveSystemId();
const zoomLevel = this.context.getZoomLevel();
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);
}
const povLevel = this.context.getPovLevel();
// Orbit lines: only show for active system in system/local zoom
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);
}
for (const [systemId, summaryVisual] of this.context.systemSummaryVisuals.entries()) {
const summaryOpacity = isUniverse
? 0.96
: 0;
summaryVisual.sprite.setOpacity(summaryOpacity);
}
this.context.scene.fog = new THREE.FogExp2(0x040912, 0.000035);
this.context.galaxyScene.fog = new THREE.FogExp2(0x040912, 0.000035);
}
updateNetworkPanel() {
@@ -117,14 +99,12 @@ export class ViewerPresentationController {
updatePlanetPresentation(
world,
this.context.getWorldTimeSyncMs(),
this.context.getActiveSystemId(),
this.context.systemFocusLocal,
this.context.planetVisuals,
);
}
updateSystemSummaries() {
updateSystemSummaries(this.context.getWorld(), this.context.systemSummaryVisuals);
updateSystemSummaries(this.context.getWorld(), new Map());
}
renderRecentEvents(entityKind: string, entityId: string) {
@@ -138,9 +118,11 @@ export class ViewerPresentationController {
world: this.context.getWorld(),
activeSystemId: this.context.getActiveSystemId(),
cameraMode: this.context.getCameraMode(),
zoomLevel: this.context.getZoomLevel(),
povLevel: this.context.getPovLevel(),
selectedItems: this.context.getSelectedItems(),
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);
}
private resolveOrbitLineOpacity(orbitLine: OrbitLineVisual, zoomLevel: "local" | "system" | "universe", activeSystemId?: string) {
if (zoomLevel === "universe" || !activeSystemId || orbitLine.systemId !== activeSystemId) {
private resolveOrbitLineOpacity(orbitLine: OrbitLineVisual, povLevel: "local" | "system" | "galaxy", activeSystemId?: string) {
if (povLevel === "galaxy" || !activeSystemId || orbitLine.systemId !== activeSystemId) {
return 0;
}
const selected = this.context.getSelectedItems();
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) {
return orbitLine.kind === "moon" && orbitLine.planetIndex === selectedItem.planetIndex

View File

@@ -1,12 +1,18 @@
import * as THREE from "three";
import { classifyZoomLevel } from "./viewerMath";
import type { PerformanceStats } from "./viewerTypes";
import { classifyPovLevel } from "./viewerMath";
import type { PovLevel, PerformanceStats } from "./viewerTypes";
export interface RenderFrameParams {
clock: THREE.Clock;
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
universeScene: THREE.Scene;
galaxyScene: THREE.Scene;
galaxyCamera: THREE.PerspectiveCamera;
systemScene: THREE.Scene;
systemCamera: THREE.PerspectiveCamera;
localScene: THREE.Scene;
localCamera: THREE.PerspectiveCamera;
getPovLevel: () => PovLevel;
updateCamera: (delta: number) => void;
updateAmbience: (delta: number) => void;
updatePlanetPresentation: () => void;
@@ -19,7 +25,9 @@ export interface RenderFrameParams {
export interface ResizeParams {
renderer: THREE.WebGLRenderer;
camera: THREE.PerspectiveCamera;
galaxyCamera: THREE.PerspectiveCamera;
systemCamera: THREE.PerspectiveCamera;
localCamera: THREE.PerspectiveCamera;
}
export interface CameraStepParams {
@@ -38,7 +46,26 @@ export function renderFrame(params: RenderFrameParams) {
params.updateShipPresentation();
params.updateNetworkPanel();
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.updatePerformancePanel();
}
@@ -46,14 +73,16 @@ export function renderFrame(params: RenderFrameParams) {
export function resizeViewer(params: ResizeParams) {
const width = window.innerWidth;
const height = window.innerHeight;
params.camera.aspect = width / height;
params.camera.updateProjectionMatrix();
for (const camera of [params.galaxyCamera, params.systemCamera, params.localCamera]) {
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
params.renderer.setSize(width, height);
}
export function stepCamera(params: CameraStepParams) {
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);
return { currentDistance, zoomLevel, orbitPitch };
return { currentDistance, povLevel, orbitPitch };
}

View File

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

View File

@@ -1,55 +1,49 @@
import * as THREE from "three";
import {
applyCelestialDeltas as applyCelestialDeltaUpdates,
applyClaimDeltas as applyClaimDeltaUpdates,
applyConstructionSiteDeltas as applyConstructionSiteDeltaUpdates,
applyLocalBubbleDeltas as applyLocalBubbleDeltaUpdates,
applyNodeDeltas as applyNodeDeltaUpdates,
applyShipDeltas as applyShipDeltaUpdates,
applySpatialNodeDeltas as applySpatialNodeDeltaUpdates,
applyStationDeltas as applyStationDeltaUpdates,
rebuildSystems as rebuildSystemScene,
syncCelestials as syncCelestialScene,
syncClaims as syncClaimScene,
syncConstructionSites as syncConstructionSiteScene,
syncLocalBubbles as syncBubbleScene,
syncNodes as syncNodeScene,
syncShips as syncShipScene,
syncSpatialNodes as syncSpatialNodeScene,
syncStations as syncStationScene,
} from "./viewerSceneSync";
import {
deriveNodeOrbital,
deriveOrbitalFromLocalPosition,
resolveBubblePosition,
resolveOrbitalAnchor,
resolvePointPosition,
setBubbleVisualState,
} from "./viewerWorldPresentation";
import {
createCirclePoints,
shipLength,
shipPresentationColor,
shipSize,
spatialNodeColor,
celestialColor,
} from "./viewerSceneAppearance";
import type {
CelestialDelta,
CelestialSnapshot,
ClaimDelta,
ClaimSnapshot,
ConstructionSiteDelta,
ConstructionSiteSnapshot,
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
ShipDelta,
ShipSnapshot,
SpatialNodeDelta,
SpatialNodeSnapshot,
StationDelta,
StationSnapshot,
SystemSnapshot,
} from "./contracts";
import type { OrbitLineVisual, OrbitalAnchor } from "./viewerTypes";
import type { SceneNode } from "./viewerScenePrimitives";
import type { OrbitLineVisual, OrbitalAnchor, Selectable } from "./viewerTypes";
import { rawObject } from "./viewerScenePrimitives";
export interface ViewerSceneDataContext {
documentRef: Document;
@@ -58,102 +52,162 @@ export interface ViewerSceneDataContext {
getWorldSeed: () => number;
getWorldTimeSyncMs: () => number;
getWorldPresentationContext: () => any;
systemGroup: THREE.Group;
spatialNodeGroup: THREE.Group;
bubbleGroup: THREE.Group;
getActiveSystemId: () => string | undefined;
galaxySystemGroup: THREE.Group;
systemScene: THREE.Scene;
celestialGroup: THREE.Group;
nodeGroup: THREE.Group;
stationGroup: THREE.Group;
claimGroup: THREE.Group;
constructionSiteGroup: THREE.Group;
shipGroup: THREE.Group;
selectableTargets: Map<any, any>;
presentationEntries: any[];
galaxySelectableTargets: Map<THREE.Object3D, Selectable>;
systemSelectableTargets: Map<THREE.Object3D, Selectable>;
systemVisuals: Map<any, any>;
systemSummaryVisuals: Map<any, any>;
planetVisuals: any[];
orbitLines: OrbitLineVisual[];
spatialNodeVisuals: Map<any, any>;
bubbleVisuals: Map<any, any>;
celestialVisuals: Map<any, any>;
nodeVisuals: Map<any, any>;
stationVisuals: Map<any, any>;
claimVisuals: Map<any, any>;
constructionSiteVisuals: Map<any, any>;
shipVisuals: Map<any, any>;
registerPresentation: (detail: SceneNode, icon: SceneNode, hideDetailInUniverse: boolean, hideIconInUniverse?: boolean, systemId?: string) => void;
}
export class ViewerSceneDataController {
private activeSystemRootInScene: THREE.Object3D | undefined;
constructor(private readonly context: ViewerSceneDataContext) {}
rebuildSystems(systems: SystemSnapshot[]) {
this.activeSystemRootInScene = undefined;
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[]) {
syncSpatialNodeScene(this.createSceneSyncContext(), nodes);
}
syncLocalBubbles(bubbles: LocalBubbleSnapshot[]) {
syncBubbleScene(this.createSceneSyncContext(), bubbles);
syncCelestials(celestials: CelestialSnapshot[]) {
syncCelestialScene(this.createSceneSyncContext(), celestials, this.context.getActiveSystemId());
}
syncNodes(nodes: ResourceNodeSnapshot[]) {
syncNodeScene(this.createSceneSyncContext(), nodes);
syncNodeScene(this.createSceneSyncContext(), nodes, this.context.getActiveSystemId());
}
syncStations(stations: StationSnapshot[]) {
syncStationScene(this.createSceneSyncContext(), stations);
syncStationScene(this.createSceneSyncContext(), stations, this.context.getActiveSystemId());
}
syncClaims(claims: ClaimSnapshot[]) {
syncClaimScene(this.createSceneSyncContext(), claims);
syncClaimScene(this.createSceneSyncContext(), claims, this.context.getActiveSystemId());
}
syncConstructionSites(sites: ConstructionSiteSnapshot[]) {
syncConstructionSiteScene(this.createSceneSyncContext(), sites);
syncConstructionSiteScene(this.createSceneSyncContext(), sites, this.context.getActiveSystemId());
}
syncShips(ships: ShipSnapshot[], tickIntervalMs: number) {
syncShipScene(this.createSceneSyncContext(), ships, tickIntervalMs);
syncShipScene(this.createSceneSyncContext(), ships, tickIntervalMs, this.context.getActiveSystemId());
}
applySpatialNodeDeltas(nodes: SpatialNodeDelta[]) {
applySpatialNodeDeltaUpdates(this.createSceneSyncContext(), nodes);
}
applyLocalBubbleDeltas(bubbles: LocalBubbleDelta[]) {
applyLocalBubbleDeltaUpdates(this.createSceneSyncContext(), bubbles);
applyCelestialDeltas(celestials: CelestialDelta[]) {
applyCelestialDeltaUpdates(this.createSceneSyncContext(), celestials, this.context.getActiveSystemId());
}
applyNodeDeltas(nodes: ResourceNodeDelta[]) {
applyNodeDeltaUpdates(this.createSceneSyncContext(), nodes);
applyNodeDeltaUpdates(this.createSceneSyncContext(), nodes, this.context.getActiveSystemId());
}
applyStationDeltas(stations: StationDelta[]) {
applyStationDeltaUpdates(this.createSceneSyncContext(), stations);
applyStationDeltaUpdates(this.createSceneSyncContext(), stations, this.context.getActiveSystemId());
}
applyClaimDeltas(claims: ClaimDelta[]) {
applyClaimDeltaUpdates(this.createSceneSyncContext(), claims);
applyClaimDeltaUpdates(this.createSceneSyncContext(), claims, this.context.getActiveSystemId());
}
applyConstructionSiteDeltas(sites: ConstructionSiteDelta[]) {
applyConstructionSiteDeltaUpdates(this.createSceneSyncContext(), sites);
applyConstructionSiteDeltaUpdates(this.createSceneSyncContext(), sites, this.context.getActiveSystemId());
}
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: {
world: any;
activeSystemId?: string;
zoomLevel: any;
povLevel: any;
orbitYaw: number;
camera: THREE.PerspectiveCamera;
systemFocusLocal: THREE.Vector3;
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
updateSystemDetailVisibility: () => void;
systemCamera: THREE.PerspectiveCamera;
systemAnchor: THREE.Vector3;
toDisplayLocalPosition: (localPosition: THREE.Vector3) => THREE.Vector3;
setShellReticleOpacity: (sprite: any, opacity: number) => void;
}) {
return {
@@ -161,21 +215,20 @@ export class ViewerSceneDataController {
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
worldSeed: this.context.getWorldSeed(),
activeSystemId: overrides.activeSystemId,
zoomLevel: overrides.zoomLevel,
povLevel: overrides.povLevel,
orbitYaw: overrides.orbitYaw,
camera: overrides.camera,
systemFocusLocal: overrides.systemFocusLocal,
camera: overrides.systemCamera,
systemAnchor: overrides.systemAnchor,
shipVisuals: this.context.shipVisuals,
nodeVisuals: this.context.nodeVisuals,
spatialNodeVisuals: this.context.spatialNodeVisuals,
bubbleVisuals: this.context.bubbleVisuals,
celestialVisuals: this.context.celestialVisuals,
stationVisuals: this.context.stationVisuals,
claimVisuals: this.context.claimVisuals,
constructionSiteVisuals: this.context.constructionSiteVisuals,
systemVisuals: this.context.systemVisuals,
systemSummaryVisuals: this.context.systemSummaryVisuals,
systemSummaryVisuals: new Map(),
toDisplayLocalPosition: overrides.toDisplayLocalPosition,
updateSystemDetailVisibility: overrides.updateSystemDetailVisibility,
updateSystemDetailVisibility: () => {},
setShellReticleOpacity: overrides.setShellReticleOpacity,
};
}
@@ -187,39 +240,33 @@ export class ViewerSceneDataController {
orbitalSimulationSpeed: this.context.getOrbitalSimulationSpeed(),
worldSeed: this.context.getWorldSeed(),
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
systemGroup: this.context.systemGroup,
spatialNodeGroup: this.context.spatialNodeGroup,
bubbleGroup: this.context.bubbleGroup,
galaxySystemGroup: this.context.galaxySystemGroup,
celestialGroup: this.context.celestialGroup,
nodeGroup: this.context.nodeGroup,
stationGroup: this.context.stationGroup,
claimGroup: this.context.claimGroup,
constructionSiteGroup: this.context.constructionSiteGroup,
shipGroup: this.context.shipGroup,
selectableTargets: this.context.selectableTargets,
presentationEntries: this.context.presentationEntries,
galaxySelectableTargets: this.context.galaxySelectableTargets,
systemSelectableTargets: this.context.systemSelectableTargets,
systemVisuals: this.context.systemVisuals,
systemSummaryVisuals: this.context.systemSummaryVisuals,
planetVisuals: this.context.planetVisuals,
orbitLines: this.context.orbitLines,
spatialNodeVisuals: this.context.spatialNodeVisuals,
bubbleVisuals: this.context.bubbleVisuals,
celestialVisuals: this.context.celestialVisuals,
nodeVisuals: this.context.nodeVisuals,
stationVisuals: this.context.stationVisuals,
claimVisuals: this.context.claimVisuals,
constructionSiteVisuals: this.context.constructionSiteVisuals,
shipVisuals: this.context.shipVisuals,
registerPresentation: this.context.registerPresentation,
shipSize,
shipLength,
shipPresentationColor,
spatialNodeColor,
celestialColor,
createCirclePoints,
resolveBubblePosition: (bubble: LocalBubbleSnapshot | LocalBubbleDelta) => resolveBubblePosition(this.context.getWorldPresentationContext(), bubble),
resolvePointPosition: (systemId: string, nodeId?: string | null) => resolvePointPosition(this.context.getWorldPresentationContext(), systemId, nodeId),
resolvePointPosition: (systemId: string, celestialId?: string | null) => resolvePointPosition(this.context.getWorldPresentationContext(), systemId, celestialId),
resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => resolveOrbitalAnchor(this.context.getWorldPresentationContext(), systemId, localPosition),
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),
setBubbleVisualState,
};
}
}

View File

@@ -5,17 +5,16 @@ import {
STAR_RENDER_SCALE,
} from "./viewerConstants";
import type {
CelestialSnapshot,
ClaimSnapshot,
ConstructionSiteSnapshot,
LocalBubbleSnapshot,
PlanetSnapshot,
ResourceNodeSnapshot,
ShipSnapshot,
SpatialNodeSnapshot,
StationSnapshot,
SystemSnapshot,
} from "./contracts";
import type { MoonVisual, SystemSummaryVisual } from "./viewerTypes";
import type { MoonVisual } from "./viewerTypes";
import {
celestialRenderRadius,
computeMoonOrbitRadius,
@@ -46,10 +45,10 @@ export function createNodeMesh(node: ResourceNodeSnapshot): SceneNode {
return createSceneNode(mesh);
}
export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColor: (kind: string) => string): SceneNode {
const color = spatialNodeColor(node.kind);
export function createCelestialMesh(node: CelestialSnapshot, celestialColor: (kind: string) => string): SceneNode {
const color = celestialColor(node.kind);
return createSceneNode(new THREE.Mesh(
new THREE.OctahedronGeometry(10, 0),
new THREE.OctahedronGeometry(0.08, 0),
new THREE.MeshStandardMaterial({
color,
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 {
return createSceneNode(new THREE.Mesh(
new THREE.ConeGeometry(9, 20, 4),
@@ -363,20 +345,34 @@ export function createTacticalIcon(documentRef: Document, color: string, size: n
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");
canvas.width = 512;
canvas.height = 160;
canvas.width = 32;
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 sprite = createSceneNode(new THREE.Sprite(new THREE.SpriteMaterial({
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthWrite: false,
depthTest: false,
})));
sprite.object.scale.set(520, 160, 1);
sprite.setVisible(false);
return { sprite, texture, anchor };
color: "#ffffff",
fog: false,
}));
sprite.scale.setScalar(4);
sprite.visible = false;
return createSceneNode(sprite);
}
export function createShellReticle(documentRef: Document, color: string, size: number): SceneNode {

View File

@@ -1,36 +1,33 @@
import * as THREE from "three";
import {
ACTIVE_SYSTEM_DETAIL_SCALE,
PLANET_RENDER_SCALE,
STAR_RENDER_SCALE,
} from "./viewerConstants";
import { DISPLAY_UNITS_PER_KILOMETER } from "./viewerMath";
import type {
BubbleVisual,
CelestialVisual,
ClaimVisual,
ConstructionSiteVisual,
NodeVisual,
OrbitLineVisual,
PlanetVisual,
PresentationEntry,
Selectable,
ShipVisual,
SpatialNodeVisual,
StructureVisual,
SystemSummaryVisual,
SystemVisual,
} from "./viewerTypes";
import type {
CelestialDelta,
CelestialSnapshot,
ClaimDelta,
ClaimSnapshot,
ConstructionSiteDelta,
ConstructionSiteSnapshot,
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
ShipDelta,
ShipSnapshot,
SpatialNodeDelta,
SpatialNodeSnapshot,
StationDelta,
StationSnapshot,
SystemSnapshot,
@@ -45,7 +42,6 @@ import {
} from "./viewerMath";
import { getAnimatedShipLocalPosition } from "./viewerPresentation";
import {
createBubbleRing,
createClaimMesh,
createConstructionSiteMesh,
createMoonVisuals,
@@ -54,10 +50,10 @@ import {
createPlanetRing,
createShellReticle,
createShipMesh,
createSpatialNodeMesh,
createCelestialMesh,
createStarCluster,
createStarDot,
createStationMesh,
createSystemSummaryVisual,
createTacticalIcon,
} from "./viewerSceneFactory";
import {
@@ -68,47 +64,41 @@ import {
} 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 {
documentRef: Document;
worldOrbitalTimeSeconds?: number;
orbitalSimulationSpeed: number;
worldSeed: number;
worldTimeSyncMs: number;
systemGroup: THREE.Group;
spatialNodeGroup: THREE.Group;
bubbleGroup: THREE.Group;
galaxySystemGroup: THREE.Group;
celestialGroup: THREE.Group;
nodeGroup: THREE.Group;
stationGroup: THREE.Group;
claimGroup: THREE.Group;
constructionSiteGroup: THREE.Group;
shipGroup: THREE.Group;
selectableTargets: Map<THREE.Object3D, Selectable>;
presentationEntries: PresentationEntry[];
galaxySelectableTargets: Map<THREE.Object3D, Selectable>;
systemSelectableTargets: Map<THREE.Object3D, Selectable>;
systemVisuals: Map<string, SystemVisual>;
systemSummaryVisuals: Map<string, SystemSummaryVisual>;
planetVisuals: PlanetVisual[];
orbitLines: OrbitLineVisual[];
spatialNodeVisuals: Map<string, SpatialNodeVisual>;
bubbleVisuals: Map<string, BubbleVisual>;
celestialVisuals: Map<string, CelestialVisual>;
nodeVisuals: Map<string, NodeVisual>;
stationVisuals: Map<string, StructureVisual>;
claimVisuals: Map<string, ClaimVisual>;
constructionSiteVisuals: Map<string, ConstructionSiteVisual>;
shipVisuals: Map<string, ShipVisual>;
registerPresentation: (
detail: SceneNode,
icon: SceneNode,
hideDetailInUniverse: boolean,
hideIconInUniverse?: boolean,
systemId?: string,
) => void;
shipSize: (ship: ShipSnapshot) => number;
shipLength: (ship: ShipSnapshot) => number;
shipPresentationColor: (ship: ShipSnapshot) => string;
spatialNodeColor: (kind: string) => string;
celestialColor: (kind: string) => string;
createCirclePoints: (radius: number, segments: number) => THREE.Vector3[];
resolveBubblePosition: (bubble: LocalBubbleSnapshot | LocalBubbleDelta) => THREE.Vector3;
resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3;
resolvePointPosition: (systemId: string, celestialId?: string | null) => THREE.Vector3;
resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => NodeVisual["anchor"];
deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: NodeVisual["anchor"]) => {
radius: number;
@@ -120,7 +110,6 @@ interface SceneSyncContext {
phase: number;
inclination: number;
};
setBubbleVisualState: (visual: BubbleVisual, bubble: LocalBubbleSnapshot | LocalBubbleDelta) => void;
}
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)
: 0;
context.systemGroup.clear();
context.selectableTargets.clear();
context.presentationEntries.length = 0;
context.galaxySystemGroup.clear();
context.galaxySelectableTargets.clear();
context.systemSelectableTargets.clear();
context.planetVisuals.length = 0;
context.orbitLines.length = 0;
context.systemVisuals.clear();
context.systemSummaryVisuals.clear();
for (const system of systems) {
const root = createSceneNode(new THREE.Group());
root.setPosition(toDisplayGalaxyVector(system.galaxyPosition));
const detailGroup = createSceneNode(new THREE.Group());
const renderedStarSize = celestialRenderRadius(system.starSize, 0.00018, 0.16, 0.62);
// Galaxy root: star dot + shell reticle — lives in galaxyScene
const galaxyRoot = createSceneNode(new THREE.Group());
galaxyRoot.setPosition(toDisplayGalaxyVector(system.galaxyPosition));
const starCluster = createStarCluster(system);
const systemIcon = createTacticalIcon(context.documentRef, system.starColor, 96);
const systemIcon = createStarDot(context.documentRef, system.starColor);
const shellReticle = createShellReticle(context.documentRef, "#ff3b30", 400);
const summaryVisual = createSystemSummaryVisual(
context.documentRef,
toDisplayGalaxyVector(system.galaxyPosition).add(new THREE.Vector3(0, renderedStarSize + 140, 0)),
);
summaryVisual.sprite.setPosition(new THREE.Vector3(0, renderedStarSize + 110, 0));
root.add(starCluster, systemIcon, shellReticle, summaryVisual.sprite, detailGroup);
context.registerPresentation(starCluster, systemIcon, true);
context.systemVisuals.set(system.id, {
root,
galaxyRoot.add(systemIcon, shellReticle);
registerSelectableTarget(context.galaxySelectableTargets, systemIcon, { kind: "system", id: system.id });
registerSelectableTarget(context.galaxySelectableTargets, shellReticle, { kind: "system", id: system.id });
// System root: star cluster + planet detail group — added to systemScene only when this system is active
const systemRoot = createSceneNode(new THREE.Group());
const detailGroup = createSceneNode(new THREE.Group());
const starCluster = createStarCluster(system);
systemRoot.add(starCluster, detailGroup);
registerSelectableDescendants(
context.systemSelectableTargets,
starCluster,
icon: systemIcon,
shellReticle,
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 });
{ kind: "system", id: system.id },
(child) => child instanceof THREE.Mesh,
);
for (const [planetIndex, planet] of system.planets.entries()) {
const orbit = createPlanetOrbit(planet);
@@ -179,12 +161,13 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
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));
planetIcon.setPosition(rawObject(planetMesh).position.clone());
planetIcon.setPosition(initialPos);
const ring = planet.hasRing ? createPlanetRing(planet) : undefined;
if (ring) {
ring.setPosition(rawObject(planetMesh).position.clone());
ring.setPosition(initialPos);
}
const moons = createMoonVisuals(planet, context.worldSeed);
detailGroup.add(orbit, planetMesh, planetIcon);
@@ -194,8 +177,8 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
for (const moon of moons) {
moon.systemId = system.id;
moon.planetIndex = planetIndex;
moon.orbit.setPosition(rawObject(planetMesh).position.clone());
moon.mesh.setPosition(rawObject(planetMesh).position.clone());
moon.orbit.setPosition(initialPos);
moon.mesh.setPosition(initialPos);
detailGroup.add(moon.orbit, moon.mesh);
context.orbitLines.push({
line: moon.orbit,
@@ -203,7 +186,6 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
kind: "moon",
planetIndex,
});
context.registerPresentation(moon.mesh, planetIcon, true, true, system.id);
}
context.orbitLines.push({
line: orbit,
@@ -211,68 +193,73 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
kind: "planet",
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 });
registerSelectableTarget(context.selectableTargets, planetMesh, { kind: "planet", systemId: system.id, planetIndex });
registerSelectableTarget(context.selectableTargets, planetIcon, { kind: "planet", systemId: system.id, planetIndex });
registerSelectableTarget(context.systemSelectableTargets, planetMesh, { 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[]) {
context.spatialNodeGroup.clear();
context.spatialNodeVisuals.clear();
export function syncCelestials(context: SceneSyncContext, celestials: CelestialSnapshot[], activeSystemId?: string) {
context.celestialGroup.clear();
context.celestialVisuals.clear();
for (const node of nodes) {
const mesh = createSpatialNodeMesh(node, context.spatialNodeColor);
const icon = createTacticalIcon(context.documentRef, context.spatialNodeColor(node.kind), 18);
const localPosition = toThreeVector(node.localPosition);
mesh.setPosition(localPosition);
icon.setPosition(localPosition);
context.spatialNodeVisuals.set(node.id, {
id: node.id,
systemId: node.systemId,
for (const celestial of celestials) {
// Stars, planets, and moons are already rendered by rebuildSystems via SystemSnapshot.
// Only create visual objects for kinds not covered by the system builder.
if (celestial.kind === "star" || celestial.kind === "planet" || celestial.kind === "moon") {
continue;
}
const mesh = createCelestialMesh(celestial, context.celestialColor);
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,
icon,
kind: node.kind,
localPosition,
kind: celestial.kind,
orbitalAnchor,
});
context.spatialNodeGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, node.systemId);
registerSelectableTarget(context.selectableTargets, mesh, { kind: "spatial-node", id: node.id });
registerSelectableTarget(context.selectableTargets, icon, { kind: "spatial-node", id: node.id });
context.celestialGroup.add(rawObject(mesh), rawObject(icon));
registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "celestial", id: celestial.id });
registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "celestial", id: celestial.id });
}
}
export function syncLocalBubbles(context: SceneSyncContext, bubbles: LocalBubbleSnapshot[]) {
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[]) {
export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot[], activeSystemId?: string) {
context.nodeGroup.clear();
context.nodeVisuals.clear();
for (const node of nodes) {
const mesh = createNodeMesh(node);
const icon = createTacticalIcon(context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20);
icon.setPosition(rawObject(mesh).position.clone());
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 orbital = context.deriveNodeOrbital(node, anchor);
context.nodeVisuals.set(node.id, {
@@ -287,21 +274,25 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot
orbitInclination: orbital.inclination,
});
context.nodeGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, node.systemId);
registerSelectableTarget(context.selectableTargets, mesh, { kind: "node", id: node.id });
registerSelectableTarget(context.selectableTargets, icon, { kind: "node", id: node.id });
registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "node", id: node.id });
registerSelectableTarget(context.systemSelectableTargets, 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.stationVisuals.clear();
for (const station of stations) {
const mesh = createStationMesh(station);
const icon = createTacticalIcon(context.documentRef, station.color, 26);
icon.setPosition(rawObject(mesh).position.clone());
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 orbital = context.deriveOrbitalFromLocalPosition(localPosition, station.systemId, anchor);
context.stationVisuals.set(station.id, {
@@ -316,63 +307,68 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho
localPosition,
});
context.stationGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, station.systemId);
registerSelectableTarget(context.selectableTargets, mesh, { kind: "station", id: station.id });
registerSelectableTarget(context.selectableTargets, icon, { kind: "station", id: station.id });
registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "station", id: station.id });
registerSelectableTarget(context.systemSelectableTargets, 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.claimVisuals.clear();
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 icon = createTacticalIcon(context.documentRef, "#ff5b5b", 18);
mesh.setPosition(localPosition);
icon.setPosition(localPosition);
mesh.setPosition(displayPos);
icon.setPosition(displayPos);
const isActive = claim.systemId === activeSystemId;
mesh.setVisible(isActive);
icon.setVisible(isActive);
context.claimVisuals.set(claim.id, {
id: claim.id,
nodeId: claim.nodeId,
celestialId: claim.celestialId,
systemId: claim.systemId,
mesh,
icon,
localPosition,
});
context.claimGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, claim.systemId);
registerSelectableTarget(context.selectableTargets, mesh, { kind: "claim", id: claim.id });
registerSelectableTarget(context.selectableTargets, icon, { kind: "claim", id: claim.id });
registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "claim", id: claim.id });
registerSelectableTarget(context.systemSelectableTargets, 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.constructionSiteVisuals.clear();
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 icon = createTacticalIcon(context.documentRef, "#9df29c", 18);
mesh.setPosition(localPosition);
icon.setPosition(localPosition);
mesh.setPosition(displayPos);
icon.setPosition(displayPos);
const isActive = site.systemId === activeSystemId;
mesh.setVisible(isActive);
icon.setVisible(isActive);
context.constructionSiteVisuals.set(site.id, {
id: site.id,
nodeId: site.nodeId,
celestialId: site.celestialId,
systemId: site.systemId,
mesh,
icon,
localPosition,
});
context.constructionSiteGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, site.systemId);
registerSelectableTarget(context.selectableTargets, mesh, { kind: "construction-site", id: site.id });
registerSelectableTarget(context.selectableTargets, icon, { kind: "construction-site", id: site.id });
registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "construction-site", id: site.id });
registerSelectableTarget(context.systemSelectableTargets, 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.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 shipColor = context.shipPresentationColor(ship);
const icon = createTacticalIcon(context.documentRef, shipColor, 18);
const position = toThreeVector(ship.localPosition);
icon.setPosition(position);
const localPosition = toThreeVector(ship.localPosition);
const displayPos = toSystemPos(localPosition);
mesh.setPosition(displayPos);
icon.setPosition(displayPos);
icon.setColor(shipColor);
const isActive = ship.systemId === activeSystemId;
mesh.setVisible(isActive);
icon.setVisible(isActive);
context.shipGroup.add(rawObject(mesh), rawObject(icon));
registerSelectableTarget(context.selectableTargets, mesh, { kind: "ship", id: ship.id });
registerSelectableTarget(context.selectableTargets, icon, { kind: "ship", id: ship.id });
context.registerPresentation(mesh, icon, true, true, ship.systemId);
registerSelectableTarget(context.systemSelectableTargets, mesh, { kind: "ship", id: ship.id });
registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "ship", id: ship.id });
context.shipVisuals.set(ship.id, {
systemId: ship.systemId,
mesh,
icon,
startPosition: position.clone(),
authoritativePosition: position.clone(),
startPosition: localPosition.clone(),
authoritativePosition: localPosition.clone(),
targetPosition: toThreeVector(ship.targetLocalPosition),
velocity: toThreeVector(ship.localVelocity),
receivedAtMs: performance.now(),
@@ -401,39 +401,30 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick
}
}
export function applySpatialNodeDeltas(context: SceneSyncContext, nodes: SpatialNodeDelta[]) {
for (const node of nodes) {
const visual = context.spatialNodeVisuals.get(node.id);
export function applyCelestialDeltas(context: SceneSyncContext, celestials: CelestialDelta[], activeSystemId?: string) {
for (const celestial of celestials) {
if (celestial.kind === "star" || celestial.kind === "planet" || celestial.kind === "moon") {
continue;
}
const visual = context.celestialVisuals.get(celestial.id);
if (!visual) {
continue;
}
visual.systemId = node.systemId;
visual.kind = node.kind;
visual.localPosition.copy(toThreeVector(node.localPosition));
visual.mesh.setPosition(visual.localPosition);
visual.icon.setPosition(visual.localPosition);
visual.mesh.setColor(context.spatialNodeColor(node.kind));
visual.systemId = celestial.systemId;
visual.kind = celestial.kind;
visual.orbitalAnchor.copy(toSystemPos(toThreeVector(celestial.orbitalAnchor)));
visual.mesh.setPosition(visual.orbitalAnchor);
visual.icon.setPosition(visual.orbitalAnchor);
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[]) {
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[]) {
export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDelta[], activeSystemId?: string) {
for (const node of nodes) {
const visual = context.nodeVisuals.get(node.id);
if (!visual) {
@@ -449,10 +440,13 @@ export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDe
visual.orbitPhase = orbital.phase;
visual.orbitInclination = orbital.inclination;
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) {
const visual = context.stationVisuals.get(station.id);
if (!visual) {
@@ -468,10 +462,13 @@ export function applyStationDeltas(context: SceneSyncContext, stations: StationD
visual.orbitInclination = orbital.inclination;
visual.mesh.setColor(station.color);
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) {
const visual = context.claimVisuals.get(claim.id);
if (!visual) {
@@ -479,15 +476,19 @@ export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[]
}
visual.systemId = claim.systemId;
visual.localPosition.copy(context.resolvePointPosition(claim.systemId, claim.nodeId));
visual.mesh.setPosition(visual.localPosition);
visual.icon.setPosition(visual.localPosition);
visual.localPosition.copy(context.resolvePointPosition(claim.systemId, claim.celestialId));
const displayPos = toSystemPos(visual.localPosition);
visual.mesh.setPosition(displayPos);
visual.icon.setPosition(displayPos);
visual.mesh.setColor(claim.state === "active" ? "#ff7f50" : "#ff5b5b");
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) {
const visual = context.constructionSiteVisuals.get(site.id);
if (!visual) {
@@ -495,15 +496,19 @@ export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: Co
}
visual.systemId = site.systemId;
visual.localPosition.copy(context.resolvePointPosition(site.systemId, site.nodeId));
visual.mesh.setPosition(visual.localPosition);
visual.icon.setPosition(visual.localPosition);
visual.localPosition.copy(context.resolvePointPosition(site.systemId, site.celestialId));
const displayPos = toSystemPos(visual.localPosition);
visual.mesh.setPosition(displayPos);
visual.icon.setPosition(displayPos);
visual.mesh.setColor(site.state === "completed" ? "#46d37f" : "#9df29c");
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) {
const visual = context.shipVisuals.get(ship.id);
if (!visual) {
@@ -521,5 +526,8 @@ export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], t
visual.mesh.setColor(shipColor);
visual.mesh.setEmissive(shipColor, 0.18);
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 {
CameraMode,
OrbitalAnchor,
@@ -21,11 +21,8 @@ export function describeSelectable(world: WorldState | undefined, item: Selectab
if (item.kind === "node") {
return item.id;
}
if (item.kind === "spatial-node") {
return `${world.spatialNodes.get(item.id)?.kind ?? "node"} ${item.id}`;
}
if (item.kind === "bubble") {
return `bubble ${item.id}`;
if (item.kind === "celestial") {
return `${world.celestials.get(item.id)?.kind ?? "celestial"} ${item.id}`;
}
if (item.kind === "claim") {
return `claim ${item.id}`;
@@ -53,7 +50,29 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab
}
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") {
@@ -68,46 +87,38 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab
return item.id;
}
const anchorPath = node.anchorNodeId
? describeSpatialNodePathWithinSystem(world, node.systemId, node.anchorNodeId)
const anchorPath = node.celestialId
? describeCelestialPathWithinSystem(world, node.systemId, node.celestialId)
: undefined;
return anchorPath ? `${anchorPath} / ${node.itemId}` : `${node.systemId} / ${node.itemId}`;
}
if (item.kind === "spatial-node") {
const node = world.spatialNodes.get(item.id);
if (!node) {
if (item.kind === "celestial") {
const celestial = world.celestials.get(item.id);
if (!celestial) {
return item.id;
}
if (node.kind === "star") {
const system = world.systems.get(node.systemId);
return system ? `${system.label} star` : `${node.systemId} star`;
if (celestial.kind === "star") {
const system = world.systems.get(celestial.systemId);
return system ? `${system.label} star` : `${celestial.systemId} star`;
}
return describeSpatialNodePathWithinSystem(world, node.systemId, node.id) ?? `${node.systemId} / ${node.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}`;
return describeCelestialPathWithinSystem(world, celestial.systemId, celestial.id) ?? `${celestial.systemId} / ${celestial.kind}`;
}
if (item.kind === "claim") {
const claim = world.claims.get(item.id);
const anchorPath = claim?.nodeId
? describeSpatialNodePathWithinSystem(world, claim.systemId, claim.nodeId)
const anchorPath = claim?.celestialId
? describeCelestialPathWithinSystem(world, claim.systemId, claim.celestialId)
: undefined;
return anchorPath ? `${anchorPath} claim` : `Claim ${item.id}`;
}
if (item.kind === "construction-site") {
const site = world.constructionSites.get(item.id);
const anchorPath = site?.nodeId
? describeSpatialNodePathWithinSystem(world, site.systemId, site.nodeId)
const anchorPath = site?.celestialId
? describeCelestialPathWithinSystem(world, site.systemId, site.celestialId)
: undefined;
const siteLabel = site ? (site.blueprintId ?? site.targetDefinitionId) : item.id;
return anchorPath ? `${anchorPath} / ${siteLabel}` : `Construction ${siteLabel}`;
@@ -123,8 +134,6 @@ export function getSelectionGroup(item: Selectable): SelectionGroup {
if (
item.kind === "station"
|| item.kind === "node"
|| item.kind === "spatial-node"
|| item.kind === "bubble"
|| item.kind === "claim"
|| item.kind === "construction-site"
) {
@@ -147,11 +156,8 @@ export function resolveSelectableSystemId(world: WorldState | undefined, selecti
if (selection.kind === "node") {
return world.nodes.get(selection.id)?.systemId;
}
if (selection.kind === "spatial-node") {
return world.spatialNodes.get(selection.id)?.systemId;
}
if (selection.kind === "bubble") {
return world.localBubbles.get(selection.id)?.systemId;
if (selection.kind === "celestial") {
return world.celestials.get(selection.id)?.systemId;
}
if (selection.kind === "claim") {
return world.claims.get(selection.id)?.systemId;
@@ -165,29 +171,26 @@ export function resolveSelectableSystemId(world: WorldState | undefined, selecti
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) {
return undefined;
}
const selected = selectedItems[0];
if (selected.kind === "bubble") {
if (selected.kind === "celestial") {
return selected.id;
}
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") {
return world.stations.get(selected.id)?.bubbleId ?? undefined;
}
if (selected.kind === "spatial-node") {
return world.spatialNodes.get(selected.id)?.bubbleId ?? undefined;
return world.stations.get(selected.id)?.celestialId ?? undefined;
}
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") {
return world.constructionSites.get(selected.id)?.bubbleId ?? undefined;
return world.constructionSites.get(selected.id)?.celestialId ?? undefined;
}
return undefined;
}
@@ -232,8 +235,7 @@ export function renderSystemDetails(
let shipCount = 0;
let stationCount = 0;
let nodeCount = 0;
let spatialNodeCount = 0;
let bubbleCount = 0;
let celestialCount = 0;
let claimCount = 0;
let constructionCount = 0;
let moonCount = 0;
@@ -253,14 +255,9 @@ export function renderSystemDetails(
nodeCount += 1;
}
}
for (const node of world.spatialNodes.values()) {
if (node.systemId === system.id) {
spatialNodeCount += 1;
}
}
for (const bubble of world.localBubbles.values()) {
if (bubble.systemId === system.id) {
bubbleCount += 1;
for (const celestial of world.celestials.values()) {
if (celestial.systemId === system.id) {
celestialCount += 1;
}
}
for (const claim of world.claims.values()) {
@@ -285,7 +282,7 @@ export function renderSystemDetails(
<p>${system.id}${activeContext ? " · active system" : ""}</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>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>Height ${formatGalaxyDistance(system.galaxyPosition.y)}</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;
}
const destinationNode = world.spatialNodes.get(destinationNodeId);
if (!destinationNode) {
const destinationCelestial = world.celestials.get(destinationNodeId);
if (!destinationCelestial) {
return `${baseState} -> ${destinationNodeId}`;
}
if (baseState === "warping" || baseState === "spooling-warp") {
const destinationPath = describeSpatialNodePathWithinSystem(world, destinationNode.systemId, destinationNodeId);
const destinationPath = describeCelestialPathWithinSystem(world, destinationCelestial.systemId, destinationNodeId);
return `${baseState} -> ${destinationPath ?? destinationNodeId}`;
}
const destinationSystem = world.systems.get(destinationNode.systemId);
return `${baseState} -> ${destinationSystem?.label ?? destinationNode.systemId}`;
const destinationSystem = world.systems.get(destinationCelestial.systemId);
return `${baseState} -> ${destinationSystem?.label ?? destinationCelestial.systemId}`;
}
function describeControllerTask(taskKind: string): string {
@@ -381,8 +378,8 @@ export function describeShipLocation(world: WorldState | undefined, ship: ShipSn
if (ship.dockedStationId) {
const station = world.stations.get(ship.dockedStationId);
if (station) {
const anchorPath = station.anchorNodeId
? describeSpatialNodePathWithinSystem(world, station.systemId, station.anchorNodeId)
const anchorPath = station.celestialId
? describeCelestialPathWithinSystem(world, station.systemId, station.celestialId)
: undefined;
return {
system: systemLabel,
@@ -391,22 +388,11 @@ export function describeShipLocation(world: WorldState | undefined, ship: ShipSn
}
}
const currentNodeId = ship.spatialState.currentNodeId ?? ship.nodeId;
if (currentNodeId) {
const nodePath = describeSpatialNodePathWithinSystem(world, systemId, currentNodeId);
if (nodePath) {
return { system: systemLabel, local: nodePath };
}
}
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 };
}
const currentCelestialId = ship.spatialState.currentCelestialId ?? ship.celestialId;
if (currentCelestialId) {
const celestialPath = describeCelestialPathWithinSystem(world, systemId, currentCelestialId);
if (celestialPath) {
return { system: systemLabel, local: celestialPath };
}
}
@@ -415,11 +401,11 @@ export function describeShipLocation(world: WorldState | undefined, ship: ShipSn
export function describeActiveSpace(
world: WorldState | undefined,
zoomLevel: "local" | "system" | "universe",
povLevel: "local" | "system" | "galaxy",
activeSystemId: string | undefined,
selectedItems: Selectable[],
): string {
if (!world || zoomLevel === "universe") {
if (!world || povLevel === "galaxy") {
return "deep-space";
}
@@ -428,16 +414,13 @@ export function describeActiveSpace(
return "deep-space";
}
if (zoomLevel !== "local") {
if (povLevel !== "local") {
return activeSystem.label;
}
const bubbleId = resolveFocusedBubbleId(world, selectedItems);
if (bubbleId) {
const bubble = world.localBubbles.get(bubbleId);
const localPath = bubble?.nodeId
? describeSpatialNodePathWithinSystem(world, activeSystem.id, bubble.nodeId)
: undefined;
const celestialId = resolveFocusedCelestialId(world, selectedItems);
if (celestialId) {
const localPath = describeCelestialPathWithinSystem(world, activeSystem.id, celestialId);
return localPath
? `${activeSystem.label} / ${localPath}`
: activeSystem.label;
@@ -454,51 +437,43 @@ export function describeActiveSpace(
return activeSystem.label;
}
export function describeSpatialNodePathWithinSystem(world: WorldState, systemId: string, nodeId: string): string | undefined {
const node = world.spatialNodes.get(nodeId);
export function describeCelestialPathWithinSystem(world: WorldState, systemId: string, celestialId: string): string | undefined {
const celestial = world.celestials.get(celestialId);
const system = world.systems.get(systemId);
if (!node || !system) {
if (!celestial || !system) {
return undefined;
}
if (node.parentNodeId) {
const parentPath = describeSpatialNodePathWithinSystem(world, systemId, node.parentNodeId);
const segment = describeSpatialNodeSegment(world, system, node);
if (celestial.parentNodeId) {
const parentPath = describeCelestialPathWithinSystem(world, systemId, celestial.parentNodeId);
const segment = describeCelestialSegment(system, celestial);
return parentPath ? `${parentPath}/${segment}` : segment;
}
if (node.kind === "star") {
if (celestial.kind === "star") {
return undefined;
}
return describeSpatialNodeSegment(world, system, node);
return describeCelestialSegment(system, celestial);
}
function describeSpatialNodeSegment(world: WorldState, system: SystemSnapshot, node: SpatialNodeSnapshot): string {
const moonMatch = node.id.match(/-planet-(\d+)-moon-(\d+)$/);
function describeCelestialSegment(system: SystemSnapshot, celestial: CelestialSnapshot): string {
const moonMatch = celestial.id.match(/-planet-(\d+)-moon-(\d+)$/);
if (moonMatch) {
const moonIndex = Number.parseInt(moonMatch[2], 10);
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) {
return lagrangeMatch[1].toUpperCase();
}
const planetMatch = node.id.match(/-planet-(\d+)$/);
const planetMatch = celestial.id.match(/-planet-(\d+)$/);
if (planetMatch) {
const planetIndex = Number.parseInt(planetMatch[1], 10) - 1;
return system.planets[planetIndex]?.label ?? `Planet ${planetMatch[1]}`;
}
if (node.kind === "station" && node.occupyingStructureId) {
return world.stations.get(node.occupyingStructureId)?.label ?? node.occupyingStructureId;
}
if (node.kind === "resource-site") {
return node.orbitReferenceId ?? "Resource Site";
}
return node.orbitReferenceId ?? node.kind;
return celestial.orbitReferenceId ?? celestial.kind;
}

View File

@@ -40,8 +40,7 @@ export function createWorldState(snapshot: WorldSnapshot): WorldState {
orbitalSimulation: snapshot.orbitalSimulation,
generatedAtUtc: snapshot.generatedAtUtc,
systems: new Map(snapshot.systems.map((system) => [system.id, system])),
spatialNodes: new Map(snapshot.spatialNodes.map((node) => [node.id, node])),
localBubbles: new Map(snapshot.localBubbles.map((bubble) => [bubble.id, bubble])),
celestials: new Map(snapshot.celestials.map((celestial) => [celestial.id, celestial])),
nodes: new Map(snapshot.nodes.map((node) => [node.id, node])),
stations: new Map(snapshot.stations.map((station) => [station.id, station])),
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.recentEvents = [...delta.events, ...world.recentEvents].slice(0, 18);
for (const node of delta.spatialNodes) {
world.spatialNodes.set(node.id, node);
}
for (const bubble of delta.localBubbles) {
world.localBubbles.set(bubble.id, bubble);
for (const celestial of delta.celestials) {
world.celestials.set(celestial.id, celestial);
}
for (const node of delta.nodes) {
world.nodes.set(node.id, node);
@@ -100,8 +96,7 @@ export function recordDeltaStats(networkStats: NetworkStats, delta: WorldDelta,
const changedEntities = delta.ships.length
+ delta.stations.length
+ delta.nodes.length
+ delta.spatialNodes.length
+ delta.localBubbles.length
+ delta.celestials.length
+ delta.claims.length
+ delta.constructionSites.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 type { SceneNode } from "./viewerScenePrimitives";
import type {
CelestialSnapshot,
ClaimSnapshot,
ConstructionSiteSnapshot,
FactionSnapshot,
LocalBubbleSnapshot,
MarketOrderSnapshot,
PlanetSnapshot,
PolicySetSnapshot,
ResourceNodeSnapshot,
ShipSnapshot,
SimulationEventRecord,
SpatialNodeSnapshot,
StationSnapshot,
SystemSnapshot,
OrbitalSimulationSnapshot,
} from "./contracts";
export type ZoomLevel = "local" | "system" | "universe";
export type PovLevel = "local" | "system" | "galaxy";
export type SelectionGroup = "ships" | "structures" | "celestials";
export type DragMode = "orbit" | "marquee";
export type CameraMode = "tactical" | "follow";
@@ -26,8 +25,7 @@ export type Selectable =
| { kind: "ship"; id: string }
| { kind: "station"; id: string }
| { kind: "node"; id: string }
| { kind: "spatial-node"; id: string }
| { kind: "bubble"; id: string }
| { kind: "celestial"; id: string }
| { kind: "claim"; id: string }
| { kind: "construction-site"; id: string }
| { kind: "system"; id: string }
@@ -86,26 +84,18 @@ export interface NodeVisual {
orbitInclination: number;
}
export interface SpatialNodeVisual {
export interface CelestialVisual {
id: string;
systemId: string;
mesh: SceneNode;
icon: SceneNode;
kind: string;
localPosition: THREE.Vector3;
}
export interface BubbleVisual {
id: string;
systemId: string;
mesh: SceneNode;
localPosition: THREE.Vector3;
radius: number;
orbitalAnchor: THREE.Vector3;
}
export interface ClaimVisual {
id: string;
nodeId: string;
celestialId: string;
systemId: string;
mesh: SceneNode;
icon: SceneNode;
@@ -114,7 +104,7 @@ export interface ClaimVisual {
export interface ConstructionSiteVisual {
id: string;
nodeId: string;
celestialId: string;
systemId: string;
mesh: SceneNode;
icon: SceneNode;
@@ -134,13 +124,13 @@ export interface StructureVisual {
}
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;
icon: SceneNode;
shellReticle: SceneNode;
icon: SceneNode; // star dot sprite (child of galaxyRoot)
shellReticle: SceneNode; // reticle sprite (child of galaxyRoot)
shellReticleBaseScale: number;
detailGroup: SceneNode;
summary: SystemSummaryVisual;
detailGroup: SceneNode; // planets + moons (child of systemRoot)
galaxyPosition: THREE.Vector3;
}
@@ -153,8 +143,7 @@ export interface WorldState {
orbitalSimulation: OrbitalSimulationSnapshot;
generatedAtUtc: string;
systems: Map<string, SystemSnapshot>;
spatialNodes: Map<string, SpatialNodeSnapshot>;
localBubbles: Map<string, LocalBubbleSnapshot>;
celestials: Map<string, CelestialSnapshot>;
nodes: Map<string, ResourceNodeSnapshot>;
stations: Map<string, StationSnapshot>;
claims: Map<string, ClaimSnapshot>;
@@ -195,20 +184,6 @@ export interface PerformanceStats {
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 {
id: string;
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 { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState";
import type {
CelestialDelta,
CelestialSnapshot,
ClaimDelta,
ClaimSnapshot,
ConstructionSiteDelta,
ConstructionSiteSnapshot,
FactionSnapshot,
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
ShipDelta,
ShipSnapshot,
SpatialNodeDelta,
SpatialNodeSnapshot,
StationDelta,
StationSnapshot,
SystemSnapshot,
@@ -27,7 +25,7 @@ import type {
NetworkStats,
Selectable,
WorldState,
ZoomLevel,
PovLevel,
} from "./viewerTypes";
export interface ViewerWorldLifecycleContext {
@@ -41,7 +39,7 @@ export interface ViewerWorldLifecycleContext {
setStream: (stream: EventSource | undefined) => void;
getCurrentStreamScopeKey: () => string;
setCurrentStreamScopeKey: (value: string) => void;
getZoomLevel: () => ZoomLevel;
getPovLevel: () => PovLevel;
getActiveSystemId: () => string | undefined;
getSelectedItems: () => Selectable[];
getCameraMode: () => CameraMode;
@@ -54,22 +52,20 @@ export interface ViewerWorldLifecycleContext {
detailBodyEl: HTMLDivElement;
worldLabel: () => string;
rebuildSystems: (systems: SystemSnapshot[]) => void;
syncSpatialNodes: (nodes: SpatialNodeSnapshot[]) => void;
syncLocalBubbles: (bubbles: LocalBubbleSnapshot[]) => void;
syncCelestials: (celestials: CelestialSnapshot[]) => void;
syncNodes: (nodes: ResourceNodeSnapshot[]) => void;
syncStations: (stations: StationSnapshot[]) => void;
syncClaims: (claims: ClaimSnapshot[]) => void;
syncConstructionSites: (sites: ConstructionSiteSnapshot[]) => void;
syncShips: (ships: ShipSnapshot[], tickIntervalMs: number) => void;
applySpatialNodeDeltas: (nodes: SpatialNodeDelta[]) => void;
applyLocalBubbleDeltas: (bubbles: LocalBubbleDelta[]) => void;
applyCelestialDeltas: (celestials: CelestialDelta[]) => void;
applyNodeDeltas: (nodes: ResourceNodeDelta[]) => void;
applyStationDeltas: (stations: StationDelta[]) => void;
applyClaimDeltas: (claims: ClaimDelta[]) => void;
applyConstructionSiteDeltas: (sites: ConstructionSiteDelta[]) => void;
applyShipDeltas: (ships: ShipDelta[], tickIntervalMs: number) => void;
refreshHistoryWindows: () => void;
resolveFocusedBubbleId: () => string | undefined;
resolveFocusedCelestialId: () => string | undefined;
updateSystemSummaries: () => void;
applyZoomPresentation: () => void;
updateNetworkPanel: () => void;
@@ -161,8 +157,7 @@ export class ViewerWorldLifecycle {
this.context.rebuildSystems(snapshot.systems);
}
this.context.syncSpatialNodes(snapshot.spatialNodes);
this.context.syncLocalBubbles(snapshot.localBubbles);
this.context.syncCelestials(snapshot.celestials);
this.context.syncNodes(snapshot.nodes);
this.context.syncStations(snapshot.stations);
this.context.syncClaims(snapshot.claims);
@@ -182,8 +177,7 @@ export class ViewerWorldLifecycle {
this.context.setWorldTimeSyncMs(performance.now());
applyDeltaToWorld(world, delta);
this.context.applySpatialNodeDeltas(delta.spatialNodes);
this.context.applyLocalBubbleDeltas(delta.localBubbles);
this.context.applyCelestialDeltas(delta.celestials);
this.context.applyNodeDeltas(delta.nodes);
this.context.applyStationDeltas(delta.stations);
this.context.applyClaimDeltas(delta.claims);
@@ -199,7 +193,7 @@ export class ViewerWorldLifecycle {
this.context.getSelectedItems(),
this.context.getCameraMode(),
this.context.getCameraTargetShipId(),
this.context.getZoomLevel(),
this.context.getPovLevel(),
this.context.getActiveSystemId(),
);
}
@@ -216,7 +210,7 @@ export class ViewerWorldLifecycle {
updateDetailPanel(this.context.detailTitleEl, this.context.detailBodyEl, {
world,
selectedItems: this.context.getSelectedItems(),
zoomLevel: this.context.getZoomLevel(),
povLevel: this.context.getPovLevel(),
cameraMode: this.context.getCameraMode(),
cameraTargetShipId: this.context.getCameraTargetShipId(),
worldLabel: this.context.worldLabel(),
@@ -226,16 +220,16 @@ export class ViewerWorldLifecycle {
private getPreferredStreamScope() {
const activeSystemId = this.context.getActiveSystemId();
if (this.context.getZoomLevel() === "universe" || !activeSystemId) {
if (this.context.getPovLevel() === "galaxy" || !activeSystemId) {
return { scopeKind: "universe" as const };
}
const bubbleId = this.context.resolveFocusedBubbleId();
if (this.context.getZoomLevel() === "local" && bubbleId) {
const celestialId = this.context.resolveFocusedCelestialId();
if (this.context.getPovLevel() === "local" && celestialId) {
return {
scopeKind: "local-bubble" as const,
scopeKind: "local-celestial" as const,
systemId: activeSystemId,
bubbleId,
celestialId,
};
}

View File

@@ -1,42 +1,40 @@
import * as THREE from "three";
import {
DISPLAY_UNITS_PER_KILOMETER,
DISPLAY_UNITS_PER_LIGHT_YEAR,
KILOMETERS_PER_AU,
computeMoonLocalPosition,
computeMoonSize,
computePlanetLocalPosition,
currentWorldTimeSeconds,
resolveOrbitalAnchorPosition,
toDisplayGalaxyVector,
toThreeVector,
} from "./viewerMath";
import { describeActiveSpace } from "./viewerSelection";
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
import { describeActiveSpace, resolveFocusedCelestialId } from "./viewerSelection";
import {
resolveShipHeading,
updateSystemStarPresentation,
updateSystemSummaryPresentation,
getAnimatedShipLocalPosition,
} from "./viewerPresentation";
import { rawObject } from "./viewerScenePrimitives";
import type {
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
ShipSnapshot,
} from "./contracts";
import type {
BubbleVisual,
CelestialVisual,
ClaimVisual,
Selectable,
ConstructionSiteVisual,
NodeVisual,
OrbitalAnchor,
ShipVisual,
SpatialNodeVisual,
StructureVisual,
SystemSummaryVisual,
SystemVisual,
WorldState,
ZoomLevel,
PovLevel,
CameraMode,
} from "./viewerTypes";
@@ -47,23 +45,22 @@ export interface WorldOrbitalContext {
worldTimeSyncMs: number;
worldSeed: number;
nodeVisuals: Map<string, NodeVisual>;
spatialNodeVisuals: Map<string, SpatialNodeVisual>;
bubbleVisuals: Map<string, BubbleVisual>;
celestialVisuals: Map<string, CelestialVisual>;
stationVisuals: Map<string, StructureVisual>;
}
export interface WorldPresentationContext extends WorldOrbitalContext {
activeSystemId?: string;
zoomLevel: ZoomLevel;
povLevel: PovLevel;
orbitYaw: number;
camera: THREE.PerspectiveCamera;
systemFocusLocal: THREE.Vector3;
systemAnchor: THREE.Vector3;
shipVisuals: Map<string, ShipVisual>;
claimVisuals: Map<string, ClaimVisual>;
constructionSiteVisuals: Map<string, ConstructionSiteVisual>;
systemVisuals: Map<string, SystemVisual>;
systemSummaryVisuals: Map<string, SystemSummaryVisual>;
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
systemSummaryVisuals: Map<string, any>;
toDisplayLocalPosition: (localPosition: THREE.Vector3) => THREE.Vector3;
updateSystemDetailVisibility: () => void;
setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], opacity: number) => void;
}
@@ -74,15 +71,17 @@ export interface GameStatusParams {
world?: WorldState;
activeSystemId?: string;
cameraMode: CameraMode;
zoomLevel: ZoomLevel;
povLevel: PovLevel;
selectedItems: Selectable[];
mode: string;
galaxyAnchor?: THREE.Vector3;
systemAnchor?: THREE.Vector3;
}
export function updateWorldPresentation(context: WorldPresentationContext) {
const now = performance.now();
const worldTimeSeconds = currentWorldTimeSeconds(context.world, context.worldTimeSyncMs);
const renderMode = resolveRenderSpaceMode(context.activeSystemId, context.zoomLevel);
const renderMode = resolveRenderSpaceMode(context.activeSystemId, context.povLevel);
for (const [shipId, visual] of context.shipVisuals.entries()) {
const ship = context.world?.ships.get(shipId);
@@ -91,7 +90,7 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
}
const worldPosition = getAnimatedShipLocalPosition(visual, now);
const displayPosition = resolveShipWorldPosition(context, ship, visual, worldPosition);
const displayPosition = context.toDisplayLocalPosition(worldPosition);
visual.mesh.setPosition(displayPosition);
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
const shipVisible = isShipVisible(renderMode, context.activeSystemId, ship);
@@ -105,67 +104,51 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
for (const visual of context.nodeVisuals.values()) {
const animatedLocalPosition = computeNodeLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
}
for (const visual of context.spatialNodeVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
for (const visual of context.celestialVisuals.values()) {
const animatedLocalPosition = computeCelestialLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
visual.icon.setVisible(visual.systemId === context.activeSystemId);
}
for (const visual of context.bubbleVisuals.values()) {
const animatedLocalPosition = resolveBubbleAnimatedLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
}
for (const visual of context.stationVisuals.values()) {
const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
}
for (const visual of context.claimVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone();
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
const animatedLocalPosition = computeCelestialLocalPositionById(context, visual.celestialId, worldTimeSeconds) ?? visual.localPosition.clone();
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
visual.icon.setVisible(visual.systemId === context.activeSystemId);
}
for (const visual of context.constructionSiteVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone();
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
const animatedLocalPosition = computeCelestialLocalPositionById(context, visual.celestialId, worldTimeSeconds) ?? visual.localPosition.clone();
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
visual.icon.setVisible(visual.systemId === context.activeSystemId);
}
updateSystemStarPresentation(
context.systemVisuals,
context.activeSystemId,
context.systemFocusLocal,
context.camera,
context.setShellReticleOpacity,
);
context.updateSystemDetailVisibility();
updateSystemSummaryPresentation(context.systemSummaryVisuals, context.camera, context.activeSystemId);
}
type RenderSpaceMode = "galaxy" | "system" | "local";
function resolveRenderSpaceMode(activeSystemId: string | undefined, zoomLevel: ZoomLevel): RenderSpaceMode {
if (!activeSystemId || zoomLevel === "universe") {
function resolveRenderSpaceMode(activeSystemId: string | undefined, povLevel: PovLevel): RenderSpaceMode {
if (!activeSystemId || povLevel === "galaxy") {
return "galaxy";
}
return zoomLevel === "local" ? "local" : "system";
return povLevel === "local" ? "local" : "system";
}
function isShipVisible(mode: RenderSpaceMode, activeSystemId: string | undefined, ship: ShipSnapshot) {
@@ -186,22 +169,11 @@ export function resolveShipWorldPosition(
visual: ShipVisual,
animatedLocalPosition = getAnimatedShipLocalPosition(visual),
) {
if (ship.spatialState.movementRegime === "ftl-transit") {
const destinationNodeId = ship.spatialState.transit?.destinationNodeId;
const destinationNode = destinationNodeId ? context.world?.spatialNodes.get(destinationNodeId) : undefined;
const originSystem = context.world?.systems.get(ship.systemId);
const destinationSystem = destinationNode ? context.world?.systems.get(destinationNode.systemId) : undefined;
if (originSystem && destinationSystem) {
const origin = toDisplayGalaxyVector(originSystem.galaxyPosition);
const destination = toDisplayGalaxyVector(destinationSystem.galaxyPosition);
return origin.lerp(destination, THREE.MathUtils.clamp(ship.spatialState.transit?.progress ?? 0, 0, 1));
}
}
return context.toDisplayLocalPosition(animatedLocalPosition, ship.systemId);
// FTL ships are invisible in system scene; just return their last known local position.
return context.toDisplayLocalPosition(animatedLocalPosition);
}
export function updateSystemSummaries(world: WorldState | undefined, systemSummaryVisuals: Map<string, SystemSummaryVisual>) {
export function updateSystemSummaries(world: WorldState | undefined, systemSummaryVisuals: Map<string, any>) {
if (!world) {
return;
}
@@ -275,26 +247,50 @@ export function renderRecentEvents(world: WorldState | undefined, entityKind: st
.join("<br>");
}
function fmtVec(v: THREE.Vector3, digits: number) {
return `${v.x.toFixed(digits)} ${v.y.toFixed(digits)} ${v.z.toFixed(digits)}`;
}
export function updateGameStatus(params: GameStatusParams) {
const { statusEl, summaryEl, world, activeSystemId, cameraMode, zoomLevel, selectedItems, mode } = params;
const { statusEl, summaryEl, world, activeSystemId, cameraMode, povLevel, selectedItems, mode, galaxyAnchor, systemAnchor } = params;
const sequence = world?.sequence ?? 0;
const generatedAt = world?.generatedAtUtc
? new Date(world.generatedAtUtc).toLocaleTimeString()
: "n/a";
const displayZoomLevel = activeSystemId ? zoomLevel : "universe";
const activeSpace = describeActiveSpace(world, displayZoomLevel, activeSystemId, selectedItems);
const displayPovLevel = activeSystemId ? povLevel : "galaxy";
const activeSpace = describeActiveSpace(world, displayPovLevel, activeSystemId, selectedItems);
const cameraModeLabel = cameraMode === "follow" ? "follow" : "map";
// Galaxy space: galaxyAnchor in light-years — changes only during galaxy navigation
const galPos = galaxyAnchor
? `gal pos: ${fmtVec(galaxyAnchor.clone().divideScalar(DISPLAY_UNITS_PER_LIGHT_YEAR), 2)} ly`
: "";
// System space: systemAnchor in AU — changes only during system navigation
const sysPos = systemAnchor
? `sys pos: ${fmtVec(systemAnchor.clone().divideScalar(KILOMETERS_PER_AU), 3)} AU`
: "";
// Local space: position relative to the focused celestial's orbital anchor in km
const focusedCelestialId = resolveFocusedCelestialId(world, selectedItems);
const celestialAnchor = focusedCelestialId
? world?.celestials.get(focusedCelestialId)?.orbitalAnchor
: undefined;
const locPos = systemAnchor && celestialAnchor
? `loc pos: ${fmtVec(systemAnchor.clone().sub(toThreeVector(celestialAnchor)), 0)} km`
: "";
statusEl.textContent = [
`mode: ${mode}`,
`camera: ${cameraModeLabel}`,
`zoom: ${displayZoomLevel}`,
`zoom: ${displayPovLevel}`,
`space: ${activeSpace}`,
galPos,
sysPos,
locPos,
`sequence: ${sequence}`,
`snapshot: ${generatedAt}`,
].join("\n");
].filter(Boolean).join("\n");
if (summaryEl) {
summaryEl.textContent = `${mode} | ${displayZoomLevel} | ${activeSpace}`;
summaryEl.textContent = `${mode} | ${displayPovLevel} | ${activeSpace}`;
}
}
@@ -372,58 +368,54 @@ export function resolveOrbitalAnchor(context: WorldOrbitalContext, systemId: str
return bestAnchor;
}
export function resolvePointPosition(context: WorldOrbitalContext, _systemId: string, nodeId?: string | null) {
if (nodeId) {
const spatialNode = context.world?.spatialNodes.get(nodeId);
if (spatialNode) {
return toThreeVector(spatialNode.localPosition);
export function resolvePointPosition(context: WorldOrbitalContext, _systemId: string, celestialId?: string | null) {
if (celestialId) {
const celestial = context.world?.celestials.get(celestialId);
if (celestial) {
return toThreeVector(celestial.orbitalAnchor);
}
}
return new THREE.Vector3(0, 0, 0);
}
export function resolveBubblePosition(context: WorldOrbitalContext, bubble: LocalBubbleSnapshot | LocalBubbleDelta) {
return resolvePointPosition(context, bubble.systemId, bubble.nodeId);
export function computeCelestialLocalPosition(context: WorldOrbitalContext, visual: CelestialVisual, timeSeconds: number) {
return computeCelestialLocalPositionById(context, visual.id, timeSeconds) ?? visual.orbitalAnchor.clone();
}
export function computeSpatialNodeLocalPosition(context: WorldOrbitalContext, visual: SpatialNodeVisual, timeSeconds: number) {
return computeSpatialNodeLocalPositionById(context, visual.id, timeSeconds) ?? visual.localPosition.clone();
}
export function computeSpatialNodeLocalPositionById(
export function computeCelestialLocalPositionById(
context: WorldOrbitalContext,
nodeId: string,
celestialId: string,
timeSeconds: number,
visiting = new Set<string>(),
): THREE.Vector3 | undefined {
if (!context.world || visiting.has(nodeId)) {
if (!context.world || visiting.has(celestialId)) {
return undefined;
}
const node = context.world.spatialNodes.get(nodeId);
if (!node) {
const celestial = context.world.celestials.get(celestialId);
if (!celestial) {
return undefined;
}
const basePosition = toThreeVector(node.localPosition);
if (!node.parentNodeId) {
const basePosition = toThreeVector(celestial.orbitalAnchor);
if (!celestial.parentNodeId) {
return basePosition;
}
const parentNode = context.world.spatialNodes.get(node.parentNodeId);
if (!parentNode) {
const parentCelestial = context.world.celestials.get(celestial.parentNodeId);
if (!parentCelestial) {
return basePosition;
}
visiting.add(nodeId);
const parentCurrentPosition = computeSpatialNodeLocalPositionById(context, node.parentNodeId, timeSeconds, visiting);
visiting.delete(nodeId);
visiting.add(celestialId);
const parentCurrentPosition = computeCelestialLocalPositionById(context, celestial.parentNodeId, timeSeconds, visiting);
visiting.delete(celestialId);
if (!parentCurrentPosition) {
return basePosition;
}
const parentInitialPosition = toThreeVector(parentNode.localPosition);
const parentInitialPosition = toThreeVector(parentCelestial.orbitalAnchor);
const relativeOffset = basePosition.clone().sub(parentInitialPosition);
const initialAngle = Math.atan2(parentInitialPosition.z, parentInitialPosition.x);
const currentAngle = Math.atan2(parentCurrentPosition.z, parentCurrentPosition.x);
@@ -431,13 +423,6 @@ export function computeSpatialNodeLocalPositionById(
return parentCurrentPosition.clone().add(rotatedOffset);
}
export function setBubbleVisualState(visual: BubbleVisual, bubble: LocalBubbleSnapshot | LocalBubbleDelta) {
const intensity = bubble.occupantShipIds.length + bubble.occupantStationIds.length + bubble.occupantConstructionSiteIds.length;
const material = (rawObject(visual.mesh) as THREE.LineLoop).material as THREE.LineBasicMaterial;
material.opacity = THREE.MathUtils.clamp(0.18 + intensity * 0.05, 0.18, 0.72);
material.color.set(intensity > 0 ? "#7fffd4" : "#6ed6ff");
}
function drawCountIcon(
context: CanvasRenderingContext2D,
kind: SummaryIconKind,
@@ -504,24 +489,15 @@ function getOrbitalAnchorPosition(context: WorldOrbitalContext, systemId: string
return resolveOrbitalAnchorPosition(context.world, systemId, anchor, timeSeconds, context.worldSeed);
}
function resolveBubbleAnimatedLocalPosition(context: WorldOrbitalContext, visual: BubbleVisual, timeSeconds: number) {
const bubble = context.world?.localBubbles.get(visual.id);
if (!bubble) {
return visual.localPosition.clone();
}
return computeSpatialNodeLocalPositionById(context, bubble.nodeId, timeSeconds) ?? visual.localPosition.clone();
}
function resolveStructureAnimatedLocalPosition(context: WorldOrbitalContext, visual: StructureVisual, timeSeconds: number) {
if (!context.world) {
return visual.localPosition.clone();
}
const station = context.world.stations.get(visual.id);
if (!station?.nodeId) {
if (!station?.celestialId) {
return computeStructureLocalPosition(context, visual, timeSeconds, 0.14);
}
return computeSpatialNodeLocalPositionById(context, station.nodeId, timeSeconds) ?? visual.localPosition.clone();
return computeCelestialLocalPositionById(context, station.celestialId, timeSeconds) ?? visual.localPosition.clone();
}