Refactor simulation and viewer architecture

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,362 @@
import * as THREE from "three";
import {
MAX_CAMERA_DISTANCE,
MIN_CAMERA_DISTANCE,
ZOOM_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 { 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 {
createInitialNetworkStats,
createInitialPerformanceStats,
} from "./viewerState";
import { ViewerWorldLifecycle } from "./viewerWorldLifecycle";
import { ViewerInteractionController } from "./viewerInteractionController";
import { ViewerNavigationController } from "./viewerNavigationController";
import { ViewerSceneDataController } from "./viewerSceneDataController";
import { ViewerPresentationController } from "./viewerPresentationController";
import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory";
import type { FactionSnapshot, ShipSnapshot } from "./contracts";
import type {
BubbleVisual,
CameraMode,
ClaimVisual,
ConstructionSiteVisual,
DragMode,
HistoryWindowState,
MoonVisual,
NetworkStats,
NodeVisual,
OrbitalAnchor,
PerformanceStats,
PlanetVisual,
PresentationEntry,
Selectable,
ShipVisual,
SpatialNodeVisual,
StructureVisual,
SystemSummaryVisual,
SystemVisual,
WorldState,
ZoomLevel,
} 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);
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();
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 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 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 orbitLines: THREE.Object3D[] = [];
private readonly statusEl: HTMLDivElement;
private readonly systemPanelEl: HTMLDivElement;
private readonly systemTitleEl: HTMLHeadingElement;
private readonly systemBodyEl: HTMLDivElement;
private readonly detailTitleEl: HTMLHeadingElement;
private readonly detailBodyEl: HTMLDivElement;
private readonly factionStripEl: HTMLDivElement;
private readonly networkPanelEl: HTMLDivElement;
private readonly performancePanelEl: HTMLDivElement;
private readonly errorEl: HTMLDivElement;
private readonly historyLayerEl: HTMLDivElement;
private readonly marqueeEl: HTMLDivElement;
private readonly hoverLabelEl: HTMLDivElement;
private world?: WorldState;
private worldTimeSyncMs = performance.now();
private stream?: EventSource;
private currentStreamScopeKey = "";
private readonly networkStats: NetworkStats = createInitialNetworkStats();
private readonly performanceStats: PerformanceStats = createInitialPerformanceStats();
private selectedItems: Selectable[] = [];
private worldSignature = "";
private zoomLevel: ZoomLevel = "system";
private currentDistance = ZOOM_DISTANCE.system;
private desiredDistance = ZOOM_DISTANCE.system;
private orbitYaw = -2.3;
private orbitPitch = 0.62;
private cameraMode: CameraMode = "tactical";
private dragMode?: DragMode;
private dragPointerId?: number;
private dragStart = new THREE.Vector2();
private dragLast = new THREE.Vector2();
private marqueeActive = false;
private suppressClickSelection = false;
private activeSystemId?: string;
private cameraTargetShipId?: string;
private readonly followCameraPosition = new THREE.Vector3();
private readonly followCameraFocus = new THREE.Vector3();
private readonly followCameraDirection = new THREE.Vector3(0, 0.16, 1);
private readonly followCameraDesiredDirection = new THREE.Vector3(0, 0.16, 1);
private readonly followCameraOffset = new THREE.Vector3();
private readonly historyWindows: HistoryWindowState[] = [];
private historyWindowCounter = 0;
private historyWindowZCounter = 10;
private historyWindowDragId?: string;
private historyWindowDragPointerId?: number;
private historyWindowDragOffset = new THREE.Vector2();
private readonly worldLifecycle: ViewerWorldLifecycle;
private readonly interactionController: ViewerInteractionController;
private readonly navigationController: ViewerNavigationController;
private readonly sceneDataController: ViewerSceneDataController;
private readonly presentationController: ViewerPresentationController;
constructor(container: HTMLElement) {
this.container = container;
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);
const hud = createViewerHud(document);
this.statusEl = hud.statusEl;
this.systemPanelEl = hud.systemPanelEl;
this.systemTitleEl = hud.systemTitleEl;
this.systemBodyEl = hud.systemBodyEl;
this.detailTitleEl = hud.detailTitleEl;
this.detailBodyEl = hud.detailBodyEl;
this.factionStripEl = hud.factionStripEl;
this.networkPanelEl = hud.networkPanelEl;
this.performancePanelEl = hud.performancePanelEl;
this.errorEl = hud.errorEl;
this.historyLayerEl = hud.historyLayerEl;
this.marqueeEl = hud.marqueeEl;
this.hoverLabelEl = hud.hoverLabelEl;
({
sceneDataController: this.sceneDataController,
navigationController: this.navigationController,
presentationController: this.presentationController,
worldLifecycle: this.worldLifecycle,
interactionController: this.interactionController,
} = createViewerControllers(this));
this.container.append(this.renderer.domElement, hud.root);
wireViewerEvents(this);
this.onResize();
this.updateCamera(0);
}
async start() {
await this.worldLifecycle.bootstrapWorld();
this.renderer.setAnimationLoop(() => this.render());
}
private refreshStreamScopeIfNeeded() {
this.worldLifecycle.refreshStreamScopeIfNeeded();
}
private createWorldPresentationContext() {
return this.sceneDataController.createWorldPresentationContext({
world: this.world,
activeSystemId: this.activeSystemId,
orbitYaw: this.orbitYaw,
camera: this.camera,
systemFocusLocal: this.systemFocusLocal,
toDisplayLocalPosition: this.toDisplayLocalPosition.bind(this),
updateSystemDetailVisibility: () => this.navigationController.updateSystemDetailVisibility(),
setShellReticleOpacity: (sprite, opacity) => this.setShellReticleOpacity(sprite, opacity),
});
}
private rebuildFactions(_factions: FactionSnapshot[]) {
this.worldLifecycle.rebuildFactions(_factions);
}
private updatePanels() {
this.worldLifecycle.updatePanels();
}
private render() {
renderFrame({
clock: this.clock,
renderer: this.renderer,
scene: this.scene,
camera: this.camera,
updateCamera: (delta) => this.updateCamera(delta),
updateAmbience: (delta) => this.presentationController.updateAmbience(delta),
updatePlanetPresentation: () => this.presentationController.updatePlanetPresentation(),
updateShipPresentation: () => this.presentationController.updateShipPresentation(),
updateNetworkPanel: () => this.presentationController.updateNetworkPanel(),
applyZoomPresentation: () => this.presentationController.applyZoomPresentation(),
recordPerformanceStats: (frameMs) => this.presentationController.recordPerformanceStats(frameMs),
updatePerformancePanel: () => this.presentationController.updatePerformancePanel(),
});
}
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 updateCamera(delta: number) {
const nextState = stepCamera({
currentDistance: this.currentDistance,
desiredDistance: this.desiredDistance,
orbitPitch: this.orbitPitch,
delta,
});
this.currentDistance = nextState.currentDistance;
this.zoomLevel = nextState.zoomLevel;
this.orbitPitch = nextState.orbitPitch;
this.navigationController.updateActiveSystem();
if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) {
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,
);
this.camera.position.copy(focus).add(this.cameraOffset);
this.camera.lookAt(focus);
}
private updatePanFromKeyboard(delta: number) {
updatePanFromKeyboard(
this.keyState,
this.orbitYaw,
this.currentDistance,
this.activeSystemId,
this.systemFocusLocal,
this.galaxyFocus,
delta,
MIN_CAMERA_DISTANCE,
MAX_CAMERA_DISTANCE,
);
}
private updateSystemSummaries() {
this.presentationController.updateSystemSummaries();
}
private registerPresentation(
detail: THREE.Object3D,
icon: THREE.Sprite,
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);
}
private updateGamePanel(mode: string) {
this.presentationController.updateGamePanel(mode);
}
private screenPointFromClient(clientX: number, clientY: number) {
return this.presentationController.screenPointFromClient(clientX, clientY);
}
private refreshHistoryWindows() {
this.interactionController.refreshHistoryWindows();
}
private resolveFocusedBubbleId() {
return resolveFocusedBubbleId(this.world, this.selectedItems);
}
private onResize = () => {
resizeViewer({
renderer: this.renderer,
camera: this.camera,
});
};
private setShellReticleOpacity(sprite: THREE.Sprite, opacity: number) {
setShellReticleOpacity(sprite, opacity);
}
private describeSelectionParent(selection: Selectable) {
return describeSelectionParent(this.world, selection, this.stationVisuals, this.nodeVisuals);
}
private toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) {
return this.navigationController.toDisplayLocalPosition(localPosition, systemId);
}
private updateSystemPanel() {
this.presentationController.updateSystemPanel();
}
}

View File

@@ -1,290 +1,38 @@
export interface WorldSnapshot {
label: string;
seed: number;
sequence: number;
tickIntervalMs: number;
generatedAtUtc: string;
systems: SystemSnapshot[];
spatialNodes: SpatialNodeSnapshot[];
localBubbles: LocalBubbleSnapshot[];
nodes: ResourceNodeSnapshot[];
stations: StationSnapshot[];
claims: ClaimSnapshot[];
constructionSites: ConstructionSiteSnapshot[];
marketOrders: MarketOrderSnapshot[];
policies: PolicySetSnapshot[];
ships: ShipSnapshot[];
factions: FactionSnapshot[];
}
export interface WorldDelta {
sequence: number;
tickIntervalMs: number;
generatedAtUtc: string;
requiresSnapshotRefresh: boolean;
events: SimulationEventRecord[];
spatialNodes: SpatialNodeDelta[];
localBubbles: LocalBubbleDelta[];
nodes: ResourceNodeDelta[];
stations: StationDelta[];
claims: ClaimDelta[];
constructionSites: ConstructionSiteDelta[];
marketOrders: MarketOrderDelta[];
policies: PolicySetDelta[];
ships: ShipDelta[];
factions: FactionDelta[];
scope?: ObserverScope | null;
}
export interface SimulationEventRecord {
entityKind: string;
entityId: string;
kind: string;
message: string;
occurredAtUtc: string;
family?: string;
scopeKind?: string;
scopeEntityId?: string | null;
visibility?: string;
}
export interface ObserverScope {
scopeKind: string;
systemId?: string | null;
bubbleId?: string | null;
}
export interface Vector3Dto {
x: number;
y: number;
z: number;
}
export interface SystemSnapshot {
id: string;
label: string;
galaxyPosition: Vector3Dto;
starKind: string;
starCount: number;
starColor: string;
starSize: number;
planets: PlanetSnapshot[];
}
export interface PlanetSnapshot {
label: string;
planetType: string;
shape: string;
moonCount: number;
orbitRadius: number;
orbitSpeed: number;
orbitEccentricity: number;
orbitInclination: number;
orbitLongitudeOfAscendingNode: number;
orbitArgumentOfPeriapsis: number;
orbitPhaseAtEpoch: number;
size: number;
color: string;
hasRing: boolean;
}
export interface ResourceNodeSnapshot {
id: string;
systemId: string;
localPosition: Vector3Dto;
sourceKind: string;
oreRemaining: number;
maxOre: number;
itemId: string;
}
export interface ResourceNodeDelta extends ResourceNodeSnapshot {}
export interface SpatialNodeSnapshot {
id: string;
systemId: string;
kind: string;
localPosition: Vector3Dto;
bubbleId: string;
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 InventoryEntry {
itemId: string;
amount: number;
}
export interface StationSnapshot {
id: string;
label: string;
category: string;
systemId: string;
localPosition: Vector3Dto;
nodeId?: string | null;
bubbleId?: string | null;
anchorNodeId?: string | null;
color: string;
dockedShips: number;
dockingPads: number;
energyStored: number;
inventory: InventoryEntry[];
factionId: string;
commanderId?: string | null;
policySetId?: string | null;
population: number;
populationCapacity: number;
workforceRequired: number;
workforceEffectiveRatio: number;
installedModules: string[];
marketOrderIds: string[];
}
export interface StationDelta extends StationSnapshot {}
export interface ClaimSnapshot {
id: string;
factionId: string;
systemId: string;
nodeId: string;
bubbleId: string;
state: string;
health: number;
placedAtUtc: string;
activatesAtUtc: string;
}
export interface ClaimDelta extends ClaimSnapshot {}
export interface ConstructionSiteSnapshot {
id: string;
factionId: string;
systemId: string;
nodeId: string;
bubbleId: string;
targetKind: string;
targetDefinitionId: string;
blueprintId?: string | null;
claimId?: string | null;
stationId?: string | null;
state: string;
progress: number;
inventory: InventoryEntry[];
requiredItems: InventoryEntry[];
deliveredItems: InventoryEntry[];
assignedConstructorShipIds: string[];
marketOrderIds: string[];
}
export interface ConstructionSiteDelta extends ConstructionSiteSnapshot {}
export interface MarketOrderSnapshot {
id: string;
factionId: string;
stationId?: string | null;
constructionSiteId?: string | null;
kind: string;
itemId: string;
amount: number;
remainingAmount: number;
valuation: number;
reserveThreshold?: number | null;
policySetId?: string | null;
state: string;
}
export interface MarketOrderDelta extends MarketOrderSnapshot {}
export interface PolicySetSnapshot {
id: string;
ownerKind: string;
ownerId: string;
tradeAccessPolicy: string;
dockingAccessPolicy: string;
constructionAccessPolicy: string;
operationalRangePolicy: string;
}
export interface PolicySetDelta extends PolicySetSnapshot {}
export interface ShipSnapshot {
id: string;
label: string;
role: string;
shipClass: string;
systemId: string;
localPosition: Vector3Dto;
localVelocity: Vector3Dto;
targetLocalPosition: Vector3Dto;
state: string;
orderKind: string | null;
defaultBehaviorKind: string;
controllerTaskKind: string;
nodeId?: string | null;
bubbleId?: string | null;
dockedStationId?: string | null;
commanderId?: string | null;
policySetId?: string | null;
cargoCapacity: number;
workerPopulation: number;
energyStored: number;
inventory: InventoryEntry[];
factionId: string;
health: number;
history: string[];
spatialState: ShipSpatialStateSnapshot;
}
export interface ShipDelta extends ShipSnapshot {}
export interface ShipSpatialStateSnapshot {
spaceLayer: string;
currentSystemId: string;
currentNodeId?: string | null;
currentBubbleId?: string | null;
localPosition?: Vector3Dto | null;
systemPosition?: Vector3Dto | null;
movementRegime: string;
destinationNodeId?: string | null;
transit?: ShipTransitSnapshot | null;
}
export interface ShipTransitSnapshot {
regime: string;
originNodeId?: string | null;
destinationNodeId?: string | null;
startedAtUtc?: string | null;
arrivalDueAtUtc?: string | null;
progress: number;
}
export interface FactionSnapshot {
id: string;
label: string;
color: string;
credits: number;
populationTotal: number;
oreMined: number;
goodsProduced: number;
shipsBuilt: number;
shipsLost: number;
defaultPolicySetId?: string | null;
}
export interface FactionDelta extends FactionSnapshot {}
export type { Vector3Dto, InventoryEntry } from "./contractsCommon";
export type {
WorldSnapshot,
WorldDelta,
SimulationEventRecord,
ObserverScope,
} from "./contractsWorld";
export type {
SystemSnapshot,
PlanetSnapshot,
ResourceNodeSnapshot,
ResourceNodeDelta,
SpatialNodeSnapshot,
SpatialNodeDelta,
LocalBubbleSnapshot,
LocalBubbleDelta,
} from "./contractsCelestial";
export type {
StationSnapshot,
StationDelta,
ClaimSnapshot,
ClaimDelta,
ConstructionSiteSnapshot,
ConstructionSiteDelta,
} from "./contractsInfrastructure";
export type {
MarketOrderSnapshot,
MarketOrderDelta,
PolicySetSnapshot,
PolicySetDelta,
} from "./contractsEconomy";
export type {
ShipSnapshot,
ShipDelta,
ShipSpatialStateSnapshot,
ShipTransitSnapshot,
} from "./contractsShips";
export type { FactionSnapshot, FactionDelta } from "./contractsFactions";

View File

@@ -0,0 +1,67 @@
import type { Vector3Dto } from "./contractsCommon";
export interface SystemSnapshot {
id: string;
label: string;
galaxyPosition: Vector3Dto;
starKind: string;
starCount: number;
starColor: string;
starSize: number;
planets: PlanetSnapshot[];
}
export interface PlanetSnapshot {
label: string;
planetType: string;
shape: string;
moonCount: number;
orbitRadius: number;
orbitSpeed: number;
orbitEccentricity: number;
orbitInclination: number;
orbitLongitudeOfAscendingNode: number;
orbitArgumentOfPeriapsis: number;
orbitPhaseAtEpoch: number;
size: number;
color: string;
hasRing: boolean;
}
export interface ResourceNodeSnapshot {
id: string;
systemId: string;
localPosition: Vector3Dto;
sourceKind: string;
oreRemaining: number;
maxOre: number;
itemId: string;
}
export interface ResourceNodeDelta extends ResourceNodeSnapshot {}
export interface SpatialNodeSnapshot {
id: string;
systemId: string;
kind: string;
localPosition: Vector3Dto;
bubbleId: string;
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 {}

View File

@@ -0,0 +1,10 @@
export interface Vector3Dto {
x: number;
y: number;
z: number;
}
export interface InventoryEntry {
itemId: string;
amount: number;
}

View File

@@ -0,0 +1,28 @@
export interface MarketOrderSnapshot {
id: string;
factionId: string;
stationId?: string | null;
constructionSiteId?: string | null;
kind: string;
itemId: string;
amount: number;
remainingAmount: number;
valuation: number;
reserveThreshold?: number | null;
policySetId?: string | null;
state: string;
}
export interface MarketOrderDelta extends MarketOrderSnapshot {}
export interface PolicySetSnapshot {
id: string;
ownerKind: string;
ownerId: string;
tradeAccessPolicy: string;
dockingAccessPolicy: string;
constructionAccessPolicy: string;
operationalRangePolicy: string;
}
export interface PolicySetDelta extends PolicySetSnapshot {}

View File

@@ -0,0 +1,14 @@
export interface FactionSnapshot {
id: string;
label: string;
color: string;
credits: number;
populationTotal: number;
oreMined: number;
goodsProduced: number;
shipsBuilt: number;
shipsLost: number;
defaultPolicySetId?: string | null;
}
export interface FactionDelta extends FactionSnapshot {}

View File

@@ -0,0 +1,64 @@
import type { InventoryEntry, Vector3Dto } from "./contractsCommon";
export interface StationSnapshot {
id: string;
label: string;
category: string;
systemId: string;
localPosition: Vector3Dto;
nodeId?: string | null;
bubbleId?: string | null;
anchorNodeId?: string | null;
color: string;
dockedShips: number;
dockingPads: number;
energyStored: number;
inventory: InventoryEntry[];
factionId: string;
commanderId?: string | null;
policySetId?: string | null;
population: number;
populationCapacity: number;
workforceRequired: number;
workforceEffectiveRatio: number;
installedModules: string[];
marketOrderIds: string[];
}
export interface StationDelta extends StationSnapshot {}
export interface ClaimSnapshot {
id: string;
factionId: string;
systemId: string;
nodeId: string;
bubbleId: string;
state: string;
health: number;
placedAtUtc: string;
activatesAtUtc: string;
}
export interface ClaimDelta extends ClaimSnapshot {}
export interface ConstructionSiteSnapshot {
id: string;
factionId: string;
systemId: string;
nodeId: string;
bubbleId: string;
targetKind: string;
targetDefinitionId: string;
blueprintId?: string | null;
claimId?: string | null;
stationId?: string | null;
state: string;
progress: number;
inventory: InventoryEntry[];
requiredItems: InventoryEntry[];
deliveredItems: InventoryEntry[];
assignedConstructorShipIds: string[];
marketOrderIds: string[];
}
export interface ConstructionSiteDelta extends ConstructionSiteSnapshot {}

View File

@@ -0,0 +1,52 @@
import type { InventoryEntry, Vector3Dto } from "./contractsCommon";
export interface ShipSnapshot {
id: string;
label: string;
role: string;
shipClass: string;
systemId: string;
localPosition: Vector3Dto;
localVelocity: Vector3Dto;
targetLocalPosition: Vector3Dto;
state: string;
orderKind: string | null;
defaultBehaviorKind: string;
controllerTaskKind: string;
nodeId?: string | null;
bubbleId?: string | null;
dockedStationId?: string | null;
commanderId?: string | null;
policySetId?: string | null;
cargoCapacity: number;
workerPopulation: number;
energyStored: number;
inventory: InventoryEntry[];
factionId: string;
health: number;
history: string[];
spatialState: ShipSpatialStateSnapshot;
}
export interface ShipDelta extends ShipSnapshot {}
export interface ShipSpatialStateSnapshot {
spaceLayer: string;
currentSystemId: string;
currentNodeId?: string | null;
currentBubbleId?: string | null;
localPosition?: Vector3Dto | null;
systemPosition?: Vector3Dto | null;
movementRegime: string;
destinationNodeId?: string | null;
transit?: ShipTransitSnapshot | null;
}
export interface ShipTransitSnapshot {
regime: string;
originNodeId?: string | null;
destinationNodeId?: string | null;
startedAtUtc?: string | null;
arrivalDueAtUtc?: string | null;
progress: number;
}

View File

@@ -0,0 +1,85 @@
import type {
ClaimDelta,
ClaimSnapshot,
ConstructionSiteDelta,
ConstructionSiteSnapshot,
} from "./contractsInfrastructure";
import type {
FactionDelta,
FactionSnapshot,
} from "./contractsFactions";
import type {
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
SpatialNodeDelta,
SpatialNodeSnapshot,
SystemSnapshot,
} from "./contractsCelestial";
import type {
MarketOrderDelta,
PolicySetDelta,
PolicySetSnapshot,
MarketOrderSnapshot,
} from "./contractsEconomy";
import type {
ShipDelta,
ShipSnapshot,
} from "./contractsShips";
export interface WorldSnapshot {
label: string;
seed: number;
sequence: number;
tickIntervalMs: number;
generatedAtUtc: string;
systems: SystemSnapshot[];
spatialNodes: SpatialNodeSnapshot[];
localBubbles: LocalBubbleSnapshot[];
nodes: ResourceNodeSnapshot[];
stations: import("./contractsInfrastructure").StationSnapshot[];
claims: ClaimSnapshot[];
constructionSites: ConstructionSiteSnapshot[];
marketOrders: MarketOrderSnapshot[];
policies: PolicySetSnapshot[];
ships: ShipSnapshot[];
factions: FactionSnapshot[];
}
export interface WorldDelta {
sequence: number;
tickIntervalMs: number;
generatedAtUtc: string;
requiresSnapshotRefresh: boolean;
events: SimulationEventRecord[];
spatialNodes: SpatialNodeDelta[];
localBubbles: LocalBubbleDelta[];
nodes: ResourceNodeDelta[];
stations: import("./contractsInfrastructure").StationDelta[];
claims: ClaimDelta[];
constructionSites: ConstructionSiteDelta[];
marketOrders: MarketOrderDelta[];
policies: PolicySetDelta[];
ships: ShipDelta[];
factions: FactionDelta[];
scope?: ObserverScope | null;
}
export interface SimulationEventRecord {
entityKind: string;
entityId: string;
kind: string;
message: string;
occurredAtUtc: string;
family?: string;
scopeKind?: string;
scopeEntityId?: string | null;
visibility?: string;
}
export interface ObserverScope {
scopeKind: string;
systemId?: string | null;
bubbleId?: string | null;
}

View File

@@ -0,0 +1,348 @@
import * as THREE from "three";
import { ACTIVE_SYSTEM_CAPTURE_RADIUS, ACTIVE_SYSTEM_DETAIL_SCALE, GALAXY_PARALLAX_FACTOR } from "./viewerConstants";
import { computePlanetLocalPosition, currentWorldTimeSeconds, toThreeVector } from "./viewerMath";
import { resolveSelectableSystemId } from "./viewerSelection";
import type {
BubbleVisual,
ClaimVisual,
ConstructionSiteVisual,
NodeVisual,
Selectable,
ShipVisual,
SpatialNodeVisual,
StructureVisual,
WorldState,
} from "./viewerTypes";
interface ResolveSelectionPositionParams {
world: WorldState | undefined;
selection: Selectable;
worldTimeSyncMs: number;
nodeVisuals: Map<string, NodeVisual>;
planetVisuals: { systemId: string; planet: { label: string }; mesh: THREE.Mesh }[];
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined;
resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3;
}
interface FocusOnSelectionParams extends ResolveSelectionPositionParams {
activeSystemId?: string;
galaxyFocus: THREE.Vector3;
systemFocusLocal: THREE.Vector3;
}
interface DetermineActiveSystemParams {
world: WorldState | undefined;
cameraMode: "tactical" | "follow";
cameraTargetShipId?: string;
currentDistance: number;
selectedItems: Selectable[];
galaxyFocus: THREE.Vector3;
}
interface SeedSystemFocusParams {
world: WorldState | undefined;
systemId: string;
cameraMode: "tactical" | "follow";
cameraTargetShipId?: string;
selectedItems: Selectable[];
systemFocusLocal: THREE.Vector3;
worldTimeSyncMs: number;
nodeVisuals: Map<string, NodeVisual>;
planetVisuals: { systemId: string; planet: { label: string }; mesh: THREE.Mesh }[];
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined;
resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3;
}
interface CameraFocusParams {
world: WorldState | undefined;
activeSystemId?: string;
galaxyFocus: THREE.Vector3;
systemFocusLocal: THREE.Vector3;
}
interface DisplayLocalPositionParams {
world: WorldState | undefined;
systemId?: string;
activeSystemId?: string;
localPosition: THREE.Vector3;
systemFocusLocal: THREE.Vector3;
}
export function updatePanFromKeyboard(
keyState: Set<string>,
orbitYaw: number,
currentDistance: number,
activeSystemId: string | undefined,
systemFocusLocal: THREE.Vector3,
galaxyFocus: THREE.Vector3,
delta: number,
minimumDistance: number,
maximumDistance: number,
) {
const move = new THREE.Vector3();
if (keyState.has("w")) {
move.z -= 1;
}
if (keyState.has("s")) {
move.z += 1;
}
if (keyState.has("a")) {
move.x += 1;
}
if (keyState.has("d")) {
move.x -= 1;
}
if (move.lengthSq() === 0) {
return;
}
move.normalize();
const forward = new THREE.Vector3(Math.cos(orbitYaw), 0, Math.sin(orbitYaw));
const right = new THREE.Vector3(-forward.z, 0, forward.x);
const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z));
const speed = THREE.MathUtils.mapLinear(currentDistance, minimumDistance, maximumDistance, 320, 6800);
if (activeSystemId) {
systemFocusLocal.addScaledVector(pan, speed * delta);
return;
}
galaxyFocus.addScaledVector(pan, speed * delta);
}
export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined {
const {
world,
cameraMode,
cameraTargetShipId,
currentDistance,
selectedItems,
galaxyFocus,
} = params;
if (!world) {
return undefined;
}
if (cameraMode === "follow" && cameraTargetShipId) {
return world.ships.get(cameraTargetShipId)?.systemId;
}
if (currentDistance >= 12000) {
return undefined;
}
const selected = selectedItems[0];
if (selected && selectedItems.length === 1) {
if (selected.kind === "system") {
return selected.id;
}
if (selected.kind === "planet") {
return selected.systemId;
}
const selectedSystemId = resolveSelectableSystemId(world, selected);
if (selectedSystemId) {
return selectedSystemId;
}
}
let nearestSystemId: string | undefined;
let nearestDistance = Number.POSITIVE_INFINITY;
for (const system of world.systems.values()) {
const center = toThreeVector(system.galaxyPosition);
const distance = center.distanceTo(galaxyFocus);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestSystemId = system.id;
}
}
return nearestDistance <= Math.max(ACTIVE_SYSTEM_CAPTURE_RADIUS, currentDistance * 2.2)
? nearestSystemId
: undefined;
}
export function resolveSelectionPosition(params: ResolveSelectionPositionParams): THREE.Vector3 | undefined {
const {
world,
selection,
worldTimeSyncMs,
nodeVisuals,
planetVisuals,
computeNodeLocalPosition,
resolveBubblePosition,
resolvePointPosition,
} = params;
if (!world) {
return undefined;
}
if (selection.kind === "ship") {
const ship = world.ships.get(selection.id);
return ship ? toThreeVector(ship.localPosition) : undefined;
}
if (selection.kind === "station") {
const station = world.stations.get(selection.id);
return station ? toThreeVector(station.localPosition) : undefined;
}
if (selection.kind === "node") {
const node = world.nodes.get(selection.id);
const visual = node ? nodeVisuals.get(node.id) : undefined;
return visual
? 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 === "claim") {
const claim = world.claims.get(selection.id);
return claim ? resolvePointPosition(claim.systemId, claim.nodeId) : undefined;
}
if (selection.kind === "construction-site") {
const site = world.constructionSites.get(selection.id);
return site ? resolvePointPosition(site.systemId, site.nodeId) : undefined;
}
if (selection.kind === "planet") {
const system = world.systems.get(selection.systemId);
const planet = system?.planets[selection.planetIndex];
if (!system || !planet) {
return undefined;
}
const visual = planetVisuals.find((candidate) =>
candidate.systemId === selection.systemId && candidate.planet === planet);
return visual?.mesh.position.clone() ?? computePlanetLocalPosition(planet, currentWorldTimeSeconds(world, worldTimeSyncMs));
}
const system = world.systems.get(selection.id);
return system ? toThreeVector(system.galaxyPosition) : undefined;
}
export function focusOnSelection(params: FocusOnSelectionParams) {
const {
world,
selection,
activeSystemId,
galaxyFocus,
systemFocusLocal,
} = params;
const nextFocus = resolveSelectionPosition(params);
if (!nextFocus) {
return;
}
const selectionSystemId = resolveSelectableSystemId(world, selection);
if (selectionSystemId && selection.kind !== "system" && world) {
const system = world.systems.get(selectionSystemId);
if (system) {
galaxyFocus.copy(toThreeVector(system.galaxyPosition));
systemFocusLocal.copy(nextFocus);
return;
}
}
if (activeSystemId && resolveSelectableSystemId(world, selection) === activeSystemId) {
systemFocusLocal.copy(nextFocus);
return;
}
galaxyFocus.copy(nextFocus);
}
export function seedSystemFocusLocal(params: SeedSystemFocusParams) {
const {
world,
systemId,
cameraMode,
cameraTargetShipId,
selectedItems,
systemFocusLocal,
} = params;
if (!world) {
return;
}
if (cameraMode === "follow" && cameraTargetShipId) {
const followedShip = world.ships.get(cameraTargetShipId);
if (followedShip?.systemId === systemId) {
systemFocusLocal.copy(toThreeVector(followedShip.localPosition));
return;
}
}
const selected = selectedItems[0];
if (selected && resolveSelectableSystemId(world, selected) === systemId) {
const selectedPosition = resolveSelectionPosition({
world,
selection: selected,
worldTimeSyncMs: params.worldTimeSyncMs,
nodeVisuals: params.nodeVisuals,
planetVisuals: params.planetVisuals,
computeNodeLocalPosition: params.computeNodeLocalPosition,
resolveBubblePosition: params.resolveBubblePosition,
resolvePointPosition: params.resolvePointPosition,
});
if (selectedPosition) {
systemFocusLocal.copy(selectedPosition);
return;
}
}
systemFocusLocal.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
? toThreeVector(system.galaxyPosition).add(
systemFocusLocal.clone().multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE * GALAXY_PARALLAX_FACTOR),
)
: galaxyFocus;
}
export function toDisplayLocalPosition(params: DisplayLocalPositionParams): THREE.Vector3 {
const {
world,
systemId,
activeSystemId,
localPosition,
systemFocusLocal,
} = params;
if (!world || !systemId) {
return localPosition.clone();
}
const system = world.systems.get(systemId);
if (!system) {
return localPosition.clone();
}
const center = toThreeVector(system.galaxyPosition);
if (systemId !== activeSystemId) {
return center.clone().add(localPosition);
}
return center.clone().add(localPosition.clone().sub(systemFocusLocal).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE));
}

View File

@@ -0,0 +1,23 @@
import type { ZoomLevel } from "./viewerTypes";
export const ZOOM_DISTANCE: Record<ZoomLevel, number> = {
local: 900,
system: 3200,
universe: 26000,
};
export const ACTIVE_SYSTEM_DETAIL_SCALE = 10;
export const GALAXY_PARALLAX_FACTOR = 0.025;
export const ACTIVE_SYSTEM_CAPTURE_RADIUS = 9000;
export const PROJECTED_GALAXY_RADIUS = 65000;
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 = 450;
export const MAX_CAMERA_DISTANCE = 42000;
export interface ZoomBlend {
localWeight: number;
systemWeight: number;
universeWeight: number;
}

View File

@@ -0,0 +1,272 @@
import * as THREE from "three";
import { ViewerInteractionController } from "./viewerInteractionController";
import { ViewerNavigationController } from "./viewerNavigationController";
import { ViewerPresentationController } from "./viewerPresentationController";
import { ViewerSceneDataController } from "./viewerSceneDataController";
import { ViewerWorldLifecycle } from "./viewerWorldLifecycle";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
export function createViewerControllers(host: any) {
const sceneDataController = new ViewerSceneDataController({
documentRef: document,
getWorldGeneratedAtUtc: () => host.world?.generatedAtUtc,
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,
systemVisuals: host.systemVisuals,
systemSummaryVisuals: host.systemSummaryVisuals,
planetVisuals: host.planetVisuals,
orbitLines: host.orbitLines,
spatialNodeVisuals: host.spatialNodeVisuals,
bubbleVisuals: host.bubbleVisuals,
nodeVisuals: host.nodeVisuals,
stationVisuals: host.stationVisuals,
claimVisuals: host.claimVisuals,
constructionSiteVisuals: host.constructionSiteVisuals,
shipVisuals: host.shipVisuals,
registerPresentation: host.registerPresentation.bind(host),
});
const navigationController = new ViewerNavigationController({
getWorld: () => host.world,
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
getActiveSystemId: () => host.activeSystemId,
setActiveSystemId: (value) => {
host.activeSystemId = value;
},
getCameraMode: () => host.cameraMode,
setCameraMode: (value) => {
host.cameraMode = value;
},
getCameraTargetShipId: () => host.cameraTargetShipId,
setCameraTargetShipId: (value) => {
host.cameraTargetShipId = value;
},
getCurrentDistance: () => host.currentDistance,
getSelectedItems: () => host.selectedItems,
getOrbitYaw: () => host.orbitYaw,
galaxyFocus: host.galaxyFocus,
systemFocusLocal: host.systemFocusLocal,
camera: host.camera,
shipVisuals: host.shipVisuals,
nodeVisuals: host.nodeVisuals,
planetVisuals: host.planetVisuals,
systemVisuals: host.systemVisuals,
followCameraPosition: host.followCameraPosition,
followCameraFocus: host.followCameraFocus,
followCameraDirection: host.followCameraDirection,
followCameraDesiredDirection: host.followCameraDesiredDirection,
followCameraOffset: host.followCameraOffset,
createWorldPresentationContext: () => host.createWorldPresentationContext(),
updatePanels: () => host.updatePanels(),
updateGamePanel: (mode: string) => host.updateGamePanel(mode),
});
const presentationController = new ViewerPresentationController({
renderer: host.renderer,
scene: host.scene,
camera: host.camera,
ambienceGroup: host.ambienceGroup,
statusEl: host.statusEl,
networkPanelEl: host.networkPanelEl,
performancePanelEl: host.performancePanelEl,
systemPanelEl: host.systemPanelEl,
systemTitleEl: host.systemTitleEl,
systemBodyEl: host.systemBodyEl,
networkStats: host.networkStats,
performanceStats: host.performanceStats,
getWorld: () => host.world,
getActiveSystemId: () => host.activeSystemId,
getCameraMode: () => host.cameraMode,
getCameraTargetShipId: () => host.cameraTargetShipId,
getZoomLevel: () => host.zoomLevel,
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(),
});
const worldLifecycle = new ViewerWorldLifecycle({
getWorld: () => host.world,
setWorld: (world) => {
host.world = world;
},
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
setWorldTimeSyncMs: (value) => {
host.worldTimeSyncMs = value;
},
getWorldSignature: () => host.worldSignature,
setWorldSignature: (value) => {
host.worldSignature = value;
},
getStream: () => host.stream,
setStream: (stream) => {
host.stream = stream;
},
getCurrentStreamScopeKey: () => host.currentStreamScopeKey,
setCurrentStreamScopeKey: (value) => {
host.currentStreamScopeKey = value;
},
getZoomLevel: () => host.zoomLevel,
getActiveSystemId: () => host.activeSystemId,
getSelectedItems: () => host.selectedItems,
getCameraMode: () => host.cameraMode,
getCameraTargetShipId: () => host.cameraTargetShipId,
getNetworkStats: () => host.networkStats,
getSystemSummaryVisuals: () => host.systemSummaryVisuals,
errorEl: host.errorEl,
factionStripEl: host.factionStripEl,
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),
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),
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(),
updateSystemSummaries: () => host.updateSystemSummaries(),
applyZoomPresentation: () => presentationController.applyZoomPresentation(),
updateNetworkPanel: () => presentationController.updateNetworkPanel(),
updateSystemPanel: () => host.updateSystemPanel(),
updateGamePanel: (mode) => host.updateGamePanel(mode),
describeSelectionParent: (selection) => host.describeSelectionParent(selection),
});
const historyController = new ViewerHistoryWindowController({
historyLayerEl: host.historyLayerEl,
historyWindows: host.historyWindows,
getWorld: () => host.world,
getHistoryWindowCounter: () => host.historyWindowCounter,
setHistoryWindowCounter: (value) => {
host.historyWindowCounter = value;
},
getHistoryWindowZCounter: () => host.historyWindowZCounter,
setHistoryWindowZCounter: (value) => {
host.historyWindowZCounter = value;
},
getHistoryWindowDragId: () => host.historyWindowDragId,
setHistoryWindowDragId: (value) => {
host.historyWindowDragId = value;
},
getHistoryWindowDragPointerId: () => host.historyWindowDragPointerId,
setHistoryWindowDragPointerId: (value) => {
host.historyWindowDragPointerId = value;
},
historyWindowDragOffset: host.historyWindowDragOffset,
renderRecentEvents: (entityKind, entityId) => presentationController.renderRecentEvents(entityKind, entityId),
});
const interactionController = new ViewerInteractionController({
renderer: host.renderer,
raycaster: host.raycaster,
mouse: host.mouse,
camera: host.camera,
selectableTargets: host.selectableTargets,
hoverLabelEl: host.hoverLabelEl,
marqueeEl: host.marqueeEl,
keyState: host.keyState,
getWorld: () => host.world,
getActiveSystemId: () => host.activeSystemId,
getSelectedItems: () => host.selectedItems,
setSelectedItems: (items) => {
host.selectedItems = items;
},
getDragMode: () => host.dragMode,
setDragMode: (mode) => {
host.dragMode = mode;
},
getDragPointerId: () => host.dragPointerId,
setDragPointerId: (pointerId) => {
host.dragPointerId = pointerId;
},
dragStart: host.dragStart,
dragLast: host.dragLast,
getMarqueeActive: () => host.marqueeActive,
setMarqueeActive: (value) => {
host.marqueeActive = value;
},
getSuppressClickSelection: () => host.suppressClickSelection,
setSuppressClickSelection: (value) => {
host.suppressClickSelection = value;
},
getDesiredDistance: () => host.desiredDistance,
setDesiredDistance: (value) => {
host.desiredDistance = value;
},
getCameraMode: () => host.cameraMode,
setCameraMode: (value) => {
host.cameraMode = value;
},
getCameraTargetShipId: () => host.cameraTargetShipId,
setCameraTargetShipId: (value) => {
host.cameraTargetShipId = value;
},
getFollowCameraPosition: () => host.followCameraPosition,
getFollowCameraFocus: () => host.followCameraFocus,
screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y),
applyOrbitDelta: (delta: THREE.Vector2) => {
host.orbitYaw += delta.x * 0.008;
host.orbitPitch = THREE.MathUtils.clamp(host.orbitPitch + delta.y * 0.004, 0.18, 1.3);
},
syncFollowStateFromSelection: () => navigationController.syncFollowStateFromSelection(),
updatePanels: () => host.updatePanels(),
focusOnSelection: (selection) => navigationController.focusOnSelection(selection),
updateGamePanel: (mode) => host.updateGamePanel(mode),
historyController,
});
return {
historyController,
sceneDataController,
navigationController,
presentationController,
worldLifecycle,
interactionController,
};
}
export function wireViewerEvents(host: any) {
host.renderer.domElement.addEventListener("pointerdown", host.interactionController.onPointerDown);
host.renderer.domElement.addEventListener("pointermove", host.interactionController.onPointerMove);
host.renderer.domElement.addEventListener("pointerup", host.interactionController.onPointerUp);
host.renderer.domElement.addEventListener("pointerleave", host.interactionController.onPointerUp);
host.renderer.domElement.addEventListener("click", host.interactionController.onClick);
host.renderer.domElement.addEventListener("dblclick", host.interactionController.onDoubleClick);
host.renderer.domElement.addEventListener("wheel", host.interactionController.onWheel, { passive: false });
host.factionStripEl.addEventListener("click", host.interactionController.onShipStripClick);
host.factionStripEl.addEventListener("dblclick", host.interactionController.onShipStripDoubleClick);
host.historyLayerEl.addEventListener("click", host.interactionController.onHistoryLayerClick);
host.historyLayerEl.addEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown);
window.addEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove);
window.addEventListener("pointerup", host.interactionController.onHistoryWindowPointerUp);
window.addEventListener("keydown", host.interactionController.onKeyDown);
window.addEventListener("keyup", host.interactionController.onKeyUp);
window.addEventListener("resize", host.onResize);
}

View File

@@ -0,0 +1,210 @@
import * as THREE from "three";
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, ZOOM_DISTANCE } from "./viewerConstants";
import type {
CameraMode,
Selectable,
ShipVisual,
SystemVisual,
WorldState,
} from "./viewerTypes";
export function syncFollowStateFromSelection(
selectedItems: Selectable[],
cameraMode: CameraMode,
cameraTargetShipId?: string,
) {
if (selectedItems.length === 1 && selectedItems[0].kind === "ship") {
return {
cameraMode,
cameraTargetShipId: selectedItems[0].id,
};
}
return {
cameraMode: cameraMode === "follow" ? "tactical" : cameraMode,
cameraTargetShipId: undefined,
};
}
export function toggleCameraMode(params: {
cameraMode: CameraMode;
cameraTargetShipId?: string;
selectedItems: Selectable[];
desiredDistance: number;
followCameraPosition: THREE.Vector3;
followCameraFocus: THREE.Vector3;
forceMode?: CameraMode;
}) {
const {
cameraMode,
cameraTargetShipId,
selectedItems,
desiredDistance,
followCameraPosition,
followCameraFocus,
forceMode,
} = params;
const nextMode = forceMode ?? (cameraMode === "follow" ? "tactical" : "follow");
if (nextMode === "tactical") {
return {
cameraMode: "tactical" as const,
cameraTargetShipId,
desiredDistance,
};
}
const nextTargetShipId = cameraTargetShipId
?? (selectedItems.length === 1 && selectedItems[0].kind === "ship" ? selectedItems[0].id : undefined);
if (!nextTargetShipId) {
return {
cameraMode,
cameraTargetShipId,
desiredDistance,
};
}
followCameraPosition.set(0, 0, 0);
followCameraFocus.set(0, 0, 0);
return {
cameraMode: "follow" as const,
cameraTargetShipId: nextTargetShipId,
desiredDistance: Math.min(desiredDistance, 1800),
};
}
export function updateFollowCamera(params: {
world: WorldState | undefined;
cameraMode: CameraMode;
cameraTargetShipId?: string;
shipVisuals: Map<string, ShipVisual>;
currentDistance: number;
camera: THREE.PerspectiveCamera;
followCameraPosition: THREE.Vector3;
followCameraFocus: THREE.Vector3;
followCameraDirection: THREE.Vector3;
followCameraDesiredDirection: THREE.Vector3;
followCameraOffset: THREE.Vector3;
systemFocusLocal: THREE.Vector3;
delta: number;
getAnimatedShipLocalPosition: (visual: ShipVisual) => THREE.Vector3;
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
resolveShipHeading: (visual: ShipVisual, worldPosition: THREE.Vector3) => THREE.Vector3;
}) {
const {
world,
cameraTargetShipId,
shipVisuals,
currentDistance,
camera,
followCameraPosition,
followCameraFocus,
followCameraDirection,
followCameraDesiredDirection,
followCameraOffset,
systemFocusLocal,
delta,
getAnimatedShipLocalPosition,
toDisplayLocalPosition,
resolveShipHeading,
} = params;
if (!cameraTargetShipId || !world) {
return {
handled: false,
cameraMode: "tactical" as const,
cameraTargetShipId,
};
}
const ship = world.ships.get(cameraTargetShipId);
const visual = shipVisuals.get(cameraTargetShipId);
if (!ship || !visual) {
return {
handled: false,
cameraMode: "tactical" as const,
cameraTargetShipId: undefined,
};
}
const shipLocalPosition = getAnimatedShipLocalPosition(visual);
const shipWorldPosition = toDisplayLocalPosition(shipLocalPosition, ship.systemId);
systemFocusLocal.lerp(shipLocalPosition, 1 - Math.exp(-delta * 8));
followCameraDesiredDirection.copy(resolveShipHeading(visual, shipLocalPosition)).normalize();
followCameraDirection.lerp(followCameraDesiredDirection, 1 - Math.exp(-delta * 5));
followCameraDirection.normalize();
const distance = THREE.MathUtils.clamp(currentDistance * 0.72, 320, 6800);
const height = THREE.MathUtils.clamp(distance * 0.18, 70, 1100);
const lookAhead = THREE.MathUtils.clamp(distance * 0.9, 220, 2400);
followCameraOffset.copy(followCameraDirection).multiplyScalar(-distance);
followCameraOffset.y += height;
const desiredPosition = shipWorldPosition.clone().add(followCameraOffset);
const desiredFocus = shipWorldPosition.clone().addScaledVector(followCameraDirection, lookAhead);
desiredFocus.y += height * 0.28;
const positionLerp = 1 - Math.exp(-delta * 6);
const focusLerp = 1 - Math.exp(-delta * 8);
if (followCameraPosition.lengthSq() === 0) {
followCameraPosition.copy(desiredPosition);
followCameraFocus.copy(desiredFocus);
} else {
followCameraPosition.lerp(desiredPosition, positionLerp);
followCameraFocus.lerp(desiredFocus, focusLerp);
}
camera.position.copy(followCameraPosition);
camera.lookAt(followCameraFocus);
return {
handled: true,
cameraMode: "follow" as const,
cameraTargetShipId,
};
}
export function updateSystemDetailVisibility(systemVisuals: Map<string, SystemVisual>, activeSystemId?: string) {
for (const [systemId, visual] of systemVisuals.entries()) {
visual.detailGroup.visible = systemId === activeSystemId;
}
}
export function setShellReticleOpacity(sprite: THREE.Sprite, opacity: number) {
sprite.visible = opacity > 0.02;
sprite.material.opacity = opacity;
sprite.material.needsUpdate = true;
}
export function zoomFromWheel(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);
}
export function applyKeyboardControl(params: {
keyState: Set<string>;
cameraMode: CameraMode;
desiredDistance: number;
key: string;
}) {
const { keyState, key } = params;
keyState.add(key);
let cameraMode = params.cameraMode;
let desiredDistance = params.desiredDistance;
if (["w", "a", "s", "d"].includes(key)) {
cameraMode = "tactical";
}
if (key === "1") {
desiredDistance = ZOOM_DISTANCE.local;
} else if (key === "2") {
desiredDistance = ZOOM_DISTANCE.system;
} else if (key === "3") {
desiredDistance = ZOOM_DISTANCE.universe;
}
return { cameraMode, desiredDistance };
}

View File

@@ -0,0 +1,52 @@
import { inventoryAmount } from "./viewerMath";
import type { CameraMode, Selectable, WorldState } from "./viewerTypes";
export function renderFactionStrip(
world: WorldState | undefined,
selectedItems: Selectable[],
cameraMode: CameraMode,
cameraTargetShipId?: string,
) {
if (!world) {
return "";
}
const ships = [...world.ships.values()]
.sort((left, right) => left.label.localeCompare(right.label));
return ships
.map((ship) => {
const fuel = inventoryAmount(ship.inventory, "gas");
const isSelected = selectedItems.length === 1
&& selectedItems[0].kind === "ship"
&& selectedItems[0].id === ship.id;
const isFollowed = cameraMode === "follow" && cameraTargetShipId === ship.id;
return `
<article class="ship-card${isSelected ? " is-selected" : ""}${isFollowed ? " is-followed" : ""}" data-ship-id="${ship.id}">
<div class="ship-card-header">
<h3>${ship.label}</h3>
<div class="ship-card-meta">
<span class="ship-card-badge">${ship.shipClass}</span>
<button
type="button"
class="ship-card-history-button"
data-history-ship-id="${ship.id}"
aria-label="Open history for ${ship.label}"
title="Open history"
>&#128340;</button>
</div>
</div>
<p>${ship.systemId}</p>
<p>Fuel ${fuel.toFixed(0)} · Energy ${ship.energyStored.toFixed(0)}</p>
<p>State ${ship.state}</p>
<div class="ship-card-ai">
<p>Order ${ship.orderKind ?? "none"}</p>
<p>Behavior ${ship.defaultBehaviorKind}</p>
<p>Task ${ship.controllerTaskKind}</p>
</div>
</article>
`;
})
.join("");
}

View File

@@ -0,0 +1,70 @@
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
export function createHistoryWindowState(
documentRef: Document,
target: Selectable,
historyWindowsCount: number,
historyWindowCounter: number,
): HistoryWindowState {
const id = `history-${historyWindowCounter}`;
const root = documentRef.createElement("aside");
root.className = "history-window";
root.dataset.historyWindowId = id;
root.innerHTML = `
<div class="history-window-header">
<h2 class="history-window-title">History</h2>
<div class="history-window-actions">
<button type="button" class="history-window-copy">Copy</button>
<button type="button" class="history-window-close">Close</button>
</div>
</div>
<div class="history-window-body">No history selected.</div>
`;
root.style.width = `${Math.min(520, window.innerWidth - 40)}px`;
root.style.height = `${Math.min(360, Math.max(240, window.innerHeight * 0.42))}px`;
root.style.left = `${Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerWidth - 580)))}px`;
root.style.top = `${Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerHeight - 420)))}px`;
return {
id,
target,
root,
titleEl: root.querySelector(".history-window-title") as HTMLHeadingElement,
bodyEl: root.querySelector(".history-window-body") as HTMLDivElement,
copyButtonEl: root.querySelector(".history-window-copy") as HTMLButtonElement,
text: "",
};
}
export function refreshHistoryWindow(
world: WorldState,
windowState: HistoryWindowState,
renderRecentEvents: (entityKind: string, entityId: string) => string,
): boolean {
if (windowState.target.kind === "ship") {
const ship = world.ships.get(windowState.target.id);
if (!ship) {
return false;
}
windowState.titleEl.textContent = `${ship.label} History`;
windowState.text = ship.history.length > 0 ? ship.history.join("\n") : "No history yet.";
windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "<br>");
return true;
}
if (windowState.target.kind === "station") {
const station = world.stations.get(windowState.target.id);
if (!station) {
return false;
}
windowState.titleEl.textContent = `${station.label} History`;
windowState.text = renderRecentEvents("station", station.id).replaceAll("<br>", "\n") || "No history yet.";
windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "<br>");
return true;
}
return false;
}

View File

@@ -0,0 +1,180 @@
import * as THREE from "three";
import { createHistoryWindowState, refreshHistoryWindow } from "./viewerHistory";
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
export function openHistoryWindow(
historyWindows: HistoryWindowState[],
historyLayerEl: HTMLDivElement,
target: Selectable,
nextCounter: number,
bringToFront: (windowState: HistoryWindowState) => void,
refreshWindows: () => void,
) {
const existing = historyWindows.find((windowState) => JSON.stringify(windowState.target) === JSON.stringify(target));
if (existing) {
bringToFront(existing);
refreshWindows();
return nextCounter;
}
const windowState = createHistoryWindowState(document, target, historyWindows.length, nextCounter);
historyWindows.push(windowState);
historyLayerEl.append(windowState.root);
bringToFront(windowState);
refreshWindows();
return nextCounter;
}
export function refreshHistoryWindows(
world: WorldState | undefined,
historyWindows: HistoryWindowState[],
renderRecentEvents: (entityKind: string, entityId: string) => string,
destroyWindow: (id: string) => void,
) {
if (!world) {
return;
}
for (const windowState of [...historyWindows]) {
if (!refreshHistoryWindow(world, windowState, renderRecentEvents)) {
destroyWindow(windowState.id);
}
}
}
export function destroyHistoryWindow(
historyWindows: HistoryWindowState[],
historyWindowDragId: string | undefined,
historyWindowDragPointerId: number | undefined,
id: string,
) {
const index = historyWindows.findIndex((windowState) => windowState.id === id);
if (index < 0) {
return {
historyWindowDragId,
historyWindowDragPointerId,
};
}
const [removed] = historyWindows.splice(index, 1);
removed.root.remove();
if (historyWindowDragId === id) {
return {
historyWindowDragId: undefined,
historyWindowDragPointerId: undefined,
};
}
return {
historyWindowDragId,
historyWindowDragPointerId,
};
}
export function bringHistoryWindowToFront(windowState: HistoryWindowState, nextZIndex: number) {
windowState.root.style.zIndex = `${nextZIndex}`;
}
export function beginHistoryWindowDrag(
historyWindows: HistoryWindowState[],
historyWindowDragOffset: THREE.Vector2,
pointerId: number,
windowId: string,
clientX: number,
clientY: number,
) {
const windowState = historyWindows.find((candidate) => candidate.id === windowId);
if (!windowState) {
return {
historyWindowDragId: undefined,
historyWindowDragPointerId: undefined,
};
}
const bounds = windowState.root.getBoundingClientRect();
historyWindowDragOffset.set(clientX - bounds.left, clientY - bounds.top);
windowState.root.setPointerCapture?.(pointerId);
return {
historyWindowDragId: windowId,
historyWindowDragPointerId: pointerId,
};
}
export function updateHistoryWindowDrag(
historyWindows: HistoryWindowState[],
historyWindowDragId: string | undefined,
historyWindowDragPointerId: number | undefined,
historyWindowDragOffset: THREE.Vector2,
pointerId: number,
clientX: number,
clientY: number,
) {
if (historyWindowDragPointerId !== pointerId || !historyWindowDragId) {
return;
}
const windowState = historyWindows.find((candidate) => candidate.id === historyWindowDragId);
if (!windowState) {
return;
}
const width = windowState.root.offsetWidth;
const height = windowState.root.offsetHeight;
const left = THREE.MathUtils.clamp(clientX - historyWindowDragOffset.x, 20, window.innerWidth - width - 20);
const top = THREE.MathUtils.clamp(clientY - historyWindowDragOffset.y, 20, window.innerHeight - height - 20);
windowState.root.style.left = `${left}px`;
windowState.root.style.top = `${top}px`;
}
export function endHistoryWindowDrag(
historyWindows: HistoryWindowState[],
historyWindowDragId: string | undefined,
historyWindowDragPointerId: number | undefined,
pointerId: number,
) {
if (historyWindowDragPointerId !== pointerId || !historyWindowDragId) {
return {
historyWindowDragId,
historyWindowDragPointerId,
};
}
const windowState = historyWindows.find((candidate) => candidate.id === historyWindowDragId);
windowState?.root.releasePointerCapture?.(pointerId);
return {
historyWindowDragId: undefined,
historyWindowDragPointerId: undefined,
};
}
export async function copyTextToClipboard(text: string) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
}
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "0";
textarea.style.width = "1px";
textarea.style.height = "1px";
textarea.style.opacity = "0";
document.body.append(textarea);
textarea.focus();
textarea.select();
try {
const copied = document.execCommand("copy");
if (!copied) {
throw new Error("execCommand copy failed");
}
} finally {
textarea.remove();
}
}

View File

@@ -0,0 +1,175 @@
import * as THREE from "three";
import {
beginHistoryWindowDrag,
bringHistoryWindowToFront,
copyTextToClipboard,
destroyHistoryWindow,
endHistoryWindowDrag,
openHistoryWindow,
refreshHistoryWindows,
updateHistoryWindowDrag,
} from "./viewerHistoryManager";
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
export interface ViewerHistoryWindowContext {
historyLayerEl: HTMLDivElement;
historyWindows: HistoryWindowState[];
getWorld: () => WorldState | undefined;
getHistoryWindowCounter: () => number;
setHistoryWindowCounter: (value: number) => void;
getHistoryWindowZCounter: () => number;
setHistoryWindowZCounter: (value: number) => void;
getHistoryWindowDragId: () => string | undefined;
setHistoryWindowDragId: (value: string | undefined) => void;
getHistoryWindowDragPointerId: () => number | undefined;
setHistoryWindowDragPointerId: (value: number | undefined) => void;
historyWindowDragOffset: THREE.Vector2;
renderRecentEvents: (entityKind: string, entityId: string) => string;
}
export class ViewerHistoryWindowController {
constructor(private readonly context: ViewerHistoryWindowContext) {}
openHistoryWindow(target: Selectable) {
const nextCounter = openHistoryWindow(
this.context.historyWindows,
this.context.historyLayerEl,
target,
this.context.getHistoryWindowCounter() + 1,
(windowState) => this.bringHistoryWindowToFront(windowState),
() => this.refreshHistoryWindows(),
);
this.context.setHistoryWindowCounter(nextCounter);
}
refreshHistoryWindows() {
refreshHistoryWindows(
this.context.getWorld(),
this.context.historyWindows,
this.context.renderRecentEvents,
(id) => this.destroyHistoryWindow(id),
);
}
readonly onHistoryLayerClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const windowEl = target.closest<HTMLElement>("[data-history-window-id]");
const windowId = windowEl?.dataset.historyWindowId;
if (!windowId) {
return;
}
if (target.closest(".history-window-copy")) {
void this.copyHistoryWindowContent(windowId);
return;
}
if (target.closest(".history-window-close")) {
this.destroyHistoryWindow(windowId);
return;
}
const windowState = this.context.historyWindows.find((candidate) => candidate.id === windowId);
if (windowState) {
this.bringHistoryWindowToFront(windowState);
}
};
readonly onHistoryLayerPointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const windowEl = target.closest<HTMLElement>("[data-history-window-id]");
const windowId = windowEl?.dataset.historyWindowId;
if (!windowEl || !windowId) {
return;
}
const windowState = this.context.historyWindows.find((candidate) => candidate.id === windowId);
if (!windowState) {
return;
}
this.bringHistoryWindowToFront(windowState);
if (!target.closest(".history-window-header") || target.closest("button")) {
return;
}
const nextState = beginHistoryWindowDrag(
this.context.historyWindows,
this.context.historyWindowDragOffset,
event.pointerId,
windowId,
event.clientX,
event.clientY,
);
this.context.setHistoryWindowDragId(nextState.historyWindowDragId);
this.context.setHistoryWindowDragPointerId(nextState.historyWindowDragPointerId);
};
readonly onHistoryWindowPointerMove = (event: PointerEvent) => {
updateHistoryWindowDrag(
this.context.historyWindows,
this.context.getHistoryWindowDragId(),
this.context.getHistoryWindowDragPointerId(),
this.context.historyWindowDragOffset,
event.pointerId,
event.clientX,
event.clientY,
);
};
readonly onHistoryWindowPointerUp = (event: PointerEvent) => {
const nextState = endHistoryWindowDrag(
this.context.historyWindows,
this.context.getHistoryWindowDragId(),
this.context.getHistoryWindowDragPointerId(),
event.pointerId,
);
this.context.setHistoryWindowDragId(nextState.historyWindowDragId);
this.context.setHistoryWindowDragPointerId(nextState.historyWindowDragPointerId);
};
private destroyHistoryWindow(id: string) {
const nextState = destroyHistoryWindow(
this.context.historyWindows,
this.context.getHistoryWindowDragId(),
this.context.getHistoryWindowDragPointerId(),
id,
);
this.context.setHistoryWindowDragId(nextState.historyWindowDragId);
this.context.setHistoryWindowDragPointerId(nextState.historyWindowDragPointerId);
}
private async copyHistoryWindowContent(windowId: string) {
const windowState = this.context.historyWindows.find((candidate) => candidate.id === windowId);
if (!windowState?.text) {
return;
}
try {
await copyTextToClipboard(windowState.text);
windowState.copyButtonEl.textContent = "Copied";
window.setTimeout(() => {
windowState.copyButtonEl.textContent = "Copy";
}, 1200);
} catch {
windowState.copyButtonEl.textContent = "Failed";
window.setTimeout(() => {
windowState.copyButtonEl.textContent = "Copy";
}, 1200);
}
}
private bringHistoryWindowToFront(windowState: HistoryWindowState) {
const nextZIndex = this.context.getHistoryWindowZCounter() + 1;
this.context.setHistoryWindowZCounter(nextZIndex);
bringHistoryWindowToFront(windowState, nextZIndex);
}
}

View File

@@ -0,0 +1,71 @@
export interface ViewerHudElements {
root: HTMLDivElement;
statusEl: HTMLDivElement;
systemPanelEl: HTMLDivElement;
systemTitleEl: HTMLHeadingElement;
systemBodyEl: HTMLDivElement;
detailTitleEl: HTMLHeadingElement;
detailBodyEl: HTMLDivElement;
factionStripEl: HTMLDivElement;
networkPanelEl: HTMLDivElement;
performancePanelEl: HTMLDivElement;
errorEl: HTMLDivElement;
historyLayerEl: HTMLDivElement;
marqueeEl: HTMLDivElement;
hoverLabelEl: HTMLDivElement;
}
export function createViewerHud(documentRef: Document): ViewerHudElements {
const root = documentRef.createElement("div");
root.className = "viewer-shell";
root.innerHTML = `
<div class="left-panel-stack">
<header class="topbar">
<h2>Game</h2>
<div class="topbar-body">Bootstrapping</div>
</header>
<aside class="network-panel">
<h2>Network</h2>
<div class="network-body">Waiting for snapshot.</div>
</aside>
<aside class="performance-panel">
<h2>Performance</h2>
<div class="performance-body">Waiting for frame samples.</div>
</aside>
</div>
<div class="right-panel-stack">
<aside class="info-panel system-panel-section">
<h2>System</h2>
<h3 class="system-title">Deep Space</h3>
<div class="system-body">Waiting for the authoritative snapshot.</div>
</aside>
<aside class="info-panel detail-panel-section">
<h2>Focus</h2>
<h3 class="detail-title">Nothing selected</h3>
<div class="detail-body">Waiting for the authoritative snapshot.</div>
</aside>
<div class="error-strip" hidden></div>
</div>
<div class="history-layer"></div>
<section class="ship-strip"></section>
<div class="marquee-box"></div>
<div class="hover-label" hidden></div>
`;
return {
root,
statusEl: root.querySelector(".topbar-body") as HTMLDivElement,
systemPanelEl: root.querySelector(".system-panel-section") as HTMLDivElement,
systemTitleEl: root.querySelector(".system-title") as HTMLHeadingElement,
systemBodyEl: root.querySelector(".system-body") as HTMLDivElement,
detailTitleEl: root.querySelector(".detail-title") as HTMLHeadingElement,
detailBodyEl: root.querySelector(".detail-body") as HTMLDivElement,
factionStripEl: root.querySelector(".ship-strip") as HTMLDivElement,
networkPanelEl: root.querySelector(".network-body") as HTMLDivElement,
performancePanelEl: root.querySelector(".performance-body") as HTMLDivElement,
errorEl: root.querySelector(".error-strip") as HTMLDivElement,
historyLayerEl: root.querySelector(".history-layer") as HTMLDivElement,
marqueeEl: root.querySelector(".marquee-box") as HTMLDivElement,
hoverLabelEl: root.querySelector(".hover-label") as HTMLDivElement,
};
}

View File

@@ -0,0 +1,131 @@
import * as THREE from "three";
import { getSelectionGroup } from "./viewerSelection";
import type { Selectable, SelectionGroup, WorldState } from "./viewerTypes";
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 bounds = renderer.domElement.getBoundingClientRect();
mouse.x = ((clientX - bounds.left) / bounds.width) * 2 - 1;
mouse.y = -(((clientY - bounds.top) / bounds.height) * 2 - 1);
raycaster.setFromCamera(mouse, camera);
const hit = raycaster.intersectObjects([...selectableTargets.keys()], false)[0];
return hit ? selectableTargets.get(hit.object) : undefined;
}
export function updateHoverLabel(params: {
dragMode?: string;
hoverLabelEl: HTMLDivElement;
selection: Selectable | undefined;
activeSystemId?: string;
world?: WorldState;
point: THREE.Vector2;
}) {
const {
dragMode,
hoverLabelEl,
selection,
activeSystemId,
world,
point,
} = params;
if (dragMode) {
hoverLabelEl.hidden = true;
return;
}
if (!selection || selection.kind !== "system" || selection.id === activeSystemId) {
hoverLabelEl.hidden = true;
return;
}
const system = world?.systems.get(selection.id);
if (!system) {
hoverLabelEl.hidden = true;
return;
}
hoverLabelEl.hidden = false;
hoverLabelEl.textContent = system.label;
hoverLabelEl.style.left = `${point.x + 14}px`;
hoverLabelEl.style.top = `${point.y + 14}px`;
}
export function updateMarqueeBox(
marqueeEl: HTMLDivElement,
dragStart: THREE.Vector2,
dragLast: THREE.Vector2,
) {
const minX = Math.min(dragStart.x, dragLast.x);
const minY = Math.min(dragStart.y, dragLast.y);
const maxX = Math.max(dragStart.x, dragLast.x);
const maxY = Math.max(dragStart.y, dragLast.y);
marqueeEl.style.left = `${minX}px`;
marqueeEl.style.top = `${minY}px`;
marqueeEl.style.width = `${maxX - minX}px`;
marqueeEl.style.height = `${maxY - minY}px`;
}
export function hideMarqueeBox(marqueeEl: HTMLDivElement) {
marqueeEl.style.display = "none";
marqueeEl.style.width = "0";
marqueeEl.style.height = "0";
}
export function completeMarqueeSelection(params: {
renderer: THREE.WebGLRenderer;
camera: THREE.Camera;
dragStart: THREE.Vector2;
dragLast: THREE.Vector2;
selectableTargets: Map<THREE.Object3D, Selectable>;
}) {
const {
renderer,
camera,
dragStart,
dragLast,
selectableTargets,
} = params;
const bounds = renderer.domElement.getBoundingClientRect();
const minX = Math.min(dragStart.x, dragLast.x);
const minY = Math.min(dragStart.y, dragLast.y);
const maxX = Math.max(dragStart.x, dragLast.x);
const maxY = Math.max(dragStart.y, dragLast.y);
const grouped = new Map<SelectionGroup, Selectable[]>();
for (const [object, selectable] of selectableTargets.entries()) {
if (object instanceof THREE.Sprite && !object.visible) {
continue;
}
if (!object.visible) {
continue;
}
const worldPosition = new THREE.Vector3();
object.getWorldPosition(worldPosition);
worldPosition.project(camera);
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) {
continue;
}
const group = getSelectionGroup(selectable);
const list = grouped.get(group) ?? [];
if (!list.some((entry) => JSON.stringify(entry) === JSON.stringify(selectable))) {
list.push(selectable);
}
grouped.set(group, list);
}
return [...grouped.entries()]
.sort((left, right) => right[1].length - left[1].length)[0]?.[1] ?? [];
}

View File

@@ -0,0 +1,297 @@
import * as THREE from "three";
import {
completeMarqueeSelection,
hideMarqueeBox,
pickSelectableAtClientPosition,
updateHoverLabel,
updateMarqueeBox,
} from "./viewerInteraction";
import {
applyKeyboardControl,
toggleCameraMode,
zoomFromWheel,
} from "./viewerControls";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import type {
CameraMode,
DragMode,
Selectable,
WorldState,
} from "./viewerTypes";
export interface ViewerInteractionContext {
renderer: THREE.WebGLRenderer;
raycaster: THREE.Raycaster;
mouse: THREE.Vector2;
camera: THREE.PerspectiveCamera;
selectableTargets: Map<THREE.Object3D, Selectable>;
hoverLabelEl: HTMLDivElement;
marqueeEl: HTMLDivElement;
keyState: Set<string>;
getWorld: () => WorldState | undefined;
getActiveSystemId: () => string | undefined;
getSelectedItems: () => Selectable[];
setSelectedItems: (items: Selectable[]) => void;
getDragMode: () => DragMode | undefined;
setDragMode: (mode: DragMode | undefined) => void;
getDragPointerId: () => number | undefined;
setDragPointerId: (pointerId: number | undefined) => void;
dragStart: THREE.Vector2;
dragLast: THREE.Vector2;
getMarqueeActive: () => boolean;
setMarqueeActive: (value: boolean) => void;
getSuppressClickSelection: () => boolean;
setSuppressClickSelection: (value: boolean) => void;
getDesiredDistance: () => number;
setDesiredDistance: (value: number) => void;
getCameraMode: () => CameraMode;
setCameraMode: (value: CameraMode) => void;
getCameraTargetShipId: () => string | undefined;
setCameraTargetShipId: (value: string | undefined) => void;
getFollowCameraPosition: () => THREE.Vector3;
getFollowCameraFocus: () => THREE.Vector3;
screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2;
applyOrbitDelta: (delta: THREE.Vector2) => void;
syncFollowStateFromSelection: () => void;
updatePanels: () => void;
focusOnSelection: (selection: Selectable) => void;
updateGamePanel: (mode: string) => void;
historyController: ViewerHistoryWindowController;
}
export class ViewerInteractionController {
constructor(private readonly context: ViewerInteractionContext) {}
readonly onPointerDown = (event: PointerEvent) => {
if (event.button === 1) {
this.context.setDragMode("orbit");
this.context.setDragPointerId(event.pointerId);
this.context.dragLast.copy(this.context.screenPointFromClient(event.clientX, event.clientY));
this.context.renderer.domElement.setPointerCapture(event.pointerId);
return;
}
if (event.button !== 0) {
return;
}
this.context.setDragMode("marquee");
this.context.setDragPointerId(event.pointerId);
this.context.dragStart.copy(this.context.screenPointFromClient(event.clientX, event.clientY));
this.context.dragLast.copy(this.context.dragStart);
this.context.setMarqueeActive(false);
this.context.renderer.domElement.setPointerCapture(event.pointerId);
};
readonly onPointerMove = (event: PointerEvent) => {
this.updateHoverLabel(event);
if (this.context.getDragPointerId() !== event.pointerId || !this.context.getDragMode()) {
return;
}
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
if (this.context.getDragMode() === "orbit") {
const delta = point.clone().sub(this.context.dragLast);
this.context.dragLast.copy(point);
this.context.applyOrbitDelta(delta);
return;
}
const dragDistance = point.distanceTo(this.context.dragStart);
if (!this.context.getMarqueeActive() && dragDistance > 8) {
this.context.setMarqueeActive(true);
this.context.setSuppressClickSelection(true);
this.context.marqueeEl.style.display = "block";
}
if (!this.context.getMarqueeActive()) {
return;
}
this.context.dragLast.copy(point);
updateMarqueeBox(this.context.marqueeEl, this.context.dragStart, this.context.dragLast);
};
readonly onPointerUp = (event: PointerEvent) => {
if (this.context.getDragPointerId() !== event.pointerId) {
return;
}
if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) {
this.context.renderer.domElement.releasePointerCapture(event.pointerId);
}
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) {
this.completeMarqueeSelection();
hideMarqueeBox(this.context.marqueeEl);
}
this.context.setDragMode(undefined);
this.context.setDragPointerId(undefined);
this.context.setMarqueeActive(false);
};
readonly onClick = (event: MouseEvent) => {
if (this.context.getSuppressClickSelection()) {
this.context.setSuppressClickSelection(false);
return;
}
const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY);
this.context.setSelectedItems(picked ? [picked] : []);
this.context.syncFollowStateFromSelection();
this.context.updatePanels();
};
readonly onShipStripClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const historyButton = target.closest<HTMLElement>("[data-history-ship-id]");
const historyShipId = historyButton?.dataset.historyShipId;
if (historyShipId) {
this.context.historyController.openHistoryWindow({ kind: "ship", id: historyShipId });
return;
}
const card = target.closest<HTMLElement>("[data-ship-id]");
const shipId = card?.dataset.shipId;
if (!shipId) {
return;
}
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
this.context.syncFollowStateFromSelection();
this.context.updatePanels();
};
readonly onShipStripDoubleClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
if (target.closest("[data-history-ship-id]")) {
return;
}
const card = target.closest<HTMLElement>("[data-ship-id]");
const shipId = card?.dataset.shipId;
if (!shipId) {
return;
}
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
this.context.syncFollowStateFromSelection();
this.context.focusOnSelection({ kind: "ship", id: shipId });
this.toggleCameraMode("follow");
this.context.updatePanels();
this.context.updateGamePanel("Live");
};
readonly onHistoryLayerClick = (event: MouseEvent) => this.context.historyController.onHistoryLayerClick(event);
readonly onHistoryLayerPointerDown = (event: PointerEvent) => this.context.historyController.onHistoryLayerPointerDown(event);
readonly onHistoryWindowPointerMove = (event: PointerEvent) => this.context.historyController.onHistoryWindowPointerMove(event);
readonly onHistoryWindowPointerUp = (event: PointerEvent) => this.context.historyController.onHistoryWindowPointerUp(event);
readonly onDoubleClick = () => {
const selectedItems = this.context.getSelectedItems();
if (selectedItems.length !== 1) {
return;
}
this.context.focusOnSelection(selectedItems[0]);
this.context.syncFollowStateFromSelection();
};
readonly onWheel = (event: WheelEvent) => {
event.preventDefault();
this.context.setDesiredDistance(zoomFromWheel(this.context.getDesiredDistance(), event.deltaY));
this.context.updateGamePanel("Live");
};
readonly onKeyDown = (event: KeyboardEvent) => {
if (event.repeat) {
return;
}
const key = event.key.toLowerCase();
const controlState = applyKeyboardControl({
keyState: this.context.keyState,
cameraMode: this.context.getCameraMode(),
desiredDistance: this.context.getDesiredDistance(),
key,
});
this.context.setCameraMode(controlState.cameraMode);
this.context.setDesiredDistance(controlState.desiredDistance);
if (key === "c") {
this.toggleCameraMode();
}
this.context.updateGamePanel("Live");
};
readonly onKeyUp = (event: KeyboardEvent) => {
this.context.keyState.delete(event.key.toLowerCase());
};
updateHoverLabel(event: PointerEvent) {
updateHoverLabel({
dragMode: this.context.getDragMode(),
hoverLabelEl: this.context.hoverLabelEl,
selection: this.pickSelectableAtClientPosition(event.clientX, event.clientY),
activeSystemId: this.context.getActiveSystemId(),
world: this.context.getWorld(),
point: this.context.screenPointFromClient(event.clientX, event.clientY),
});
}
refreshHistoryWindows() {
this.context.historyController.refreshHistoryWindows();
}
toggleCameraMode(forceMode?: CameraMode) {
const nextState = toggleCameraMode({
cameraMode: this.context.getCameraMode(),
cameraTargetShipId: this.context.getCameraTargetShipId(),
selectedItems: this.context.getSelectedItems(),
desiredDistance: this.context.getDesiredDistance(),
followCameraPosition: this.context.getFollowCameraPosition(),
followCameraFocus: this.context.getFollowCameraFocus(),
forceMode,
});
this.context.setCameraMode(nextState.cameraMode);
this.context.setCameraTargetShipId(nextState.cameraTargetShipId);
this.context.setDesiredDistance(nextState.desiredDistance);
}
private pickSelectableAtClientPosition(clientX: number, clientY: number) {
return pickSelectableAtClientPosition(
this.context.renderer,
this.context.raycaster,
this.context.mouse,
this.context.camera,
this.context.selectableTargets,
clientX,
clientY,
);
}
private completeMarqueeSelection() {
const selection = completeMarqueeSelection({
renderer: this.context.renderer,
camera: this.context.camera,
dragStart: this.context.dragStart,
dragLast: this.context.dragLast,
selectableTargets: this.context.selectableTargets,
});
this.context.setSelectedItems(selection);
this.context.syncFollowStateFromSelection();
this.context.updatePanels();
}
}

View File

@@ -0,0 +1,193 @@
import * as THREE from "three";
import { MOON_RENDER_SCALE } from "./viewerConstants";
import type {
PlanetSnapshot,
Vector3Dto,
WorldSnapshot,
} from "./contracts";
import type {
OrbitalAnchor,
WorldState,
ZoomLevel,
} from "./viewerTypes";
import type { ZoomBlend } from "./viewerConstants";
export function formatInventory(entries: { itemId: string; amount: number }[]): string {
if (entries.length === 0) {
return "empty";
}
return entries
.map((entry) => `${entry.itemId} ${entry.amount.toFixed(0)}`)
.join("<br>");
}
export function inventoryAmount(entries: { itemId: string; amount: number }[], itemId: string): number {
return entries.find((entry) => entry.itemId === itemId)?.amount ?? 0;
}
export function formatVector(vector: Vector3Dto): string {
return `${vector.x.toFixed(1)}, ${vector.y.toFixed(1)}, ${vector.z.toFixed(1)}`;
}
export function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
if (bytes >= 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${Math.round(bytes)} B`;
}
export function smoothBand(value: number, start: number, end: number): number {
const t = THREE.MathUtils.clamp((value - start) / Math.max(end - start, 1), 0, 1);
return t * t * (3 - (2 * t));
}
export function computeZoomBlend(distance: number): ZoomBlend {
const localToSystem = smoothBand(distance, 1200, 5200);
const systemToUniverse = smoothBand(distance, 9000, 22000);
return {
localWeight: 1 - localToSystem,
systemWeight: Math.min(localToSystem, 1 - systemToUniverse),
universeWeight: systemToUniverse,
};
}
export function classifyZoomLevel(distance: number): ZoomLevel {
const blend = computeZoomBlend(distance);
if (blend.localWeight >= blend.systemWeight && blend.localWeight >= blend.universeWeight) {
return "local";
}
if (blend.systemWeight >= blend.universeWeight) {
return "system";
}
return "universe";
}
export function toThreeVector(vector: Vector3Dto): THREE.Vector3 {
return new THREE.Vector3(vector.x, vector.y, vector.z);
}
export function currentWorldTimeSeconds(world: WorldState | undefined, worldTimeSyncMs: number): number {
if (!world) {
return 0;
}
const baseUtcMs = Date.parse(world.generatedAtUtc);
const elapsedMs = performance.now() - worldTimeSyncMs;
return ((baseUtcMs + elapsedMs) / 1000) + (world.seed * 97);
}
export function hashUnit(seed: number, value: string): number {
let hash = seed;
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) - hash) + value.charCodeAt(index);
hash |= 0;
}
return (hash >>> 0) / 0xffffffff;
}
export function computePlanetLocalPosition(planet: PlanetSnapshot, timeSeconds: number, phaseOverrideDegrees?: number): THREE.Vector3 {
const eccentricity = THREE.MathUtils.clamp(planet.orbitEccentricity, 0, 0.85);
const meanAnomaly = THREE.MathUtils.degToRad(phaseOverrideDegrees ?? planet.orbitPhaseAtEpoch) + (timeSeconds * planet.orbitSpeed);
const eccentricAnomaly = meanAnomaly
+ (eccentricity * Math.sin(meanAnomaly))
+ (0.5 * eccentricity * eccentricity * Math.sin(2 * meanAnomaly));
const semiMajorAxis = planet.orbitRadius;
const semiMinorAxis = semiMajorAxis * Math.sqrt(Math.max(1 - (eccentricity * eccentricity), 0.05));
const local = new THREE.Vector3(
semiMajorAxis * (Math.cos(eccentricAnomaly) - eccentricity),
0,
semiMinorAxis * Math.sin(eccentricAnomaly),
);
local.applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(planet.orbitArgumentOfPeriapsis));
local.applyAxisAngle(new THREE.Vector3(1, 0, 0), THREE.MathUtils.degToRad(planet.orbitInclination));
local.applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(planet.orbitLongitudeOfAscendingNode));
return local;
}
export function computeMoonOrbitRadius(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
const spacing = planet.size * 1.4;
const variance = hashUnit(seed, `${planet.label}:${moonIndex}:radius`) * planet.size * 0.9;
return (planet.size * 1.8) + (moonIndex * spacing) + variance;
}
export function computeMoonOrbitSpeed(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
const radius = computeMoonOrbitRadius(planet, moonIndex, seed);
return 0.9 / Math.sqrt(Math.max(radius, 1)) + (moonIndex * 0.003);
}
export function computeMoonLocalPosition(planet: PlanetSnapshot, moonIndex: number, timeSeconds: number, seed: number): THREE.Vector3 {
const orbitRadius = computeMoonOrbitRadius(planet, moonIndex, seed);
const speed = computeMoonOrbitSpeed(planet, moonIndex, seed);
const phase = hashUnit(seed, `${planet.label}:${moonIndex}:phase`) * Math.PI * 2;
const inclination = THREE.MathUtils.degToRad((hashUnit(seed, `${planet.label}:${moonIndex}:inclination`) - 0.5) * 28);
const node = THREE.MathUtils.degToRad(hashUnit(seed, `${planet.label}:${moonIndex}:node`) * 360);
const angle = phase + (timeSeconds * speed);
const local = new THREE.Vector3(
Math.cos(angle) * orbitRadius,
0,
Math.sin(angle) * orbitRadius,
);
local.applyAxisAngle(new THREE.Vector3(1, 0, 0), inclination);
local.applyAxisAngle(new THREE.Vector3(0, 1, 0), node);
return local;
}
export function computeMoonSize(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
const base = Math.max(2.2, planet.size * 0.11);
const variance = hashUnit(seed, `${planet.label}:${moonIndex}:size`) * Math.max(planet.size * 0.16, 2.5);
return Math.min(base + variance, planet.size * 0.42);
}
export function celestialRenderRadius(size: number, scale: number, minRadius: number, exponent = 1): number {
return Math.max(minRadius, Math.pow(Math.max(size, 0.1), exponent) * scale);
}
export function computeMoonRenderRadius(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
return celestialRenderRadius(computeMoonSize(planet, moonIndex, seed), MOON_RENDER_SCALE, 2.5, 1.04);
}
export function starHaloOpacity(starKind: string): number {
if (starKind.includes("neutron")) {
return 0.22;
}
if (starKind.includes("white-dwarf")) {
return 0.18;
}
if (starKind.includes("brown-dwarf")) {
return 0.1;
}
return 0.14;
}
export function resolveOrbitalAnchorPosition(
world: WorldState | undefined,
systemId: string,
anchor: OrbitalAnchor,
timeSeconds: number,
seed: number,
): THREE.Vector3 {
if (!world || anchor.kind === "star") {
return new THREE.Vector3();
}
const system = world.systems.get(systemId);
const planet = system?.planets[anchor.planetIndex];
if (!system || !planet) {
return new THREE.Vector3();
}
const planetPosition = computePlanetLocalPosition(planet, timeSeconds);
if (anchor.kind === "planet") {
return planetPosition;
}
return planetPosition.add(computeMoonLocalPosition(planet, anchor.moonIndex, timeSeconds, seed));
}

View File

@@ -0,0 +1,193 @@
import * as THREE from "three";
import {
determineActiveSystemId,
focusOnSelection,
getCameraFocusWorldPosition,
resolveSelectionPosition,
seedSystemFocusLocal,
toDisplayLocalPosition,
} from "./viewerCamera";
import {
syncFollowStateFromSelection,
updateFollowCamera,
updateSystemDetailVisibility,
} from "./viewerControls";
import { computeNodeLocalPosition, resolveBubblePosition, resolvePointPosition } from "./viewerWorldPresentation";
import { getAnimatedShipLocalPosition, resolveShipHeading } from "./viewerPresentation";
import type {
CameraMode,
NodeVisual,
PlanetVisual,
Selectable,
ShipVisual,
SystemVisual,
WorldState,
} from "./viewerTypes";
export interface ViewerNavigationContext {
getWorld: () => WorldState | undefined;
getWorldTimeSyncMs: () => number;
getActiveSystemId: () => string | undefined;
setActiveSystemId: (value: string | undefined) => void;
getCameraMode: () => CameraMode;
setCameraMode: (value: CameraMode) => void;
getCameraTargetShipId: () => string | undefined;
setCameraTargetShipId: (value: string | undefined) => void;
getCurrentDistance: () => number;
getSelectedItems: () => Selectable[];
getOrbitYaw: () => number;
galaxyFocus: THREE.Vector3;
systemFocusLocal: THREE.Vector3;
camera: THREE.PerspectiveCamera;
shipVisuals: Map<string, ShipVisual>;
nodeVisuals: Map<string, NodeVisual>;
planetVisuals: PlanetVisual[];
systemVisuals: Map<string, SystemVisual>;
followCameraPosition: THREE.Vector3;
followCameraFocus: THREE.Vector3;
followCameraDirection: THREE.Vector3;
followCameraDesiredDirection: THREE.Vector3;
followCameraOffset: THREE.Vector3;
createWorldPresentationContext: () => any;
updatePanels: () => void;
updateGamePanel: (mode: string) => void;
}
export class ViewerNavigationController {
constructor(private readonly context: ViewerNavigationContext) {}
focusOnSelection(selection: Selectable) {
focusOnSelection({
world: this.context.getWorld(),
selection,
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: (systemId, nodeId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, nodeId),
activeSystemId: this.context.getActiveSystemId(),
galaxyFocus: this.context.galaxyFocus,
systemFocusLocal: this.context.systemFocusLocal,
});
}
resolveSelectionPosition(selection: Selectable) {
return resolveSelectionPosition({
world: this.context.getWorld(),
selection,
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: (systemId, nodeId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, nodeId),
});
}
updateActiveSystem() {
const nextActiveSystemId = determineActiveSystemId({
world: this.context.getWorld(),
cameraMode: this.context.getCameraMode(),
cameraTargetShipId: this.context.getCameraTargetShipId(),
currentDistance: this.context.getCurrentDistance(),
selectedItems: this.context.getSelectedItems(),
galaxyFocus: this.context.galaxyFocus,
});
if (nextActiveSystemId === this.context.getActiveSystemId()) {
return;
}
if (nextActiveSystemId) {
this.seedSystemFocusLocal(nextActiveSystemId);
}
this.context.setActiveSystemId(nextActiveSystemId);
this.updateSystemDetailVisibility();
this.context.updatePanels();
this.context.updateGamePanel("Live");
}
updateFollowCamera(delta: number) {
const nextState = updateFollowCamera({
world: this.context.getWorld(),
cameraMode: this.context.getCameraMode(),
cameraTargetShipId: this.context.getCameraTargetShipId(),
shipVisuals: this.context.shipVisuals,
currentDistance: this.context.getCurrentDistance(),
camera: this.context.camera,
followCameraPosition: this.context.followCameraPosition,
followCameraFocus: this.context.followCameraFocus,
followCameraDirection: this.context.followCameraDirection,
followCameraDesiredDirection: this.context.followCameraDesiredDirection,
followCameraOffset: this.context.followCameraOffset,
systemFocusLocal: this.context.systemFocusLocal,
delta,
getAnimatedShipLocalPosition,
toDisplayLocalPosition: (localPosition, systemId) => this.toDisplayLocalPosition(localPosition, systemId),
resolveShipHeading: (visual, worldPosition) => resolveShipHeading(visual, worldPosition, this.context.getOrbitYaw()),
});
this.context.setCameraMode(nextState.cameraMode);
this.context.setCameraTargetShipId(nextState.cameraTargetShipId);
return nextState.handled;
}
syncFollowStateFromSelection() {
const nextState = syncFollowStateFromSelection(
this.context.getSelectedItems(),
this.context.getCameraMode(),
this.context.getCameraTargetShipId(),
);
this.context.setCameraMode(nextState.cameraMode);
this.context.setCameraTargetShipId(nextState.cameraTargetShipId);
}
updateSystemDetailVisibility() {
updateSystemDetailVisibility(this.context.systemVisuals, this.context.getActiveSystemId());
}
getCameraFocusWorldPosition() {
return getCameraFocusWorldPosition({
world: this.context.getWorld(),
activeSystemId: this.context.getActiveSystemId(),
galaxyFocus: this.context.galaxyFocus,
systemFocusLocal: this.context.systemFocusLocal,
});
}
seedSystemFocusLocal(systemId: string) {
seedSystemFocusLocal({
world: this.context.getWorld(),
systemId,
cameraMode: this.context.getCameraMode(),
cameraTargetShipId: this.context.getCameraTargetShipId(),
selectedItems: this.context.getSelectedItems(),
systemFocusLocal: this.context.systemFocusLocal,
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),
});
}
toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) {
return toDisplayLocalPosition({
world: this.context.getWorld(),
systemId,
activeSystemId: this.context.getActiveSystemId(),
localPosition,
systemFocusLocal: this.context.systemFocusLocal,
});
}
}

View File

@@ -0,0 +1,296 @@
import { formatInventory, formatVector } from "./viewerMath";
import { describeOrbitalParent, describeSelectable, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
import type {
CameraMode,
HistoryWindowState,
NodeVisual,
OrbitalAnchor,
Selectable,
ShipVisual,
StructureVisual,
WorldState,
} from "./viewerTypes";
interface DetailPanelParams {
world: WorldState;
selectedItems: Selectable[];
zoomLevel: string;
cameraMode: CameraMode;
cameraTargetShipId?: string;
worldLabel: string;
describeSelectionParent: (selection: Selectable) => string;
}
interface SystemPanelParams {
world: WorldState;
activeSystemId?: string;
systemTitleEl: HTMLHeadingElement;
systemBodyEl: HTMLDivElement;
systemPanelEl: HTMLDivElement;
cameraMode: CameraMode;
cameraTargetShipId?: string;
}
export function updateDetailPanel(
detailTitleEl: HTMLHeadingElement,
detailBodyEl: HTMLDivElement,
params: DetailPanelParams,
) {
const {
world,
selectedItems,
zoomLevel,
cameraMode,
cameraTargetShipId,
worldLabel,
describeSelectionParent,
} = params;
if (selectedItems.length === 0) {
detailTitleEl.textContent = worldLabel;
detailBodyEl.innerHTML = `
Zoom ${zoomLevel}<br>
Systems ${world.systems.size}<br>
Spatial nodes ${world.spatialNodes.size}<br>
Bubbles ${world.localBubbles.size}<br>
Stations ${world.stations.size}<br>
Claims ${world.claims.size}<br>
Construction ${world.constructionSites.size}<br>
Ships ${world.ships.size}<br>
Recent events ${world.recentEvents.length}
`;
return;
}
if (selectedItems.length > 1) {
const group = getSelectionGroup(selectedItems[0]);
detailTitleEl.textContent = `${selectedItems.length} selected`;
detailBodyEl.innerHTML = `
Type ${group}<br>
${selectedItems.slice(0, 8).map((item) => describeSelectable(world, item)).join("<br>")}
`;
return;
}
const selected = selectedItems[0];
if (selected.kind === "ship") {
const ship = world.ships.get(selected.id);
if (!ship) {
return;
}
const parent = describeSelectionParent(selected);
const cargoUsed = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0);
detailTitleEl.textContent = ship.label;
detailBodyEl.innerHTML = `
<p>Parent ${parent}</p>
<p>State ${ship.state}</p>
<p>Energy ${ship.energyStored.toFixed(0)}<br>Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}</p>
<p>Inventory ${formatInventory(ship.inventory)}</p>
<p>Velocity ${formatVector(ship.localVelocity)}</p>
<p>Camera ${cameraMode === "follow" && cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}<br>Press C to toggle follow</p>
`;
return;
}
if (selected.kind === "station") {
const station = world.stations.get(selected.id);
if (!station) {
return;
}
const parent = describeSelectionParent(selected);
detailTitleEl.textContent = station.label;
detailBodyEl.innerHTML = `
<p>${station.category} · ${station.systemId}</p>
<p>Parent ${parent}</p>
<p>Energy ${station.energyStored.toFixed(0)}<br>Docked ${station.dockedShips} / ${station.dockingPads}</p>
<p>Inventory ${formatInventory(station.inventory)}</p>
<p>History available in the separate history window.</p>
`;
return;
}
if (selected.kind === "node") {
const node = world.nodes.get(selected.id);
if (!node) {
return;
}
const parent = describeSelectionParent(selected);
detailTitleEl.textContent = `Node ${node.id}`;
detailBodyEl.innerHTML = `
<p>${node.systemId}</p>
<p>Parent ${parent}</p>
<p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p>
<p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
`;
return;
}
if (selected.kind === "spatial-node") {
const node = world.spatialNodes.get(selected.id);
if (!node) {
return;
}
const bubble = world.localBubbles.get(node.bubbleId);
detailTitleEl.textContent = `${node.kind} node`;
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 ${bubble.radius.toFixed(0)}</p>
<p>Ships ${bubble.occupantShipIds.length}<br>Stations ${bubble.occupantStationIds.length}</p>
<p>Claims ${bubble.occupantClaimIds.length}<br>Construction sites ${bubble.occupantConstructionSiteIds.length}</p>
`;
return;
}
if (selected.kind === "claim") {
const claim = world.claims.get(selected.id);
if (!claim) {
return;
}
detailTitleEl.textContent = `Claim ${claim.id}`;
detailBodyEl.innerHTML = `
<p>${claim.systemId}</p>
<p>Node ${claim.nodeId}<br>Bubble ${claim.bubbleId}</p>
<p>State ${claim.state}<br>Health ${claim.health.toFixed(0)}</p>
<p>Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}</p>
`;
return;
}
if (selected.kind === "construction-site") {
const site = world.constructionSites.get(selected.id);
if (!site) {
return;
}
const orderCount = [...world.marketOrders.values()].filter((order) => order.constructionSiteId === site.id).length;
detailTitleEl.textContent = `Construction ${site.id}`;
detailBodyEl.innerHTML = `
<p>${site.systemId}</p>
<p>Node ${site.nodeId}<br>Bubble ${site.bubbleId}</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>
`;
return;
}
if (selected.kind === "planet") {
const system = world.systems.get(selected.systemId);
const planet = system?.planets[selected.planetIndex];
if (!system || !planet) {
return;
}
const parent = describeSelectionParent(selected);
detailTitleEl.textContent = planet.label;
detailBodyEl.innerHTML = `
<p>${system.label}</p>
<p>Parent ${parent}</p>
<p>${planet.planetType} · ${planet.shape} · Moons ${planet.moonCount}</p>
<p>Orbit ${planet.orbitRadius.toFixed(0)}<br>Speed ${planet.orbitSpeed.toFixed(3)}<br>Ecc ${planet.orbitEccentricity.toFixed(3)}<br>Inc ${planet.orbitInclination.toFixed(1)}°</p>
<p>Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°</p>
`;
return;
}
const system = world.systems.get(selected.id);
if (!system) {
return;
}
detailTitleEl.textContent = system.label;
detailBodyEl.innerHTML = `
<p>Parent galaxy</p>
${renderSystemDetails(world, system, false, cameraMode, cameraTargetShipId)}
`;
}
export function updateSystemPanel(params: SystemPanelParams) {
const {
world,
activeSystemId,
systemTitleEl,
systemBodyEl,
systemPanelEl,
cameraMode,
cameraTargetShipId,
} = params;
const activeSystem = activeSystemId ? world.systems.get(activeSystemId) : undefined;
systemPanelEl.hidden = !activeSystem;
if (!activeSystem) {
systemTitleEl.textContent = "Deep Space";
systemBodyEl.innerHTML = "";
return;
}
systemTitleEl.textContent = activeSystem.label;
systemBodyEl.innerHTML = renderSystemDetails(world, activeSystem, true, cameraMode, cameraTargetShipId);
}
export function describeSelectionParent(
world: WorldState | undefined,
selection: Selectable,
stationVisuals: Map<string, StructureVisual>,
nodeVisuals: Map<string, NodeVisual>,
) {
if (!world) {
return "unknown";
}
if (selection.kind === "system") {
return "galaxy";
}
if (selection.kind === "planet") {
const system = world.systems.get(selection.systemId);
return system ? `${system.label} star` : selection.systemId;
}
if (selection.kind === "ship") {
const ship = world.ships.get(selection.id);
if (!ship) {
return "unknown";
}
const system = world.systems.get(ship.systemId);
return system ? `${system.label} system` : ship.systemId;
}
if (selection.kind === "station") {
const station = world.stations.get(selection.id);
const visual = station ? stationVisuals.get(selection.id) : undefined;
return describeOrbitalParent(world, station?.systemId, visual?.anchor);
}
if (selection.kind === "node") {
const node = world.nodes.get(selection.id);
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 === "claim") {
return world.claims.get(selection.id)?.nodeId ?? "unknown";
}
if (selection.kind === "construction-site") {
return world.constructionSites.get(selection.id)?.nodeId ?? "unknown";
}
return "unknown";
}

View File

@@ -0,0 +1,126 @@
import * as THREE from "three";
import { ACTIVE_SYSTEM_DETAIL_SCALE, PROJECTED_GALAXY_RADIUS } from "./viewerConstants";
import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds } from "./viewerMath";
import type { PlanetVisual, ShipVisual, SystemSummaryVisual, SystemVisual, WorldState } from "./viewerTypes";
export function getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) {
const elapsedMs = now - visual.receivedAtMs;
const blendT = THREE.MathUtils.clamp(elapsedMs / visual.blendDurationMs, 0, 1);
return new THREE.Vector3().lerpVectors(visual.startPosition, visual.authoritativePosition, blendT);
}
export function resolveShipHeading(visual: ShipVisual, worldPosition: THREE.Vector3, orbitYaw: number) {
const desiredHeading = visual.targetPosition.clone().sub(worldPosition);
if (desiredHeading.lengthSq() > 0.01) {
return desiredHeading;
}
if (visual.velocity.lengthSq() > 0.01) {
return visual.velocity.clone();
}
return new THREE.Vector3(Math.cos(orbitYaw), 0, Math.sin(orbitYaw));
}
export function updatePlanetPresentation(
world: WorldState | undefined,
worldTimeSyncMs: number,
activeSystemId: string | undefined,
systemFocusLocal: THREE.Vector3,
planetVisuals: PlanetVisual[],
) {
const nowSeconds = currentWorldTimeSeconds(world, worldTimeSyncMs);
for (const visual of planetVisuals) {
const scale = visual.systemId === activeSystemId ? ACTIVE_SYSTEM_DETAIL_SCALE : 1;
const localPosition = 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);
visual.orbit.scale.setScalar(scale);
visual.orbit.position.copy(orbitOffset);
visual.mesh.position.copy(position);
visual.icon.position.copy(position);
if (visual.ring) {
visual.ring.position.copy(position);
}
for (const [moonIndex, moon] of visual.moons.entries()) {
moon.orbit.position.copy(position);
moon.orbit.scale.setScalar(scale);
moon.mesh.position.copy(position).add(
computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds, world?.seed ?? 1).multiplyScalar(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);
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,
setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void,
) {
const activeSystem = activeSystemId ? systemVisuals.get(activeSystemId) : undefined;
for (const [systemId, visual] of systemVisuals.entries()) {
visual.root.position.copy(visual.galaxyPosition);
visual.shellReticle.scale.setScalar(visual.shellReticleBaseScale);
if (!activeSystem) {
visual.starCluster.position.set(0, 0, 0);
visual.icon.position.set(0, 0, 0);
visual.icon.visible = true;
visual.shellReticle.position.set(0, 0, 0);
visual.shellReticle.visible = false;
setShellReticleOpacity(visual.shellReticle, 0);
continue;
}
if (systemId !== activeSystemId) {
visual.starCluster.position.set(0, 0, 0);
visual.icon.position.set(0, 0, 0);
visual.icon.visible = false;
visual.shellReticle.position.set(0, 0, 0);
visual.shellReticle.visible = true;
setShellReticleOpacity(visual.shellReticle, 1);
const direction = visual.galaxyPosition.clone().sub(activeSystem.galaxyPosition);
if (direction.lengthSq() > 0.0001) {
visual.root.position.copy(
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 reticleScale = Math.max(900, reticleDistance * 0.032);
visual.shellReticle.scale.setScalar(reticleScale);
continue;
}
const offset = systemFocusLocal.clone().multiplyScalar(-ACTIVE_SYSTEM_DETAIL_SCALE);
visual.starCluster.position.copy(offset);
visual.icon.position.copy(offset);
visual.icon.visible = true;
visual.shellReticle.visible = false;
setShellReticleOpacity(visual.shellReticle, 0);
}
}

View File

@@ -0,0 +1,182 @@
import * as THREE from "three";
import { computeZoomBlend } from "./viewerMath";
import {
updateNetworkPanel as renderNetworkPanel,
recordPerformanceStats,
updatePerformancePanel as renderPerformancePanel,
} from "./viewerTelemetry";
import { updatePlanetPresentation } from "./viewerPresentation";
import { renderRecentEvents, updateGameStatus, updateSystemSummaries, updateWorldPresentation } from "./viewerWorldPresentation";
import { updateSystemPanel } from "./viewerPanels";
import { createBackdropStars, createNebulaClouds, createNebulaTexture } from "./viewerSceneFactory";
export interface ViewerPresentationContext {
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
ambienceGroup: THREE.Group;
statusEl: HTMLDivElement;
networkPanelEl: HTMLDivElement;
performancePanelEl: HTMLDivElement;
systemPanelEl: HTMLDivElement;
systemTitleEl: HTMLHeadingElement;
systemBodyEl: HTMLDivElement;
networkStats: any;
performanceStats: any;
getWorld: () => any;
getActiveSystemId: () => string | undefined;
getCameraMode: () => any;
getCameraTargetShipId: () => string | undefined;
getZoomLevel: () => any;
getWorldTimeSyncMs: () => number;
getCurrentDistance: () => number;
systemFocusLocal: THREE.Vector3;
planetVisuals: any[];
systemSummaryVisuals: Map<any, any>;
presentationEntries: any[];
orbitLines: THREE.Object3D[];
systemVisuals: Map<any, any>;
createWorldPresentationContext: () => any;
}
export class ViewerPresentationController {
constructor(private readonly context: ViewerPresentationContext) {}
initializeAmbience() {
this.context.ambienceGroup.renderOrder = -10;
this.context.ambienceGroup.add(createBackdropStars());
this.context.ambienceGroup.add(...createNebulaClouds(createNebulaTexture(document)));
}
updateAmbience(delta: number) {
this.context.ambienceGroup.position.copy(this.context.camera.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 blend = computeZoomBlend(this.context.getCurrentDistance());
for (const entry of this.context.presentationEntries) {
const systemId = entry.systemId;
const isActiveDetail = !systemId || systemId === activeSystemId;
const isProjectedSystemIcon = !!activeSystemId
&& !!systemId
&& systemId !== activeSystemId
&& this.context.systemVisuals.get(systemId)?.icon === entry.icon;
const detailAlpha = entry.hideDetailInUniverse
? Math.max(blend.localWeight, blend.systemWeight) * (isActiveDetail ? 1 : 0)
: 1;
const iconAlpha = isProjectedSystemIcon
? 0
: entry.hideIconInUniverse
? blend.systemWeight * (isActiveDetail ? 1 : 0)
: Math.max(blend.systemWeight, blend.universeWeight);
this.setObjectOpacity(entry.detail, detailAlpha);
this.setObjectOpacity(entry.icon, iconAlpha);
}
for (const orbitLine of this.context.orbitLines) {
const alpha = Math.max(blend.localWeight * 0.55, blend.systemWeight) * (activeSystemId ? 1 : 0);
this.setObjectOpacity(orbitLine, alpha);
}
for (const [systemId, summaryVisual] of this.context.systemSummaryVisuals.entries()) {
const summaryOpacity = systemId === activeSystemId
? 0
: (activeSystemId ? 0.72 : 0.96);
this.setObjectOpacity(summaryVisual.sprite, summaryOpacity);
}
this.context.scene.fog = new THREE.FogExp2(0x040912, 0.000035);
}
updateNetworkPanel() {
renderNetworkPanel(this.context.networkPanelEl, this.context.networkStats);
}
recordPerformanceStats(frameMs: number) {
recordPerformanceStats(this.context.performanceStats, frameMs);
}
updatePerformancePanel() {
renderPerformancePanel(this.context.performancePanelEl, this.context.performanceStats, this.context.renderer);
}
updateShipPresentation() {
updateWorldPresentation(this.context.createWorldPresentationContext());
}
updatePlanetPresentation() {
const world = this.context.getWorld();
updatePlanetPresentation(
world,
this.context.getWorldTimeSyncMs(),
this.context.getActiveSystemId(),
this.context.systemFocusLocal,
this.context.planetVisuals,
);
}
updateSystemSummaries() {
updateSystemSummaries(this.context.getWorld(), this.context.systemSummaryVisuals);
}
renderRecentEvents(entityKind: string, entityId: string) {
return renderRecentEvents(this.context.getWorld(), entityKind, entityId);
}
updateGamePanel(mode: string) {
updateGameStatus({
statusEl: this.context.statusEl,
world: this.context.getWorld(),
activeSystemId: this.context.getActiveSystemId(),
cameraMode: this.context.getCameraMode(),
zoomLevel: this.context.getZoomLevel(),
mode,
});
}
updateSystemPanel() {
const world = this.context.getWorld();
if (!world) {
return;
}
updateSystemPanel({
world,
activeSystemId: this.context.getActiveSystemId(),
systemTitleEl: this.context.systemTitleEl,
systemBodyEl: this.context.systemBodyEl,
systemPanelEl: this.context.systemPanelEl,
cameraMode: this.context.getCameraMode(),
cameraTargetShipId: this.context.getCameraTargetShipId(),
});
}
screenPointFromClient(clientX: number, clientY: number) {
const bounds = this.context.renderer.domElement.getBoundingClientRect();
return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top);
}
private setObjectOpacity(object: THREE.Object3D, opacity: number) {
const visible = opacity > 0.02;
object.visible = visible;
object.traverse((child) => {
if (!("material" in child)) {
return;
}
const materials = Array.isArray(child.material) ? child.material : [child.material];
for (const material of materials) {
if (!("opacity" in material)) {
continue;
}
material.transparent = true;
material.opacity = opacity;
material.needsUpdate = true;
}
});
}
}

View File

@@ -0,0 +1,59 @@
import * as THREE from "three";
import { classifyZoomLevel } from "./viewerMath";
import type { PerformanceStats } from "./viewerTypes";
export interface RenderFrameParams {
clock: THREE.Clock;
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
updateCamera: (delta: number) => void;
updateAmbience: (delta: number) => void;
updatePlanetPresentation: () => void;
updateShipPresentation: () => void;
updateNetworkPanel: () => void;
applyZoomPresentation: () => void;
recordPerformanceStats: (frameMs: number) => void;
updatePerformancePanel: () => void;
}
export interface ResizeParams {
renderer: THREE.WebGLRenderer;
camera: THREE.PerspectiveCamera;
}
export interface CameraStepParams {
currentDistance: number;
desiredDistance: number;
orbitPitch: number;
delta: number;
}
export function renderFrame(params: RenderFrameParams) {
const frameStartedAtMs = performance.now();
const delta = Math.min(params.clock.getDelta(), 0.033);
params.updateCamera(delta);
params.updateAmbience(delta);
params.updatePlanetPresentation();
params.updateShipPresentation();
params.updateNetworkPanel();
params.applyZoomPresentation();
params.renderer.render(params.scene, params.camera);
params.recordPerformanceStats(performance.now() - frameStartedAtMs);
params.updatePerformancePanel();
}
export function resizeViewer(params: ResizeParams) {
const width = window.innerWidth;
const height = window.innerHeight;
params.camera.aspect = width / height;
params.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 orbitPitch = THREE.MathUtils.clamp(params.orbitPitch, 0.18, 1.3);
return { currentDistance, zoomLevel, orbitPitch };
}

View File

@@ -0,0 +1,67 @@
import * as THREE from "three";
import type { ShipSnapshot } from "./contracts";
export function shipSize(ship: ShipSnapshot) {
switch (ship.shipClass) {
case "capital":
return 18;
case "cruiser":
return 13;
case "destroyer":
return 10;
case "industrial":
return 11;
default:
return 8;
}
}
export function shipLength(ship: ShipSnapshot) {
return shipSize(ship) * 2.6;
}
export function shipColor(role: ShipSnapshot["role"]) {
if (role === "mining") {
return "#ffcf6e";
}
if (role === "transport") {
return "#9ff0aa";
}
return "#8bc0ff";
}
export function shipPresentationColor(ship: ShipSnapshot) {
if (ship.spatialState.spaceLayer !== "local-space") {
return "#c77dff";
}
if (ship.spatialState.movementRegime === "warp") {
return "#ffd166";
}
if (ship.spatialState.movementRegime === "ftl-transit") {
return "#ff6ad5";
}
return shipColor(ship.role);
}
export function spatialNodeColor(kind: string) {
if (kind.includes("lagrange")) {
return "#7fe8ff";
}
if (kind.includes("station")) {
return "#ffc36e";
}
if (kind.includes("planet")) {
return "#8bc0ff";
}
if (kind.includes("moon")) {
return "#c7d7e8";
}
return "#ffe082";
}
export function createCirclePoints(radius: number, segments: number) {
return Array.from({ length: segments }, (_, index) => {
const theta = (index / segments) * Math.PI * 2;
return new THREE.Vector3(Math.cos(theta) * radius, 0, Math.sin(theta) * radius);
});
}

View File

@@ -0,0 +1,222 @@
import * as THREE from "three";
import {
applyClaimDeltas as applyClaimDeltaUpdates,
applyConstructionSiteDeltas as applyConstructionSiteDeltaUpdates,
applyLocalBubbleDeltas as applyLocalBubbleDeltaUpdates,
applyNodeDeltas as applyNodeDeltaUpdates,
applyShipDeltas as applyShipDeltaUpdates,
applySpatialNodeDeltas as applySpatialNodeDeltaUpdates,
applyStationDeltas as applyStationDeltaUpdates,
rebuildSystems as rebuildSystemScene,
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,
} from "./viewerSceneAppearance";
import type {
ClaimDelta,
ClaimSnapshot,
ConstructionSiteDelta,
ConstructionSiteSnapshot,
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
ShipDelta,
ShipSnapshot,
SpatialNodeDelta,
SpatialNodeSnapshot,
StationDelta,
StationSnapshot,
SystemSnapshot,
} from "./contracts";
import type {
OrbitalAnchor,
} from "./viewerTypes";
export interface ViewerSceneDataContext {
documentRef: Document;
getWorldGeneratedAtUtc: () => string | undefined;
getWorldSeed: () => number;
getWorldTimeSyncMs: () => number;
getWorldPresentationContext: () => any;
systemGroup: THREE.Group;
spatialNodeGroup: THREE.Group;
bubbleGroup: THREE.Group;
nodeGroup: THREE.Group;
stationGroup: THREE.Group;
claimGroup: THREE.Group;
constructionSiteGroup: THREE.Group;
shipGroup: THREE.Group;
selectableTargets: Map<any, any>;
presentationEntries: any[];
systemVisuals: Map<any, any>;
systemSummaryVisuals: Map<any, any>;
planetVisuals: any[];
orbitLines: THREE.Object3D[];
spatialNodeVisuals: Map<any, any>;
bubbleVisuals: 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: THREE.Object3D, icon: THREE.Sprite, hideDetailInUniverse: boolean, hideIconInUniverse?: boolean, systemId?: string) => void;
}
export class ViewerSceneDataController {
constructor(private readonly context: ViewerSceneDataContext) {}
rebuildSystems(systems: SystemSnapshot[]) {
rebuildSystemScene(this.createSceneSyncContext(), systems);
}
syncSpatialNodes(nodes: SpatialNodeSnapshot[]) {
syncSpatialNodeScene(this.createSceneSyncContext(), nodes);
}
syncLocalBubbles(bubbles: LocalBubbleSnapshot[]) {
syncBubbleScene(this.createSceneSyncContext(), bubbles);
}
syncNodes(nodes: ResourceNodeSnapshot[]) {
syncNodeScene(this.createSceneSyncContext(), nodes);
}
syncStations(stations: StationSnapshot[]) {
syncStationScene(this.createSceneSyncContext(), stations);
}
syncClaims(claims: ClaimSnapshot[]) {
syncClaimScene(this.createSceneSyncContext(), claims);
}
syncConstructionSites(sites: ConstructionSiteSnapshot[]) {
syncConstructionSiteScene(this.createSceneSyncContext(), sites);
}
syncShips(ships: ShipSnapshot[], tickIntervalMs: number) {
syncShipScene(this.createSceneSyncContext(), ships, tickIntervalMs);
}
applySpatialNodeDeltas(nodes: SpatialNodeDelta[]) {
applySpatialNodeDeltaUpdates(this.createSceneSyncContext(), nodes);
}
applyLocalBubbleDeltas(bubbles: LocalBubbleDelta[]) {
applyLocalBubbleDeltaUpdates(this.createSceneSyncContext(), bubbles);
}
applyNodeDeltas(nodes: ResourceNodeDelta[]) {
applyNodeDeltaUpdates(this.createSceneSyncContext(), nodes);
}
applyStationDeltas(stations: StationDelta[]) {
applyStationDeltaUpdates(this.createSceneSyncContext(), stations);
}
applyClaimDeltas(claims: ClaimDelta[]) {
applyClaimDeltaUpdates(this.createSceneSyncContext(), claims);
}
applyConstructionSiteDeltas(sites: ConstructionSiteDelta[]) {
applyConstructionSiteDeltaUpdates(this.createSceneSyncContext(), sites);
}
applyShipDeltas(ships: ShipDelta[], tickIntervalMs: number) {
applyShipDeltaUpdates(this.createSceneSyncContext(), ships, tickIntervalMs);
}
createWorldPresentationContext(overrides: {
world: any;
activeSystemId?: string;
orbitYaw: number;
camera: THREE.PerspectiveCamera;
systemFocusLocal: THREE.Vector3;
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
updateSystemDetailVisibility: () => void;
setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void;
}) {
return {
world: overrides.world,
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
worldSeed: this.context.getWorldSeed(),
activeSystemId: overrides.activeSystemId,
orbitYaw: overrides.orbitYaw,
camera: overrides.camera,
systemFocusLocal: overrides.systemFocusLocal,
shipVisuals: this.context.shipVisuals,
nodeVisuals: this.context.nodeVisuals,
spatialNodeVisuals: this.context.spatialNodeVisuals,
bubbleVisuals: this.context.bubbleVisuals,
stationVisuals: this.context.stationVisuals,
claimVisuals: this.context.claimVisuals,
constructionSiteVisuals: this.context.constructionSiteVisuals,
systemVisuals: this.context.systemVisuals,
systemSummaryVisuals: this.context.systemSummaryVisuals,
toDisplayLocalPosition: overrides.toDisplayLocalPosition,
updateSystemDetailVisibility: overrides.updateSystemDetailVisibility,
setShellReticleOpacity: overrides.setShellReticleOpacity,
};
}
private createSceneSyncContext() {
return {
documentRef: this.context.documentRef,
worldGeneratedAtUtc: this.context.getWorldGeneratedAtUtc(),
worldSeed: this.context.getWorldSeed(),
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
systemGroup: this.context.systemGroup,
spatialNodeGroup: this.context.spatialNodeGroup,
bubbleGroup: this.context.bubbleGroup,
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,
systemVisuals: this.context.systemVisuals,
systemSummaryVisuals: this.context.systemSummaryVisuals,
planetVisuals: this.context.planetVisuals,
orbitLines: this.context.orbitLines,
spatialNodeVisuals: this.context.spatialNodeVisuals,
bubbleVisuals: this.context.bubbleVisuals,
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,
createCirclePoints,
resolveBubblePosition: (bubble: LocalBubbleSnapshot | LocalBubbleDelta) => resolveBubblePosition(this.context.getWorldPresentationContext(), bubble),
resolvePointPosition: (systemId: string, nodeId?: string | null) => resolvePointPosition(this.context.getWorldPresentationContext(), systemId, nodeId),
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

@@ -0,0 +1,420 @@
import * as THREE from "three";
import {
MOON_RENDER_SCALE,
PLANET_RENDER_SCALE,
STAR_RENDER_SCALE,
} from "./viewerConstants";
import type {
ClaimSnapshot,
ConstructionSiteSnapshot,
LocalBubbleSnapshot,
PlanetSnapshot,
ResourceNodeSnapshot,
ShipSnapshot,
SpatialNodeSnapshot,
StationSnapshot,
SystemSnapshot,
} from "./contracts";
import type { MoonVisual, SystemSummaryVisual } from "./viewerTypes";
import {
celestialRenderRadius,
computeMoonOrbitRadius,
computeMoonRenderRadius,
computePlanetLocalPosition,
starHaloOpacity,
toThreeVector,
} from "./viewerMath";
export function createNodeMesh(node: ResourceNodeSnapshot): THREE.Mesh {
const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas";
const mesh = new THREE.Mesh(
isGas ? new THREE.SphereGeometry(18, 14, 14) : new THREE.IcosahedronGeometry(12, 0),
new THREE.MeshStandardMaterial({
color: isGas ? 0x7fd6ff : 0xd2b07a,
flatShading: !isGas,
transparent: isGas,
opacity: isGas ? 0.68 : 1,
emissive: new THREE.Color(isGas ? 0x7fd6ff : 0xd2b07a).multiplyScalar(isGas ? 0.22 : 0.05),
}),
);
mesh.position.copy(toThreeVector(node.localPosition));
mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
return mesh;
}
export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColor: (kind: string) => string): THREE.Mesh {
const color = spatialNodeColor(node.kind);
return new THREE.Mesh(
new THREE.OctahedronGeometry(10, 0),
new THREE.MeshStandardMaterial({
color,
emissive: new THREE.Color(color).multiplyScalar(0.16),
roughness: 0.35,
metalness: 0.45,
}),
);
}
export function createBubbleRing(
bubble: LocalBubbleSnapshot,
localPosition: THREE.Vector3,
createCirclePoints: (radius: number, segments: number) => THREE.Vector3[],
): THREE.LineLoop {
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 ring;
}
export function createClaimMesh(claim: ClaimSnapshot): THREE.Mesh {
return new THREE.Mesh(
new THREE.ConeGeometry(9, 20, 4),
new THREE.MeshStandardMaterial({
color: claim.state === "active" ? 0xff7f50 : 0xff5b5b,
emissive: new THREE.Color(claim.state === "active" ? 0xff7f50 : 0xff5b5b).multiplyScalar(0.16),
roughness: 0.4,
metalness: 0.28,
}),
);
}
export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): THREE.Mesh {
return new THREE.Mesh(
new THREE.TorusKnotGeometry(7, 2.2, 54, 8),
new THREE.MeshStandardMaterial({
color: site.state === "completed" ? 0x46d37f : 0x9df29c,
emissive: new THREE.Color(site.state === "completed" ? 0x46d37f : 0x9df29c).multiplyScalar(0.15),
roughness: 0.34,
metalness: 0.48,
}),
);
}
export function createStarCluster(system: SystemSnapshot): THREE.Group {
const root = new THREE.Group();
const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02);
const offsets = system.starCount > 1
? [new THREE.Vector3(-renderedStarSize * 0.55, 0, 0), new THREE.Vector3(renderedStarSize * 0.75, renderedStarSize * 0.08, 0)]
: [new THREE.Vector3(0, 0, 0)];
for (const [index, offset] of offsets.entries()) {
const sizeScale = index === 0 ? 1 : 0.72;
const star = new THREE.Mesh(
new THREE.SphereGeometry(renderedStarSize * sizeScale, 24, 24),
new THREE.MeshBasicMaterial({ color: system.starColor }),
);
const halo = new THREE.Mesh(
new THREE.SphereGeometry(renderedStarSize * sizeScale * 1.45, 20, 20),
new THREE.MeshBasicMaterial({
color: system.starColor,
transparent: true,
opacity: starHaloOpacity(system.starKind),
side: THREE.BackSide,
}),
);
star.position.copy(offset);
halo.position.copy(offset);
root.add(star, halo);
}
return root;
}
export function createPlanetOrbit(planet: PlanetSnapshot): THREE.LineLoop {
const points = Array.from({ length: 120 }, (_, index) => {
const phaseDegrees = (index / 120) * 360;
return computePlanetLocalPosition(planet, 0, phaseDegrees);
});
return new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.22 }),
);
}
export function createPlanetRing(planet: PlanetSnapshot): THREE.Mesh {
const renderedPlanetRadius = celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06);
const ring = new THREE.Mesh(
new THREE.RingGeometry(renderedPlanetRadius * 1.35, renderedPlanetRadius * 2.15, 48),
new THREE.MeshBasicMaterial({
color: 0xdac89a,
transparent: true,
opacity: 0.42,
side: THREE.DoubleSide,
}),
);
ring.rotation.x = Math.PI / 2;
ring.rotation.z = THREE.MathUtils.degToRad(planet.orbitInclination * 0.25);
return ring;
}
export function createMoonVisuals(planet: PlanetSnapshot, seed: number): MoonVisual[] {
const moonCount = Math.min(planet.moonCount, 12);
const moons: MoonVisual[] = [];
for (let moonIndex = 0; moonIndex < moonCount; moonIndex += 1) {
const orbitRadius = computeMoonOrbitRadius(planet, moonIndex, seed);
const orbit = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(
Array.from({ length: 48 }, (_, index) => {
const angle = (index / 48) * Math.PI * 2;
return new THREE.Vector3(
Math.cos(angle) * orbitRadius,
0,
Math.sin(angle) * orbitRadius,
);
}),
),
new THREE.LineBasicMaterial({ color: 0x3b5065, transparent: true, opacity: 0.1 }),
);
orbit.rotation.x = THREE.MathUtils.degToRad(planet.orbitInclination * 0.35);
const moonSize = computeMoonRenderRadius(planet, moonIndex, seed);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(moonSize, 12, 12),
new THREE.MeshStandardMaterial({
color: new THREE.Color(planet.color).lerp(new THREE.Color("#d9dee7"), 0.55),
roughness: 0.96,
metalness: 0.02,
}),
);
moons.push({ mesh, orbit });
}
return moons;
}
export function createStationMesh(station: StationSnapshot): THREE.Mesh {
const mesh = new THREE.Mesh(
new THREE.CylinderGeometry(24, 24, 18, 10),
new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }),
);
mesh.rotation.x = Math.PI / 2;
mesh.position.copy(toThreeVector(station.localPosition));
return mesh;
}
export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): THREE.Mesh {
const geometry = new THREE.ConeGeometry(size, length, 7);
geometry.rotateX(Math.PI / 2);
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({
color,
emissive: new THREE.Color(color).multiplyScalar(0.18),
}),
);
mesh.position.copy(toThreeVector(ship.localPosition));
return mesh;
}
export function createBackdropStars(): THREE.Points {
const starCount = 1800;
const radius = 36000;
const positions = new Float32Array(starCount * 3);
const colors = new Float32Array(starCount * 3);
const color = new THREE.Color();
for (let index = 0; index < starCount; index += 1) {
const direction = new THREE.Vector3(
THREE.MathUtils.randFloatSpread(2),
THREE.MathUtils.randFloatSpread(2),
THREE.MathUtils.randFloatSpread(2),
).normalize().multiplyScalar(radius * THREE.MathUtils.randFloat(0.82, 1));
positions[index * 3] = direction.x;
positions[index * 3 + 1] = direction.y;
positions[index * 3 + 2] = direction.z;
const tint = THREE.MathUtils.randFloat(0, 1);
color.setRGB(
THREE.MathUtils.lerp(0.68, 1, tint),
THREE.MathUtils.lerp(0.76, 0.94, tint),
THREE.MathUtils.lerp(0.9, 1, tint),
);
if (Math.random() < 0.08) {
color.lerp(new THREE.Color(0xffd6a0), 0.45);
}
colors[index * 3] = color.r;
colors[index * 3 + 1] = color.g;
colors[index * 3 + 2] = color.b;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
return new THREE.Points(
geometry,
new THREE.PointsMaterial({
size: 2.2,
sizeAttenuation: false,
vertexColors: true,
transparent: true,
opacity: 0.9,
depthWrite: false,
blending: THREE.AdditiveBlending,
}),
);
}
export function createNebulaTexture(documentRef: Document): THREE.CanvasTexture {
const canvas = documentRef.createElement("canvas");
canvas.width = 256;
canvas.height = 256;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Unable to create nebula texture");
}
const gradient = context.createRadialGradient(128, 128, 18, 128, 128, 118);
gradient.addColorStop(0, "rgba(255,255,255,0.95)");
gradient.addColorStop(0.2, "rgba(255,255,255,0.48)");
gradient.addColorStop(0.55, "rgba(140,180,255,0.14)");
gradient.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = gradient;
context.fillRect(0, 0, 256, 256);
for (let index = 0; index < 10; index += 1) {
const x = THREE.MathUtils.randFloat(30, 226);
const y = THREE.MathUtils.randFloat(30, 226);
const radius = THREE.MathUtils.randFloat(24, 72);
const puff = context.createRadialGradient(x, y, 0, x, y, radius);
puff.addColorStop(0, "rgba(255,255,255,0.16)");
puff.addColorStop(0.45, "rgba(255,255,255,0.08)");
puff.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = puff;
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
}
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}
export function createNebulaClouds(texture: THREE.Texture): THREE.Sprite[] {
const directions = [
new THREE.Vector3(0.74, 0.34, -0.58),
new THREE.Vector3(-0.62, 0.18, -0.77),
new THREE.Vector3(0.22, -0.44, -0.87),
new THREE.Vector3(-0.38, 0.56, 0.73),
];
return directions.map((direction, index) => {
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: 0.14,
depthWrite: false,
color: ["#6dc7ff", "#ff9ec8", "#8e7dff", "#7ce0c3"][index] ?? "#6dc7ff",
blending: THREE.AdditiveBlending,
}));
sprite.position.copy(direction.normalize().multiplyScalar(25000 + index * 2600));
const scale = 15000 + index * 2400;
sprite.scale.set(scale, scale * 0.62, 1);
return sprite;
});
}
export function createTacticalIcon(documentRef: Document, color: string, size: number): THREE.Sprite {
const canvas = documentRef.createElement("canvas");
canvas.width = 64;
canvas.height = 64;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Unable to create tactical icon");
}
context.clearRect(0, 0, 64, 64);
context.strokeStyle = color;
context.lineWidth = 5;
context.beginPath();
context.arc(32, 32, 18, 0, Math.PI * 2);
context.stroke();
context.beginPath();
context.moveTo(32, 8);
context.lineTo(32, 56);
context.moveTo(8, 32);
context.lineTo(56, 32);
context.stroke();
const texture = new THREE.CanvasTexture(canvas);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthWrite: false,
depthTest: false,
color: "#ffffff",
}));
sprite.scale.setScalar(size);
sprite.visible = false;
return sprite;
}
export function createSystemSummaryVisual(documentRef: Document, anchor: THREE.Vector3): SystemSummaryVisual {
const canvas = documentRef.createElement("canvas");
canvas.width = 512;
canvas.height = 160;
const texture = new THREE.CanvasTexture(canvas);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthWrite: false,
depthTest: false,
}));
sprite.scale.set(520, 160, 1);
sprite.visible = false;
return { sprite, texture, anchor };
}
export function createShellReticle(documentRef: Document, color: string, size: number): THREE.Sprite {
const canvas = documentRef.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Unable to create shell reticle");
}
context.clearRect(0, 0, 128, 128);
context.strokeStyle = color;
context.lineWidth = 6;
context.globalAlpha = 0.58;
context.beginPath();
context.arc(64, 64, 48, 0.12 * Math.PI, 0.34 * Math.PI);
context.stroke();
context.beginPath();
context.arc(64, 64, 48, 0.62 * Math.PI, 0.84 * Math.PI);
context.stroke();
context.beginPath();
context.arc(64, 64, 48, 1.12 * Math.PI, 1.34 * Math.PI);
context.stroke();
context.beginPath();
context.arc(64, 64, 48, 1.62 * Math.PI, 1.84 * Math.PI);
context.stroke();
const texture = new THREE.CanvasTexture(canvas);
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthWrite: false,
depthTest: false,
color,
opacity: 1,
blending: THREE.AdditiveBlending,
fog: false,
});
const sprite = new THREE.Sprite(material);
sprite.scale.setScalar(size);
sprite.visible = false;
sprite.renderOrder = 1000;
return sprite;
}

View File

@@ -0,0 +1,508 @@
import * as THREE from "three";
import {
PLANET_RENDER_SCALE,
STAR_RENDER_SCALE,
} from "./viewerConstants";
import type {
BubbleVisual,
ClaimVisual,
ConstructionSiteVisual,
NodeVisual,
PlanetVisual,
PresentationEntry,
Selectable,
ShipVisual,
SpatialNodeVisual,
StructureVisual,
SystemSummaryVisual,
SystemVisual,
} from "./viewerTypes";
import type {
ClaimDelta,
ClaimSnapshot,
ConstructionSiteDelta,
ConstructionSiteSnapshot,
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
ShipDelta,
ShipSnapshot,
SpatialNodeDelta,
SpatialNodeSnapshot,
StationDelta,
StationSnapshot,
SystemSnapshot,
} from "./contracts";
import {
celestialRenderRadius,
computePlanetLocalPosition,
toThreeVector,
} from "./viewerMath";
import {
createBubbleRing,
createClaimMesh,
createConstructionSiteMesh,
createMoonVisuals,
createNodeMesh,
createPlanetOrbit,
createPlanetRing,
createShellReticle,
createShipMesh,
createSpatialNodeMesh,
createStarCluster,
createStationMesh,
createSystemSummaryVisual,
createTacticalIcon,
} from "./viewerSceneFactory";
interface SceneSyncContext {
documentRef: Document;
worldGeneratedAtUtc?: string;
worldSeed: number;
worldTimeSyncMs: number;
systemGroup: THREE.Group;
spatialNodeGroup: THREE.Group;
bubbleGroup: THREE.Group;
nodeGroup: THREE.Group;
stationGroup: THREE.Group;
claimGroup: THREE.Group;
constructionSiteGroup: THREE.Group;
shipGroup: THREE.Group;
selectableTargets: Map<THREE.Object3D, Selectable>;
presentationEntries: PresentationEntry[];
systemVisuals: Map<string, SystemVisual>;
systemSummaryVisuals: Map<string, SystemSummaryVisual>;
planetVisuals: PlanetVisual[];
orbitLines: THREE.Object3D[];
spatialNodeVisuals: Map<string, SpatialNodeVisual>;
bubbleVisuals: Map<string, BubbleVisual>;
nodeVisuals: Map<string, NodeVisual>;
stationVisuals: Map<string, StructureVisual>;
claimVisuals: Map<string, ClaimVisual>;
constructionSiteVisuals: Map<string, ConstructionSiteVisual>;
shipVisuals: Map<string, ShipVisual>;
registerPresentation: (
detail: THREE.Object3D,
icon: THREE.Sprite,
hideDetailInUniverse: boolean,
hideIconInUniverse?: boolean,
systemId?: string,
) => void;
shipSize: (ship: ShipSnapshot) => number;
shipLength: (ship: ShipSnapshot) => number;
shipPresentationColor: (ship: ShipSnapshot) => string;
spatialNodeColor: (kind: string) => string;
createCirclePoints: (radius: number, segments: number) => THREE.Vector3[];
resolveBubblePosition: (bubble: LocalBubbleSnapshot | LocalBubbleDelta) => THREE.Vector3;
resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3;
resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => NodeVisual["anchor"];
deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: NodeVisual["anchor"]) => {
radius: number;
phase: number;
inclination: number;
};
deriveOrbitalFromLocalPosition: (localPosition: THREE.Vector3, systemId: string, anchor: StructureVisual["anchor"]) => {
radius: number;
phase: number;
inclination: number;
};
setBubbleVisualState: (visual: BubbleVisual, bubble: LocalBubbleSnapshot | LocalBubbleDelta) => void;
}
export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapshot[]) {
const worldTimeSeconds = context.worldGeneratedAtUtc
? ((Date.parse(context.worldGeneratedAtUtc) + (performance.now() - context.worldTimeSyncMs)) / 1000) + (context.worldSeed * 97)
: 0;
context.systemGroup.clear();
context.selectableTargets.clear();
context.presentationEntries.length = 0;
context.planetVisuals.length = 0;
context.orbitLines.length = 0;
context.systemVisuals.clear();
context.systemSummaryVisuals.clear();
for (const system of systems) {
const root = new THREE.Group();
root.position.set(system.galaxyPosition.x, system.galaxyPosition.y, system.galaxyPosition.z);
const detailGroup = new THREE.Group();
const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02);
const starCluster = createStarCluster(system);
const systemIcon = createTacticalIcon(context.documentRef, system.starColor, 96);
const shellReticle = createShellReticle(context.documentRef, "#ff3b30", 400);
const summaryVisual = createSystemSummaryVisual(
context.documentRef,
new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y + renderedStarSize + 140, system.galaxyPosition.z),
);
summaryVisual.sprite.position.set(0, renderedStarSize + 110, 0);
root.add(starCluster, systemIcon, shellReticle, summaryVisual.sprite, detailGroup);
context.registerPresentation(starCluster, systemIcon, true);
context.systemVisuals.set(system.id, {
root,
starCluster,
icon: systemIcon,
shellReticle,
shellReticleBaseScale: 400,
detailGroup,
summary: summaryVisual,
galaxyPosition: toThreeVector(system.galaxyPosition),
});
context.systemSummaryVisuals.set(system.id, summaryVisual);
starCluster.traverse((child) => {
if (child instanceof THREE.Mesh) {
context.selectableTargets.set(child, { kind: "system", id: system.id });
}
});
context.selectableTargets.set(systemIcon, { kind: "system", id: system.id });
context.selectableTargets.set(shellReticle, { kind: "system", id: system.id });
for (const [planetIndex, planet] of system.planets.entries()) {
const orbit = createPlanetOrbit(planet);
const renderedPlanetRadius = celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06);
const planetMesh = new THREE.Mesh(
new THREE.SphereGeometry(renderedPlanetRadius, 18, 18),
new THREE.MeshStandardMaterial({
color: planet.color,
roughness: 0.92,
metalness: 0.08,
emissive: new THREE.Color(planet.color).multiplyScalar(0.04),
}),
);
planetMesh.position.copy(computePlanetLocalPosition(planet, worldTimeSeconds));
const planetIcon = createTacticalIcon(context.documentRef, planet.color, Math.max(24, renderedPlanetRadius * 2));
planetIcon.position.copy(planetMesh.position);
const ring = planet.hasRing ? createPlanetRing(planet) : undefined;
if (ring) {
ring.position.copy(planetMesh.position);
}
const moons = createMoonVisuals(planet, context.worldSeed);
detailGroup.add(orbit, planetMesh, planetIcon);
if (ring) {
detailGroup.add(ring);
}
for (const moon of moons) {
moon.orbit.position.copy(planetMesh.position);
moon.mesh.position.copy(planetMesh.position);
detailGroup.add(moon.orbit, moon.mesh);
context.orbitLines.push(moon.orbit);
context.registerPresentation(moon.mesh, planetIcon, true, true, system.id);
}
context.orbitLines.push(orbit);
context.registerPresentation(planetMesh, planetIcon, true, true, system.id);
if (ring) {
context.registerPresentation(ring, planetIcon, true, true, system.id);
}
context.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, ring, moons });
context.selectableTargets.set(planetMesh, { kind: "planet", systemId: system.id, planetIndex });
context.selectableTargets.set(planetIcon, { kind: "planet", systemId: system.id, planetIndex });
}
context.systemGroup.add(root);
}
}
export function syncSpatialNodes(context: SceneSyncContext, nodes: SpatialNodeSnapshot[]) {
context.spatialNodeGroup.clear();
context.spatialNodeVisuals.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.position.copy(localPosition);
icon.position.copy(localPosition);
context.spatialNodeVisuals.set(node.id, {
id: node.id,
systemId: node.systemId,
mesh,
icon,
kind: node.kind,
localPosition,
});
context.spatialNodeGroup.add(mesh, icon);
context.registerPresentation(mesh, icon, true, true, node.systemId);
context.selectableTargets.set(mesh, { kind: "spatial-node", id: node.id });
context.selectableTargets.set(icon, { kind: "spatial-node", id: node.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(mesh);
context.selectableTargets.set(mesh, { kind: "bubble", id: bubble.id });
}
}
export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot[]) {
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.position.copy(mesh.position);
const localPosition = toThreeVector(node.localPosition);
const anchor = context.resolveOrbitalAnchor(node.systemId, localPosition);
const orbital = context.deriveNodeOrbital(node, anchor);
context.nodeVisuals.set(node.id, {
systemId: node.systemId,
mesh,
icon,
sourceKind: node.sourceKind,
anchor,
localPosition,
orbitRadius: orbital.radius,
orbitPhase: orbital.phase,
orbitInclination: orbital.inclination,
});
context.nodeGroup.add(mesh, icon);
context.registerPresentation(mesh, icon, true, true, node.systemId);
context.selectableTargets.set(mesh, { kind: "node", id: node.id });
context.selectableTargets.set(icon, { kind: "node", id: node.id });
}
}
export function syncStations(context: SceneSyncContext, stations: StationSnapshot[]) {
context.stationGroup.clear();
context.stationVisuals.clear();
for (const station of stations) {
const mesh = createStationMesh(station);
const icon = createTacticalIcon(context.documentRef, station.color, 26);
icon.position.copy(mesh.position);
const localPosition = toThreeVector(station.localPosition);
const anchor = context.resolveOrbitalAnchor(station.systemId, localPosition);
const orbital = context.deriveOrbitalFromLocalPosition(localPosition, station.systemId, anchor);
context.stationVisuals.set(station.id, {
id: station.id,
systemId: station.systemId,
mesh,
icon,
anchor,
orbitRadius: orbital.radius,
orbitPhase: orbital.phase,
orbitInclination: orbital.inclination,
localPosition,
});
context.stationGroup.add(mesh, icon);
context.registerPresentation(mesh, icon, true, true, station.systemId);
context.selectableTargets.set(mesh, { kind: "station", id: station.id });
context.selectableTargets.set(icon, { kind: "station", id: station.id });
}
}
export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[]) {
context.claimGroup.clear();
context.claimVisuals.clear();
for (const claim of claims) {
const localPosition = context.resolvePointPosition(claim.systemId, claim.nodeId);
const mesh = createClaimMesh(claim);
const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 18);
mesh.position.copy(localPosition);
icon.position.copy(localPosition);
context.claimVisuals.set(claim.id, {
id: claim.id,
nodeId: claim.nodeId,
systemId: claim.systemId,
mesh,
icon,
localPosition,
});
context.claimGroup.add(mesh, icon);
context.registerPresentation(mesh, icon, true, true, claim.systemId);
context.selectableTargets.set(mesh, { kind: "claim", id: claim.id });
context.selectableTargets.set(icon, { kind: "claim", id: claim.id });
}
}
export function syncConstructionSites(context: SceneSyncContext, sites: ConstructionSiteSnapshot[]) {
context.constructionSiteGroup.clear();
context.constructionSiteVisuals.clear();
for (const site of sites) {
const localPosition = context.resolvePointPosition(site.systemId, site.nodeId);
const mesh = createConstructionSiteMesh(site);
const icon = createTacticalIcon(context.documentRef, "#9df29c", 18);
mesh.position.copy(localPosition);
icon.position.copy(localPosition);
context.constructionSiteVisuals.set(site.id, {
id: site.id,
nodeId: site.nodeId,
systemId: site.systemId,
mesh,
icon,
localPosition,
});
context.constructionSiteGroup.add(mesh, icon);
context.registerPresentation(mesh, icon, true, true, site.systemId);
context.selectableTargets.set(mesh, { kind: "construction-site", id: site.id });
context.selectableTargets.set(icon, { kind: "construction-site", id: site.id });
}
}
export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tickIntervalMs: number) {
context.shipGroup.clear();
context.shipVisuals.clear();
for (const ship of ships) {
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.position.copy(position);
icon.material.color.set(shipColor);
context.shipGroup.add(mesh, icon);
context.selectableTargets.set(mesh, { kind: "ship", id: ship.id });
context.selectableTargets.set(icon, { kind: "ship", id: ship.id });
context.registerPresentation(mesh, icon, true, true, ship.systemId);
context.shipVisuals.set(ship.id, {
systemId: ship.systemId,
mesh,
icon,
startPosition: position.clone(),
authoritativePosition: position.clone(),
targetPosition: toThreeVector(ship.targetLocalPosition),
velocity: toThreeVector(ship.localVelocity),
receivedAtMs: performance.now(),
blendDurationMs: Math.max(tickIntervalMs, 80),
});
}
}
export function applySpatialNodeDeltas(context: SceneSyncContext, nodes: SpatialNodeDelta[]) {
for (const node of nodes) {
const visual = context.spatialNodeVisuals.get(node.id);
if (!visual) {
continue;
}
visual.systemId = node.systemId;
visual.kind = node.kind;
visual.localPosition.copy(toThreeVector(node.localPosition));
visual.mesh.position.copy(visual.localPosition);
visual.icon.position.copy(visual.localPosition);
(visual.mesh.material as THREE.MeshStandardMaterial).color.set(context.spatialNodeColor(node.kind));
}
}
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.position.copy(visual.localPosition);
visual.mesh.scale.setScalar(Math.max(bubble.radius, 60));
context.setBubbleVisualState(visual, bubble);
}
}
export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDelta[]) {
for (const node of nodes) {
const visual = context.nodeVisuals.get(node.id);
if (!visual) {
continue;
}
visual.systemId = node.systemId;
visual.sourceKind = node.sourceKind;
visual.localPosition.copy(toThreeVector(node.localPosition));
visual.anchor = context.resolveOrbitalAnchor(node.systemId, visual.localPosition);
const orbital = context.deriveNodeOrbital(node, visual.anchor);
visual.orbitRadius = orbital.radius;
visual.orbitPhase = orbital.phase;
visual.orbitInclination = orbital.inclination;
visual.mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
}
}
export function applyStationDeltas(context: SceneSyncContext, stations: StationDelta[]) {
for (const station of stations) {
const visual = context.stationVisuals.get(station.id);
if (!visual) {
continue;
}
visual.systemId = station.systemId;
visual.localPosition.copy(toThreeVector(station.localPosition));
visual.anchor = context.resolveOrbitalAnchor(station.systemId, visual.localPosition);
const orbital = context.deriveOrbitalFromLocalPosition(visual.localPosition, station.systemId, visual.anchor);
visual.orbitRadius = orbital.radius;
visual.orbitPhase = orbital.phase;
visual.orbitInclination = orbital.inclination;
const material = visual.mesh.material as THREE.MeshStandardMaterial;
material.color.set(station.color);
material.emissive = new THREE.Color(station.color).multiplyScalar(0.1);
}
}
export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[]) {
for (const claim of claims) {
const visual = context.claimVisuals.get(claim.id);
if (!visual) {
continue;
}
visual.systemId = claim.systemId;
visual.localPosition.copy(context.resolvePointPosition(claim.systemId, claim.nodeId));
visual.mesh.position.copy(visual.localPosition);
visual.icon.position.copy(visual.localPosition);
const material = visual.mesh.material as THREE.MeshStandardMaterial;
material.color.set(claim.state === "active" ? "#ff7f50" : "#ff5b5b");
material.emissive.set(claim.state === "active" ? "#ffb27d" : "#7a2020");
}
}
export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: ConstructionSiteDelta[]) {
for (const site of sites) {
const visual = context.constructionSiteVisuals.get(site.id);
if (!visual) {
continue;
}
visual.systemId = site.systemId;
visual.localPosition.copy(context.resolvePointPosition(site.systemId, site.nodeId));
visual.mesh.position.copy(visual.localPosition);
visual.icon.position.copy(visual.localPosition);
const material = visual.mesh.material as THREE.MeshStandardMaterial;
material.color.set(site.state === "completed" ? "#46d37f" : "#9df29c");
visual.mesh.scale.setScalar(0.75 + site.progress * 0.35);
}
}
export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], tickIntervalMs: number) {
for (const ship of ships) {
const visual = context.shipVisuals.get(ship.id);
if (!visual) {
continue;
}
visual.systemId = ship.systemId;
visual.startPosition.copy(visual.authoritativePosition);
visual.authoritativePosition.copy(toThreeVector(ship.localPosition));
visual.targetPosition.copy(toThreeVector(ship.targetLocalPosition));
visual.velocity.copy(toThreeVector(ship.localVelocity));
visual.receivedAtMs = performance.now();
visual.blendDurationMs = Math.max(tickIntervalMs * 1.35, 100);
const shipColor = context.shipPresentationColor(ship);
const material = visual.mesh.material as THREE.MeshStandardMaterial;
material.color.set(shipColor);
material.emissive.set(new THREE.Color(shipColor).multiplyScalar(0.18));
visual.icon.material.color.set(shipColor);
}
}

View File

@@ -0,0 +1,216 @@
import type { SystemSnapshot } from "./contracts";
import type {
CameraMode,
OrbitalAnchor,
Selectable,
SelectionGroup,
WorldState,
} from "./viewerTypes";
export function describeSelectable(world: WorldState | undefined, item: Selectable): string {
if (!world) {
return item.kind;
}
if (item.kind === "ship") {
return world.ships.get(item.id)?.label ?? item.id;
}
if (item.kind === "station") {
return world.stations.get(item.id)?.label ?? item.id;
}
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 === "claim") {
return `claim ${item.id}`;
}
if (item.kind === "construction-site") {
return `construction ${item.id}`;
}
if (item.kind === "planet") {
return world.systems.get(item.systemId)?.planets[item.planetIndex]?.label ?? `${item.systemId}:${item.planetIndex}`;
}
return world.systems.get(item.id)?.label ?? item.id;
}
export function getSelectionGroup(item: Selectable): SelectionGroup {
if (item.kind === "ship") {
return "ships";
}
if (
item.kind === "station"
|| item.kind === "node"
|| item.kind === "spatial-node"
|| item.kind === "bubble"
|| item.kind === "claim"
|| item.kind === "construction-site"
) {
return "structures";
}
return "celestials";
}
export function resolveSelectableSystemId(world: WorldState | undefined, selection: Selectable): string | undefined {
if (!world) {
return undefined;
}
if (selection.kind === "ship") {
return world.ships.get(selection.id)?.systemId;
}
if (selection.kind === "station") {
return world.stations.get(selection.id)?.systemId;
}
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 === "claim") {
return world.claims.get(selection.id)?.systemId;
}
if (selection.kind === "construction-site") {
return world.constructionSites.get(selection.id)?.systemId;
}
if (selection.kind === "planet") {
return selection.systemId;
}
return selection.id;
}
export function resolveFocusedBubbleId(world: WorldState | undefined, selectedItems: Selectable[]): string | undefined {
if (!world || selectedItems.length !== 1) {
return undefined;
}
const selected = selectedItems[0];
if (selected.kind === "bubble") {
return selected.id;
}
if (selected.kind === "ship") {
return world.ships.get(selected.id)?.bubbleId ?? world.ships.get(selected.id)?.spatialState.currentBubbleId ?? 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;
}
if (selected.kind === "claim") {
return world.claims.get(selected.id)?.bubbleId ?? undefined;
}
if (selected.kind === "construction-site") {
return world.constructionSites.get(selected.id)?.bubbleId ?? undefined;
}
return undefined;
}
export function describeOrbitalParent(world: WorldState | undefined, systemId?: string, anchor?: OrbitalAnchor): string {
if (!world || !systemId) {
return "unknown";
}
const system = world.systems.get(systemId);
if (!system) {
return systemId;
}
if (!anchor || anchor.kind === "star") {
return `${system.label} star`;
}
const planet = system.planets[anchor.planetIndex];
if (!planet) {
return `${system.label} star`;
}
if (anchor.kind === "planet") {
return planet.label;
}
return `${planet.label} moon ${anchor.moonIndex + 1}`;
}
export function renderSystemDetails(
world: WorldState | undefined,
system: SystemSnapshot,
activeContext: boolean,
cameraMode: CameraMode,
cameraTargetShipId?: string,
): string {
if (!world) {
return "";
}
let shipCount = 0;
let stationCount = 0;
let nodeCount = 0;
let spatialNodeCount = 0;
let bubbleCount = 0;
let claimCount = 0;
let constructionCount = 0;
let moonCount = 0;
for (const ship of world.ships.values()) {
if (ship.systemId === system.id) {
shipCount += 1;
}
}
for (const station of world.stations.values()) {
if (station.systemId === system.id) {
stationCount += 1;
}
}
for (const node of world.nodes.values()) {
if (node.systemId === system.id) {
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 claim of world.claims.values()) {
if (claim.systemId === system.id) {
claimCount += 1;
}
}
for (const site of world.constructionSites.values()) {
if (site.systemId === system.id) {
constructionCount += 1;
}
}
for (const planet of system.planets) {
moonCount += planet.moonCount;
}
const followText = activeContext && cameraMode === "follow" && cameraTargetShipId
? `<p>Camera locked to ${world.ships.get(cameraTargetShipId)?.label ?? cameraTargetShipId}</p>`
: "";
return `
<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>Claims ${claimCount}<br>Construction sites ${constructionCount}</p>
<p>Height ${system.galaxyPosition.y.toFixed(0)}</p>
<p>${system.planets.slice(0, 8).map((planet) => `${planet.label} (${planet.planetType})`).join("<br>")}</p>
${followText}
`;
}

View File

@@ -0,0 +1,119 @@
import type {
FactionSnapshot,
WorldDelta,
WorldSnapshot,
} from "./contracts";
import type {
NetworkStats,
PerformanceStats,
WorldState,
} from "./viewerTypes";
export function createInitialNetworkStats(): NetworkStats {
return {
snapshotBytes: 0,
deltasReceived: 0,
deltaBytes: 0,
lastDeltaBytes: 0,
lastEntityChanges: 0,
eventsReceived: 0,
streamConnected: false,
throughputSamples: [],
};
}
export function createInitialPerformanceStats(): PerformanceStats {
return {
frameSamples: [],
lastFrameMs: 0,
lastPanelUpdateAtMs: 0,
};
}
export function createWorldState(snapshot: WorldSnapshot): WorldState {
return {
label: snapshot.label,
seed: snapshot.seed,
sequence: snapshot.sequence,
tickIntervalMs: snapshot.tickIntervalMs,
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])),
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])),
constructionSites: new Map(snapshot.constructionSites.map((site) => [site.id, site])),
marketOrders: new Map(snapshot.marketOrders.map((order) => [order.id, order])),
policies: new Map(snapshot.policies.map((policy) => [policy.id, policy])),
ships: new Map(snapshot.ships.map((ship) => [ship.id, ship])),
factions: new Map(snapshot.factions.map((faction) => [faction.id, faction])),
recentEvents: [],
};
}
export function applyDeltaToWorld(world: WorldState, delta: WorldDelta): boolean {
world.sequence = delta.sequence;
world.tickIntervalMs = delta.tickIntervalMs;
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 node of delta.nodes) {
world.nodes.set(node.id, node);
}
for (const station of delta.stations) {
world.stations.set(station.id, station);
}
for (const claim of delta.claims) {
world.claims.set(claim.id, claim);
}
for (const site of delta.constructionSites) {
world.constructionSites.set(site.id, site);
}
for (const order of delta.marketOrders) {
world.marketOrders.set(order.id, order);
}
for (const policy of delta.policies) {
world.policies.set(policy.id, policy);
}
for (const ship of delta.ships) {
world.ships.set(ship.id, ship);
}
for (const faction of delta.factions) {
world.factions.set(faction.id, faction);
}
return delta.factions.length > 0;
}
export function recordDeltaStats(networkStats: NetworkStats, delta: WorldDelta, rawBytes: number): void {
const changedEntities = delta.ships.length
+ delta.stations.length
+ delta.nodes.length
+ delta.spatialNodes.length
+ delta.localBubbles.length
+ delta.claims.length
+ delta.constructionSites.length
+ delta.marketOrders.length
+ delta.policies.length
+ delta.factions.length;
networkStats.deltasReceived += 1;
networkStats.deltaBytes += rawBytes;
networkStats.lastDeltaBytes = rawBytes;
networkStats.lastEntityChanges = changedEntities;
networkStats.eventsReceived += delta.events.length;
networkStats.lastDeltaAtMs = performance.now();
networkStats.throughputSamples.push({ atMs: performance.now(), bytes: rawBytes });
const cutoff = performance.now() - 4000;
networkStats.throughputSamples = networkStats.throughputSamples.filter((sample) => sample.atMs >= cutoff);
}
export function cloneFactions(world: WorldState): FactionSnapshot[] {
return [...world.factions.values()];
}

View File

@@ -0,0 +1,91 @@
import * as THREE from "three";
import { formatBytes } from "./viewerMath";
import type {
NetworkStats,
PerformanceStats,
} from "./viewerTypes";
export function updateNetworkPanel(networkPanelEl: HTMLDivElement, networkStats: NetworkStats) {
const now = performance.now();
const uptimeSeconds = networkStats.streamOpenedAtMs
? (now - networkStats.streamOpenedAtMs) / 1000
: 0;
const recentBytes = networkStats.throughputSamples.reduce((sum, sample) => sum + sample.bytes, 0);
const recentWindowSeconds = networkStats.throughputSamples.length > 1
? Math.max((now - networkStats.throughputSamples[0].atMs) / 1000, 1)
: 1;
const kbPerSecond = recentBytes / 1024 / recentWindowSeconds;
const averageDeltaBytes = networkStats.deltasReceived > 0
? networkStats.deltaBytes / networkStats.deltasReceived
: 0;
const secondsSinceLastDelta = networkStats.lastDeltaAtMs
? ((now - networkStats.lastDeltaAtMs) / 1000).toFixed(1)
: "n/a";
networkPanelEl.textContent = [
`snapshot: ${formatBytes(networkStats.snapshotBytes)}`,
`stream: ${networkStats.streamConnected ? "live" : "offline"}`,
`deltas: ${networkStats.deltasReceived}`,
`events: ${networkStats.eventsReceived}`,
`avg delta: ${formatBytes(averageDeltaBytes)}`,
`last delta: ${formatBytes(networkStats.lastDeltaBytes)}`,
`recent rate: ${kbPerSecond.toFixed(1)} KB/s`,
`changed: ${networkStats.lastEntityChanges}`,
`uptime: ${uptimeSeconds.toFixed(1)}s`,
`last packet: ${secondsSinceLastDelta}s`,
].join("\n");
}
export function recordPerformanceStats(performanceStats: PerformanceStats, frameMs: number) {
const now = performance.now();
performanceStats.lastFrameMs = frameMs;
performanceStats.frameSamples.push({ atMs: now, frameMs });
const cutoff = now - 4000;
performanceStats.frameSamples = performanceStats.frameSamples.filter((sample) => sample.atMs >= cutoff);
}
export function updatePerformancePanel(
performancePanelEl: HTMLDivElement,
performanceStats: PerformanceStats,
renderer: THREE.WebGLRenderer,
) {
const now = performance.now();
if (
performanceStats.lastPanelUpdateAtMs > 0 &&
now - performanceStats.lastPanelUpdateAtMs < 250
) {
return;
}
const samples = performanceStats.frameSamples;
const elapsedWindowSeconds = samples.length > 1
? Math.max((samples[samples.length - 1].atMs - samples[0].atMs) / 1000, 0.25)
: 1;
const averageFrameMs = samples.length > 0
? samples.reduce((sum, sample) => sum + sample.frameMs, 0) / samples.length
: 0;
const worstFrameMs = samples.length > 0
? samples.reduce((max, sample) => Math.max(max, sample.frameMs), 0)
: 0;
const fps = samples.length > 1
? (samples.length - 1) / elapsedWindowSeconds
: 0;
const recentLowFps = averageFrameMs > 0 ? 1000 / Math.max(worstFrameMs, averageFrameMs) : 0;
const renderInfo = renderer.info;
performancePanelEl.textContent = [
`fps: ${fps.toFixed(1)}`,
`frame avg: ${averageFrameMs.toFixed(2)} ms`,
`frame last: ${performanceStats.lastFrameMs.toFixed(2)} ms`,
`frame worst: ${worstFrameMs.toFixed(2)} ms`,
`recent low: ${recentLowFps.toFixed(1)}`,
`draw calls: ${renderInfo.render.calls}`,
`triangles: ${renderInfo.render.triangles}`,
`points: ${renderInfo.render.points}`,
`lines: ${renderInfo.render.lines}`,
`geometries: ${renderInfo.memory.geometries}`,
`textures: ${renderInfo.memory.textures}`,
`pixel ratio: ${renderer.getPixelRatio().toFixed(2)}`,
].join("\n");
performanceStats.lastPanelUpdateAtMs = now;
}

View File

@@ -0,0 +1,207 @@
import * as THREE from "three";
import type {
ClaimSnapshot,
ConstructionSiteSnapshot,
FactionSnapshot,
LocalBubbleSnapshot,
MarketOrderSnapshot,
PlanetSnapshot,
PolicySetSnapshot,
ResourceNodeSnapshot,
ShipSnapshot,
SimulationEventRecord,
SpatialNodeSnapshot,
StationSnapshot,
SystemSnapshot,
} from "./contracts";
export type ZoomLevel = "local" | "system" | "universe";
export type SelectionGroup = "ships" | "structures" | "celestials";
export type DragMode = "orbit" | "marquee";
export type CameraMode = "tactical" | "follow";
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: "claim"; id: string }
| { kind: "construction-site"; id: string }
| { kind: "system"; id: string }
| { kind: "planet"; systemId: string; planetIndex: number };
export interface ShipVisual {
systemId: string;
mesh: THREE.Mesh;
icon: THREE.Sprite;
startPosition: THREE.Vector3;
authoritativePosition: THREE.Vector3;
targetPosition: THREE.Vector3;
velocity: THREE.Vector3;
receivedAtMs: number;
blendDurationMs: number;
}
export interface PlanetVisual {
systemId: string;
planet: PlanetSnapshot;
orbit: THREE.LineLoop;
mesh: THREE.Mesh;
icon: THREE.Sprite;
ring?: THREE.Mesh;
moons: MoonVisual[];
}
export interface MoonVisual {
mesh: THREE.Mesh;
orbit: THREE.LineLoop;
}
export type OrbitalAnchor =
| { kind: "star" }
| { kind: "planet"; planetIndex: number }
| { kind: "moon"; planetIndex: number; moonIndex: number };
export interface NodeVisual {
systemId: string;
mesh: THREE.Mesh;
icon: THREE.Sprite;
sourceKind: string;
anchor: OrbitalAnchor;
localPosition: THREE.Vector3;
orbitRadius: number;
orbitPhase: number;
orbitInclination: number;
}
export interface SpatialNodeVisual {
id: string;
systemId: string;
mesh: THREE.Mesh;
icon: THREE.Sprite;
kind: string;
localPosition: THREE.Vector3;
}
export interface BubbleVisual {
id: string;
systemId: string;
mesh: THREE.LineLoop;
localPosition: THREE.Vector3;
radius: number;
}
export interface ClaimVisual {
id: string;
nodeId: string;
systemId: string;
mesh: THREE.Mesh;
icon: THREE.Sprite;
localPosition: THREE.Vector3;
}
export interface ConstructionSiteVisual {
id: string;
nodeId: string;
systemId: string;
mesh: THREE.Mesh;
icon: THREE.Sprite;
localPosition: THREE.Vector3;
}
export interface StructureVisual {
id: string;
systemId: string;
mesh: THREE.Mesh;
icon: THREE.Sprite;
anchor: OrbitalAnchor;
orbitRadius: number;
orbitPhase: number;
orbitInclination: number;
localPosition: THREE.Vector3;
}
export interface SystemVisual {
root: THREE.Group;
starCluster: THREE.Group;
icon: THREE.Sprite;
shellReticle: THREE.Sprite;
shellReticleBaseScale: number;
detailGroup: THREE.Group;
summary: SystemSummaryVisual;
galaxyPosition: THREE.Vector3;
}
export interface WorldState {
label: string;
seed: number;
sequence: number;
tickIntervalMs: number;
generatedAtUtc: string;
systems: Map<string, SystemSnapshot>;
spatialNodes: Map<string, SpatialNodeSnapshot>;
localBubbles: Map<string, LocalBubbleSnapshot>;
nodes: Map<string, ResourceNodeSnapshot>;
stations: Map<string, StationSnapshot>;
claims: Map<string, ClaimSnapshot>;
constructionSites: Map<string, ConstructionSiteSnapshot>;
marketOrders: Map<string, MarketOrderSnapshot>;
policies: Map<string, PolicySetSnapshot>;
ships: Map<string, ShipSnapshot>;
factions: Map<string, FactionSnapshot>;
recentEvents: SimulationEventRecord[];
}
export interface NetworkSample {
atMs: number;
bytes: number;
}
export interface NetworkStats {
snapshotBytes: number;
deltasReceived: number;
deltaBytes: number;
lastDeltaBytes: number;
lastEntityChanges: number;
eventsReceived: number;
streamConnected: boolean;
streamOpenedAtMs?: number;
lastDeltaAtMs?: number;
throughputSamples: NetworkSample[];
}
export interface PerformanceSample {
atMs: number;
frameMs: number;
}
export interface PerformanceStats {
frameSamples: PerformanceSample[];
lastFrameMs: number;
lastPanelUpdateAtMs: number;
}
export interface PresentationEntry {
detail: THREE.Object3D;
icon: THREE.Sprite;
systemId?: string;
hideDetailInUniverse?: boolean;
hideIconInUniverse?: boolean;
}
export interface SystemSummaryVisual {
sprite: THREE.Sprite;
texture: THREE.CanvasTexture;
anchor: THREE.Vector3;
}
export interface HistoryWindowState {
id: string;
target: Selectable;
root: HTMLElement;
titleEl: HTMLHeadingElement;
bodyEl: HTMLDivElement;
copyButtonEl: HTMLButtonElement;
text: string;
}

View File

@@ -0,0 +1,247 @@
import { fetchWorldSnapshot, openWorldStream } from "./api";
import { renderFactionStrip } from "./viewerFactionStrip";
import { updateDetailPanel } from "./viewerPanels";
import { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState";
import type {
ClaimDelta,
ClaimSnapshot,
ConstructionSiteDelta,
ConstructionSiteSnapshot,
FactionSnapshot,
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
ShipDelta,
ShipSnapshot,
SpatialNodeDelta,
SpatialNodeSnapshot,
StationDelta,
StationSnapshot,
SystemSnapshot,
WorldDelta,
WorldSnapshot,
} from "./contracts";
import type {
CameraMode,
NetworkStats,
Selectable,
WorldState,
ZoomLevel,
} from "./viewerTypes";
export interface ViewerWorldLifecycleContext {
getWorld: () => WorldState | undefined;
setWorld: (world: WorldState | undefined) => void;
getWorldTimeSyncMs: () => number;
setWorldTimeSyncMs: (value: number) => void;
getWorldSignature: () => string;
setWorldSignature: (value: string) => void;
getStream: () => EventSource | undefined;
setStream: (stream: EventSource | undefined) => void;
getCurrentStreamScopeKey: () => string;
setCurrentStreamScopeKey: (value: string) => void;
getZoomLevel: () => ZoomLevel;
getActiveSystemId: () => string | undefined;
getSelectedItems: () => Selectable[];
getCameraMode: () => CameraMode;
getCameraTargetShipId: () => string | undefined;
getNetworkStats: () => NetworkStats;
getSystemSummaryVisuals: () => Map<string, unknown>;
errorEl: HTMLDivElement;
factionStripEl: HTMLDivElement;
detailTitleEl: HTMLHeadingElement;
detailBodyEl: HTMLDivElement;
worldLabel: () => string;
rebuildSystems: (systems: SystemSnapshot[]) => void;
syncSpatialNodes: (nodes: SpatialNodeSnapshot[]) => void;
syncLocalBubbles: (bubbles: LocalBubbleSnapshot[]) => 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;
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;
updateSystemSummaries: () => void;
applyZoomPresentation: () => void;
updateNetworkPanel: () => void;
updateSystemPanel: () => void;
updateGamePanel: (mode: string) => void;
describeSelectionParent: (selection: Selectable) => string;
}
export class ViewerWorldLifecycle {
constructor(private readonly context: ViewerWorldLifecycleContext) {}
async bootstrapWorld() {
try {
const snapshot = await fetchWorldSnapshot();
this.context.setWorld(createWorldState(snapshot));
this.context.getNetworkStats().snapshotBytes = new Blob([JSON.stringify(snapshot)]).size;
this.context.updateGamePanel("Bootstrapped");
this.context.errorEl.hidden = true;
this.applySnapshot(snapshot);
this.openDeltaStream(snapshot.sequence);
this.updatePanels();
} catch (error) {
this.context.updateGamePanel("Backend offline");
this.context.errorEl.hidden = false;
this.context.errorEl.textContent = error instanceof Error ? error.message : "Unable to bootstrap the backend snapshot.";
}
}
openDeltaStream(afterSequence: number) {
this.context.getStream()?.close();
const scope = this.getPreferredStreamScope();
this.context.setCurrentStreamScopeKey(JSON.stringify(scope));
this.context.setStream(openWorldStream(afterSequence, {
onOpen: () => {
const networkStats = this.context.getNetworkStats();
networkStats.streamConnected = true;
networkStats.streamOpenedAtMs = performance.now();
this.context.updateGamePanel("Stream live");
this.context.updateNetworkPanel();
},
onError: () => {
this.context.getNetworkStats().streamConnected = false;
this.context.updateGamePanel("Stream reconnecting");
this.context.updateNetworkPanel();
},
onDelta: (delta, rawBytes) => {
void this.handleDelta(delta, rawBytes);
},
}, scope));
}
async handleDelta(delta: WorldDelta, rawBytes: number) {
if (!this.context.getWorld()) {
return;
}
if (delta.requiresSnapshotRefresh) {
await this.bootstrapWorld();
return;
}
this.applyDelta(delta);
recordDeltaStats(this.context.getNetworkStats(), delta, rawBytes);
this.context.updateGamePanel("Live");
this.updatePanels();
this.context.updateNetworkPanel();
this.refreshStreamScopeIfNeeded();
}
refreshStreamScopeIfNeeded() {
const world = this.context.getWorld();
if (!world) {
return;
}
const nextScopeKey = JSON.stringify(this.getPreferredStreamScope());
if (nextScopeKey === this.context.getCurrentStreamScopeKey()) {
return;
}
this.openDeltaStream(world.sequence);
}
applySnapshot(snapshot: WorldSnapshot) {
this.context.setWorldTimeSyncMs(performance.now());
const signature = `${snapshot.seed}|${snapshot.systems.length}`;
if (signature !== this.context.getWorldSignature()) {
this.context.setWorldSignature(signature);
this.context.rebuildSystems(snapshot.systems);
}
this.context.syncSpatialNodes(snapshot.spatialNodes);
this.context.syncLocalBubbles(snapshot.localBubbles);
this.context.syncNodes(snapshot.nodes);
this.context.syncStations(snapshot.stations);
this.context.syncClaims(snapshot.claims);
this.context.syncConstructionSites(snapshot.constructionSites);
this.context.syncShips(snapshot.ships, snapshot.tickIntervalMs);
this.rebuildFactions(snapshot.factions);
this.context.updateSystemSummaries();
this.context.applyZoomPresentation();
this.context.updateNetworkPanel();
}
applyDelta(delta: WorldDelta) {
const world = this.context.getWorld();
if (!world) {
return;
}
this.context.setWorldTimeSyncMs(performance.now());
const factionsChanged = applyDeltaToWorld(world, delta);
this.context.applySpatialNodeDeltas(delta.spatialNodes);
this.context.applyLocalBubbleDeltas(delta.localBubbles);
this.context.applyNodeDeltas(delta.nodes);
this.context.applyStationDeltas(delta.stations);
this.context.applyClaimDeltas(delta.claims);
this.context.applyConstructionSiteDeltas(delta.constructionSites);
this.context.applyShipDeltas(delta.ships, delta.tickIntervalMs);
if (factionsChanged) {
this.rebuildFactions(cloneFactions(world));
}
this.context.updateSystemSummaries();
}
rebuildFactions(_factions: FactionSnapshot[]) {
this.context.factionStripEl.innerHTML = renderFactionStrip(
this.context.getWorld(),
this.context.getSelectedItems(),
this.context.getCameraMode(),
this.context.getCameraTargetShipId(),
);
}
updatePanels() {
const world = this.context.getWorld();
if (!world) {
return;
}
this.context.refreshHistoryWindows();
this.context.updateSystemPanel();
this.refreshStreamScopeIfNeeded();
updateDetailPanel(this.context.detailTitleEl, this.context.detailBodyEl, {
world,
selectedItems: this.context.getSelectedItems(),
zoomLevel: this.context.getZoomLevel(),
cameraMode: this.context.getCameraMode(),
cameraTargetShipId: this.context.getCameraTargetShipId(),
worldLabel: this.context.worldLabel(),
describeSelectionParent: this.context.describeSelectionParent,
});
}
private getPreferredStreamScope() {
const activeSystemId = this.context.getActiveSystemId();
if (this.context.getZoomLevel() === "universe" || !activeSystemId) {
return { scopeKind: "universe" as const };
}
const bubbleId = this.context.resolveFocusedBubbleId();
if (this.context.getZoomLevel() === "local" && bubbleId) {
return {
scopeKind: "local-bubble" as const,
systemId: activeSystemId,
bubbleId,
};
}
return {
scopeKind: "system" as const,
systemId: activeSystemId,
};
}
}

View File

@@ -0,0 +1,465 @@
import * as THREE from "three";
import {
computeMoonLocalPosition,
computeMoonSize,
computePlanetLocalPosition,
currentWorldTimeSeconds,
resolveOrbitalAnchorPosition,
toThreeVector,
} from "./viewerMath";
import {
resolveShipHeading,
updateSystemStarPresentation,
updateSystemSummaryPresentation,
getAnimatedShipLocalPosition,
} from "./viewerPresentation";
import type {
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
} from "./contracts";
import type {
BubbleVisual,
ClaimVisual,
ConstructionSiteVisual,
NodeVisual,
OrbitalAnchor,
ShipVisual,
SpatialNodeVisual,
StructureVisual,
SystemSummaryVisual,
SystemVisual,
WorldState,
ZoomLevel,
CameraMode,
} from "./viewerTypes";
type SummaryIconKind = "ship" | "station" | "structure";
export interface WorldOrbitalContext {
world?: WorldState;
worldTimeSyncMs: number;
worldSeed: number;
nodeVisuals: Map<string, NodeVisual>;
spatialNodeVisuals: Map<string, SpatialNodeVisual>;
bubbleVisuals: Map<string, BubbleVisual>;
stationVisuals: Map<string, StructureVisual>;
}
export interface WorldPresentationContext extends WorldOrbitalContext {
activeSystemId?: string;
orbitYaw: number;
camera: THREE.PerspectiveCamera;
systemFocusLocal: THREE.Vector3;
shipVisuals: Map<string, ShipVisual>;
claimVisuals: Map<string, ClaimVisual>;
constructionSiteVisuals: Map<string, ConstructionSiteVisual>;
systemVisuals: Map<string, SystemVisual>;
systemSummaryVisuals: Map<string, SystemSummaryVisual>;
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
updateSystemDetailVisibility: () => void;
setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void;
}
export interface GameStatusParams {
statusEl: HTMLDivElement;
world?: WorldState;
activeSystemId?: string;
cameraMode: CameraMode;
zoomLevel: ZoomLevel;
mode: string;
}
export function updateWorldPresentation(context: WorldPresentationContext) {
const now = performance.now();
const worldTimeSeconds = currentWorldTimeSeconds(context.world, context.worldTimeSyncMs);
for (const visual of context.shipVisuals.values()) {
const worldPosition = getAnimatedShipLocalPosition(visual, now);
visual.mesh.position.copy(context.toDisplayLocalPosition(worldPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
const shipVisible = visual.systemId === context.activeSystemId;
visual.mesh.visible = shipVisible;
visual.icon.visible = shipVisible && visual.icon.visible;
const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw);
if (desiredHeading.lengthSq() > 0.01) {
visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading));
}
}
for (const visual of context.nodeVisuals.values()) {
const animatedLocalPosition = computeNodeLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === context.activeSystemId;
}
for (const visual of context.spatialNodeVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === context.activeSystemId;
visual.icon.visible = visual.systemId === context.activeSystemId;
}
for (const visual of context.bubbleVisuals.values()) {
const animatedLocalPosition = resolveBubbleAnimatedLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.mesh.visible = visual.systemId === context.activeSystemId;
}
for (const visual of context.stationVisuals.values()) {
const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === context.activeSystemId;
}
for (const visual of context.claimVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone();
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === context.activeSystemId;
visual.icon.visible = visual.systemId === context.activeSystemId;
}
for (const visual of context.constructionSiteVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone();
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === context.activeSystemId;
visual.icon.visible = visual.systemId === context.activeSystemId;
}
updateSystemStarPresentation(
context.systemVisuals,
context.activeSystemId,
context.systemFocusLocal,
context.camera,
context.setShellReticleOpacity,
);
context.updateSystemDetailVisibility();
updateSystemSummaryPresentation(context.systemSummaryVisuals, context.camera, context.activeSystemId);
}
export function updateSystemSummaries(world: WorldState | undefined, systemSummaryVisuals: Map<string, SystemSummaryVisual>) {
if (!world) {
return;
}
const shipCounts = new Map<string, number>();
const stationCounts = new Map<string, number>();
const structureCounts = new Map<string, number>();
for (const ship of world.ships.values()) {
shipCounts.set(ship.systemId, (shipCounts.get(ship.systemId) ?? 0) + 1);
}
for (const station of world.stations.values()) {
stationCounts.set(station.systemId, (stationCounts.get(station.systemId) ?? 0) + 1);
structureCounts.set(station.systemId, (structureCounts.get(station.systemId) ?? 0) + 1);
}
for (const node of world.nodes.values()) {
structureCounts.set(node.systemId, (structureCounts.get(node.systemId) ?? 0) + 1);
}
for (const [systemId, system] of world.systems.entries()) {
const visual = systemSummaryVisuals.get(systemId);
if (!visual) {
continue;
}
const canvas = visual.texture.image as HTMLCanvasElement;
const context = canvas.getContext("2d");
if (!context) {
continue;
}
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#eaf4ff";
context.font = "600 34px Space Grotesk, sans-serif";
context.textAlign = "center";
context.fillText(system.label, canvas.width / 2, 40);
const ships = shipCounts.get(systemId) ?? 0;
const stations = stationCounts.get(systemId) ?? 0;
const structures = structureCounts.get(systemId) ?? 0;
const gasClouds = [...world.nodes.values()]
.filter((node) => node.systemId === systemId && node.sourceKind === "gas-cloud")
.length;
const total = ships + stations + structures;
if (total > 0) {
context.fillStyle = "rgba(3, 8, 18, 0.72)";
context.fillRect(56, 64, canvas.width - 112, 68);
context.strokeStyle = "rgba(132, 196, 255, 0.22)";
context.strokeRect(56, 64, canvas.width - 112, 68);
drawCountIcon(context, "ship", 126, 98, ships, "#8bc0ff");
drawCountIcon(context, "station", 256, 98, stations, "#ffbf69");
drawCountIcon(context, "structure", 386, 98, structures, gasClouds > 0 ? "#7fd6ff" : "#98adc4");
}
visual.texture.needsUpdate = true;
}
}
export function renderRecentEvents(world: WorldState | undefined, entityKind: string, entityId: string) {
if (!world) {
return "";
}
return world.recentEvents
.filter((event) => event.entityKind === entityKind && (!entityId || event.entityId === entityId))
.slice(0, 8)
.map((event) => `${new Date(event.occurredAtUtc).toLocaleTimeString()} ${event.message}`)
.join("<br>");
}
export function updateGameStatus(params: GameStatusParams) {
const { statusEl, world, activeSystemId, cameraMode, zoomLevel, mode } = params;
const sequence = world?.sequence ?? 0;
const generatedAt = world?.generatedAtUtc
? new Date(world.generatedAtUtc).toLocaleTimeString()
: "n/a";
const activeSystem = activeSystemId ?? "deep-space";
const cameraModeLabel = cameraMode === "follow" ? "camera-follow" : "tactical";
statusEl.textContent = [
`mode: ${mode}`,
`camera: ${cameraModeLabel}`,
`zoom: ${zoomLevel}`,
`system: ${activeSystem}`,
`sequence: ${sequence}`,
`snapshot: ${generatedAt}`,
].join("\n");
}
export function deriveNodeOrbital(
context: WorldOrbitalContext,
node: ResourceNodeSnapshot | ResourceNodeDelta,
anchor: OrbitalAnchor,
) {
return deriveOrbitalFromLocalPosition(context, toThreeVector(node.localPosition), node.systemId, anchor);
}
export function deriveOrbitalFromLocalPosition(
context: WorldOrbitalContext,
localPosition: THREE.Vector3,
systemId: string,
anchor: OrbitalAnchor,
) {
const anchorPosition = getOrbitalAnchorPosition(context, systemId, anchor, currentWorldTimeSeconds(context.world, context.worldTimeSyncMs));
const relativePosition = localPosition.clone().sub(anchorPosition);
const radius = Math.max(Math.sqrt((relativePosition.x * relativePosition.x) + (relativePosition.z * relativePosition.z)), 24);
const phase = Math.atan2(relativePosition.z, relativePosition.x);
const inclination = Math.atan2(relativePosition.y, radius);
return { radius, phase, inclination };
}
export function computeNodeLocalPosition(context: WorldOrbitalContext, node: NodeVisual, timeSeconds: number) {
const speed = computeNodeOrbitSpeed(node);
const angle = node.orbitPhase + (timeSeconds * speed);
const orbit = new THREE.Vector3(
Math.cos(angle) * node.orbitRadius,
0,
Math.sin(angle) * node.orbitRadius,
);
orbit.applyAxisAngle(new THREE.Vector3(1, 0, 0), node.orbitInclination);
return orbit.add(getOrbitalAnchorPosition(context, node.systemId, node.anchor, timeSeconds));
}
export function resolveOrbitalAnchor(context: WorldOrbitalContext, systemId: string, localPosition: THREE.Vector3): OrbitalAnchor {
if (!context.world) {
return { kind: "star" };
}
const system = context.world.systems.get(systemId);
if (!system) {
return { kind: "star" };
}
const nowSeconds = currentWorldTimeSeconds(context.world, context.worldTimeSyncMs);
let bestAnchor: OrbitalAnchor = { kind: "star" };
let bestDistance = Number.POSITIVE_INFINITY;
for (const [planetIndex, planet] of system.planets.entries()) {
const planetPosition = computePlanetLocalPosition(planet, nowSeconds);
const planetDistance = localPosition.distanceTo(planetPosition);
const planetThreshold = Math.max(planet.size * 10, 180);
if (planetDistance < planetThreshold && planetDistance < bestDistance) {
bestDistance = planetDistance;
bestAnchor = { kind: "planet", planetIndex };
}
const moonCount = Math.min(planet.moonCount, 12);
for (let moonIndex = 0; moonIndex < moonCount; moonIndex += 1) {
const moonPosition = planetPosition
.clone()
.add(computeMoonLocalPosition(planet, moonIndex, nowSeconds, context.world.seed));
const moonDistance = localPosition.distanceTo(moonPosition);
const moonThreshold = Math.max(computeMoonSize(planet, moonIndex, context.world.seed) * 14, 80);
if (moonDistance < moonThreshold && moonDistance < bestDistance) {
bestDistance = moonDistance;
bestAnchor = { kind: "moon", planetIndex, moonIndex };
}
}
}
return bestAnchor;
}
export function resolvePointPosition(context: WorldOrbitalContext, _systemId: string, nodeId?: string | null) {
if (nodeId) {
const spatialNode = context.world?.spatialNodes.get(nodeId);
if (spatialNode) {
return toThreeVector(spatialNode.localPosition);
}
}
return new THREE.Vector3(0, 0, 0);
}
export function resolveBubblePosition(context: WorldOrbitalContext, bubble: LocalBubbleSnapshot | LocalBubbleDelta) {
return resolvePointPosition(context, bubble.systemId, bubble.nodeId);
}
export function computeSpatialNodeLocalPosition(context: WorldOrbitalContext, visual: SpatialNodeVisual, timeSeconds: number) {
return computeSpatialNodeLocalPositionById(context, visual.id, timeSeconds) ?? visual.localPosition.clone();
}
export function computeSpatialNodeLocalPositionById(
context: WorldOrbitalContext,
nodeId: string,
timeSeconds: number,
visiting = new Set<string>(),
): THREE.Vector3 | undefined {
if (!context.world || visiting.has(nodeId)) {
return undefined;
}
const node = context.world.spatialNodes.get(nodeId);
if (!node) {
return undefined;
}
const basePosition = toThreeVector(node.localPosition);
if (!node.parentNodeId) {
return basePosition;
}
const parentNode = context.world.spatialNodes.get(node.parentNodeId);
if (!parentNode) {
return basePosition;
}
visiting.add(nodeId);
const parentCurrentPosition = computeSpatialNodeLocalPositionById(context, node.parentNodeId, timeSeconds, visiting);
visiting.delete(nodeId);
if (!parentCurrentPosition) {
return basePosition;
}
const parentInitialPosition = toThreeVector(parentNode.localPosition);
const relativeOffset = basePosition.clone().sub(parentInitialPosition);
const initialAngle = Math.atan2(parentInitialPosition.z, parentInitialPosition.x);
const currentAngle = Math.atan2(parentCurrentPosition.z, parentCurrentPosition.x);
const rotatedOffset = relativeOffset.applyAxisAngle(new THREE.Vector3(0, 1, 0), currentAngle - initialAngle);
return parentCurrentPosition.clone().add(rotatedOffset);
}
export function setBubbleVisualState(visual: BubbleVisual, bubble: LocalBubbleSnapshot | LocalBubbleDelta) {
const intensity = bubble.occupantShipIds.length + bubble.occupantStationIds.length + bubble.occupantConstructionSiteIds.length;
const material = visual.mesh.material as THREE.LineBasicMaterial;
material.opacity = THREE.MathUtils.clamp(0.18 + intensity * 0.05, 0.18, 0.72);
material.color.set(intensity > 0 ? "#7fffd4" : "#6ed6ff");
}
function drawCountIcon(
context: CanvasRenderingContext2D,
kind: SummaryIconKind,
x: number,
y: number,
value: number,
color: string,
) {
context.save();
context.strokeStyle = color;
context.fillStyle = color;
context.lineWidth = 3;
if (kind === "ship") {
context.beginPath();
context.moveTo(x - 14, y + 10);
context.lineTo(x, y - 14);
context.lineTo(x + 14, y + 10);
context.closePath();
context.stroke();
} else if (kind === "station") {
context.strokeRect(x - 14, y - 14, 28, 28);
} else {
context.beginPath();
context.arc(x, y, 14, 0, Math.PI * 2);
context.stroke();
context.beginPath();
context.moveTo(x - 8, y);
context.lineTo(x + 8, y);
context.moveTo(x, y - 8);
context.lineTo(x, y + 8);
context.stroke();
}
context.fillStyle = "#eaf4ff";
context.font = "600 26px IBM Plex Mono, monospace";
context.textAlign = "left";
context.fillText(String(value), x + 24, y + 9);
context.restore();
}
function computeNodeOrbitSpeed(node: NodeVisual) {
const base = node.sourceKind === "gas-cloud" ? 0.16 : 0.24;
return base / Math.sqrt(Math.max(node.orbitRadius / 140, 0.4));
}
function computeStructureLocalPosition(
context: WorldOrbitalContext,
structure: StructureVisual,
timeSeconds: number,
baseSpeed: number,
) {
const angle = structure.orbitPhase + (timeSeconds * (baseSpeed / Math.sqrt(Math.max(structure.orbitRadius / 180, 0.45))));
const orbit = new THREE.Vector3(
Math.cos(angle) * structure.orbitRadius,
0,
Math.sin(angle) * structure.orbitRadius,
);
orbit.applyAxisAngle(new THREE.Vector3(1, 0, 0), structure.orbitInclination);
return orbit.add(getOrbitalAnchorPosition(context, structure.systemId, structure.anchor, timeSeconds));
}
function getOrbitalAnchorPosition(context: WorldOrbitalContext, systemId: string, anchor: OrbitalAnchor, timeSeconds: number) {
return resolveOrbitalAnchorPosition(context.world, systemId, anchor, timeSeconds, context.worldSeed);
}
function resolveBubbleAnimatedLocalPosition(context: WorldOrbitalContext, visual: BubbleVisual, timeSeconds: number) {
const bubble = context.world?.localBubbles.get(visual.id);
if (!bubble) {
return visual.localPosition.clone();
}
return computeSpatialNodeLocalPositionById(context, bubble.nodeId, timeSeconds) ?? visual.localPosition.clone();
}
function resolveStructureAnimatedLocalPosition(context: WorldOrbitalContext, visual: StructureVisual, timeSeconds: number) {
if (!context.world) {
return visual.localPosition.clone();
}
const station = context.world.stations.get(visual.id);
if (!station?.nodeId) {
return computeStructureLocalPosition(context, visual, timeSeconds, 0.14);
}
return computeSpatialNodeLocalPositionById(context, station.nodeId, timeSeconds) ?? visual.localPosition.clone();
}