feat: production chain
This commit is contained in:
@@ -55,6 +55,7 @@ import { ViewerNavigationController } from "./viewerNavigationController";
|
||||
import { ViewerSceneDataController } from "./viewerSceneDataController";
|
||||
import { ViewerPresentationController } from "./viewerPresentationController";
|
||||
import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory";
|
||||
import type { SceneNode } from "./viewerScenePrimitives";
|
||||
import type { FactionSnapshot, ShipSnapshot } from "./contracts";
|
||||
import type {
|
||||
BubbleVisual,
|
||||
@@ -66,6 +67,7 @@ import type {
|
||||
MoonVisual,
|
||||
NetworkStats,
|
||||
NodeVisual,
|
||||
OrbitLineVisual,
|
||||
OrbitalAnchor,
|
||||
PerformanceStats,
|
||||
PlanetVisual,
|
||||
@@ -101,6 +103,7 @@ export class ViewerAppController {
|
||||
private readonly constructionSiteGroup = new THREE.Group();
|
||||
private readonly shipGroup = new THREE.Group();
|
||||
private readonly ambienceGroup = new THREE.Group();
|
||||
private readonly gamePanelEl: HTMLDivElement;
|
||||
private readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
|
||||
private readonly presentationEntries: PresentationEntry[] = [];
|
||||
private readonly nodeVisuals = new Map<string, NodeVisual>();
|
||||
@@ -113,15 +116,20 @@ export class ViewerAppController {
|
||||
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 orbitLines: OrbitLineVisual[] = [];
|
||||
private readonly statusEl: HTMLDivElement;
|
||||
private readonly gameSummaryEl: HTMLSpanElement;
|
||||
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 networkSectionEl: HTMLDivElement;
|
||||
private readonly networkSummaryEl: HTMLSpanElement;
|
||||
private readonly networkPanelEl: HTMLDivElement;
|
||||
private readonly performanceSectionEl: HTMLDivElement;
|
||||
private readonly performanceSummaryEl: HTMLSpanElement;
|
||||
private readonly performancePanelEl: HTMLDivElement;
|
||||
private readonly errorEl: HTMLDivElement;
|
||||
private readonly historyLayerEl: HTMLDivElement;
|
||||
@@ -179,15 +187,32 @@ export class ViewerAppController {
|
||||
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3);
|
||||
keyLight.position.set(1000, 1200, 800);
|
||||
this.scene.add(keyLight);
|
||||
this.scene.add(
|
||||
this.ambienceGroup,
|
||||
this.systemGroup,
|
||||
this.spatialNodeGroup,
|
||||
this.bubbleGroup,
|
||||
this.nodeGroup,
|
||||
this.stationGroup,
|
||||
this.claimGroup,
|
||||
this.constructionSiteGroup,
|
||||
this.shipGroup,
|
||||
);
|
||||
const hud = createViewerHud(document);
|
||||
this.gamePanelEl = hud.gamePanelEl;
|
||||
this.statusEl = hud.statusEl;
|
||||
this.gameSummaryEl = hud.gameSummaryEl;
|
||||
this.networkSectionEl = hud.networkSectionEl;
|
||||
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.networkSummaryEl = hud.networkSummaryEl;
|
||||
this.networkPanelEl = hud.networkPanelEl;
|
||||
this.performanceSectionEl = hud.performanceSectionEl;
|
||||
this.performanceSummaryEl = hud.performanceSummaryEl;
|
||||
this.performancePanelEl = hud.performancePanelEl;
|
||||
this.errorEl = hud.errorEl;
|
||||
this.historyLayerEl = hud.historyLayerEl;
|
||||
@@ -200,13 +225,31 @@ export class ViewerAppController {
|
||||
worldLifecycle: this.worldLifecycle,
|
||||
interactionController: this.interactionController,
|
||||
} = createViewerControllers(this));
|
||||
this.presentationController.initializeAmbience();
|
||||
|
||||
this.container.append(this.renderer.domElement, hud.root);
|
||||
this.initializePanelToggles();
|
||||
wireViewerEvents(this);
|
||||
this.onResize();
|
||||
this.updateCamera(0);
|
||||
}
|
||||
|
||||
private initializePanelToggles() {
|
||||
for (const panel of [this.gamePanelEl, this.networkSectionEl, this.performanceSectionEl]) {
|
||||
const toggle = panel.querySelector(".panel-toggle");
|
||||
if (!(toggle instanceof HTMLButtonElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
toggle.addEventListener("click", () => {
|
||||
const collapsed = panel.classList.toggle("is-collapsed");
|
||||
toggle.textContent = collapsed ? "+" : "-";
|
||||
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
toggle.setAttribute("aria-label", `${collapsed ? "Expand" : "Collapse"} ${panel.dataset.panelName ?? "panel"}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
await this.worldLifecycle.bootstrapWorld();
|
||||
this.renderer.setAnimationLoop(() => this.render());
|
||||
@@ -308,8 +351,8 @@ export class ViewerAppController {
|
||||
}
|
||||
|
||||
private registerPresentation(
|
||||
detail: THREE.Object3D,
|
||||
icon: THREE.Sprite,
|
||||
detail: SceneNode,
|
||||
icon: SceneNode,
|
||||
hideDetailInUniverse: boolean,
|
||||
hideIconInUniverse = false,
|
||||
systemId?: string,
|
||||
@@ -344,7 +387,7 @@ export class ViewerAppController {
|
||||
});
|
||||
};
|
||||
|
||||
private setShellReticleOpacity(sprite: THREE.Sprite, opacity: number) {
|
||||
private setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
|
||||
setShellReticleOpacity(sprite, opacity);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export type {
|
||||
WorldDelta,
|
||||
SimulationEventRecord,
|
||||
ObserverScope,
|
||||
OrbitalSimulationSnapshot,
|
||||
} from "./contractsWorld";
|
||||
export type {
|
||||
SystemSnapshot,
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface ResourceNodeSnapshot {
|
||||
id: string;
|
||||
systemId: string;
|
||||
localPosition: Vector3Dto;
|
||||
anchorNodeId?: string | null;
|
||||
sourceKind: string;
|
||||
oreRemaining: number;
|
||||
maxOre: number;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { InventoryEntry, Vector3Dto } from "./contractsCommon";
|
||||
|
||||
export interface StationActionProgressSnapshot {
|
||||
lane: string;
|
||||
label: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface StationSnapshot {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -11,8 +17,13 @@ export interface StationSnapshot {
|
||||
anchorNodeId?: string | null;
|
||||
color: string;
|
||||
dockedShips: number;
|
||||
dockedShipIds: string[];
|
||||
dockingPads: number;
|
||||
fuelStored: number;
|
||||
fuelCapacity: number;
|
||||
energyStored: number;
|
||||
energyCapacity: number;
|
||||
currentProcesses: StationActionProgressSnapshot[];
|
||||
inventory: InventoryEntry[];
|
||||
factionId: string;
|
||||
commanderId?: string | null;
|
||||
|
||||
@@ -19,17 +19,24 @@ export interface ShipSnapshot {
|
||||
commanderId?: string | null;
|
||||
policySetId?: string | null;
|
||||
cargoCapacity: number;
|
||||
cargoItemId?: string | null;
|
||||
workerPopulation: number;
|
||||
energyStored: number;
|
||||
inventory: InventoryEntry[];
|
||||
factionId: string;
|
||||
health: number;
|
||||
history: string[];
|
||||
currentAction?: ShipActionProgressSnapshot | null;
|
||||
spatialState: ShipSpatialStateSnapshot;
|
||||
}
|
||||
|
||||
export interface ShipDelta extends ShipSnapshot {}
|
||||
|
||||
export interface ShipActionProgressSnapshot {
|
||||
label: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface ShipSpatialStateSnapshot {
|
||||
spaceLayer: string;
|
||||
currentSystemId: string;
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface WorldSnapshot {
|
||||
seed: number;
|
||||
sequence: number;
|
||||
tickIntervalMs: number;
|
||||
orbitalTimeSeconds: number;
|
||||
orbitalSimulation: OrbitalSimulationSnapshot;
|
||||
generatedAtUtc: string;
|
||||
systems: SystemSnapshot[];
|
||||
spatialNodes: SpatialNodeSnapshot[];
|
||||
@@ -50,6 +52,8 @@ export interface WorldSnapshot {
|
||||
export interface WorldDelta {
|
||||
sequence: number;
|
||||
tickIntervalMs: number;
|
||||
orbitalTimeSeconds: number;
|
||||
orbitalSimulation: OrbitalSimulationSnapshot;
|
||||
generatedAtUtc: string;
|
||||
requiresSnapshotRefresh: boolean;
|
||||
events: SimulationEventRecord[];
|
||||
@@ -83,3 +87,7 @@ export interface ObserverScope {
|
||||
systemId?: string | null;
|
||||
bubbleId?: string | null;
|
||||
}
|
||||
|
||||
export interface OrbitalSimulationSnapshot {
|
||||
simulatedSecondsPerRealSecond: number;
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ canvas {
|
||||
|
||||
.topbar {
|
||||
border-radius: 22px;
|
||||
padding: 18px 20px;
|
||||
padding: 14px 16px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -124,8 +124,48 @@ canvas {
|
||||
.topbar h2 {
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.64rem;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-heading-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-summary {
|
||||
display: none;
|
||||
color: var(--muted);
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel-toggle {
|
||||
border: 1px solid rgba(127, 214, 255, 0.2);
|
||||
background: rgba(127, 214, 255, 0.08);
|
||||
color: var(--text);
|
||||
border-radius: 999px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.panel-toggle:hover {
|
||||
background: rgba(127, 214, 255, 0.16);
|
||||
}
|
||||
|
||||
.topbar-body {
|
||||
@@ -139,7 +179,7 @@ canvas {
|
||||
|
||||
.info-panel {
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
padding: 16px;
|
||||
color: var(--text);
|
||||
pointer-events: auto;
|
||||
overflow: auto;
|
||||
@@ -147,7 +187,7 @@ canvas {
|
||||
|
||||
.network-panel {
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
padding: 14px 16px;
|
||||
color: var(--text);
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -155,7 +195,7 @@ canvas {
|
||||
.performance-panel {
|
||||
width: min(360px, calc(100vw - 40px));
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
padding: 14px 16px;
|
||||
color: var(--text);
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -172,7 +212,8 @@ canvas {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.64rem;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -186,6 +227,20 @@ canvas {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.collapsible-panel.is-collapsed .topbar-body,
|
||||
.collapsible-panel.is-collapsed .network-body,
|
||||
.collapsible-panel.is-collapsed .performance-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collapsible-panel.is-collapsed .panel-summary {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.collapsible-panel.is-collapsed {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin-top: 12px;
|
||||
font-size: 1.05rem;
|
||||
@@ -208,6 +263,40 @@ canvas {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.detail-progress,
|
||||
.ship-action-progress {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.detail-progress-label,
|
||||
.ship-action-progress-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.detail-progress-track,
|
||||
.ship-action-progress-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(127, 214, 255, 0.12);
|
||||
border: 1px solid rgba(127, 214, 255, 0.14);
|
||||
}
|
||||
|
||||
.detail-progress-fill,
|
||||
.ship-action-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(127, 214, 255, 0.72), rgba(255, 191, 105, 0.9));
|
||||
}
|
||||
|
||||
.history {
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.78rem;
|
||||
@@ -329,7 +418,7 @@ canvas {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100vw;
|
||||
width: 50vw;
|
||||
min-height: 128px;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
@@ -412,12 +501,16 @@ canvas {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.ship-card-header + p {
|
||||
.ship-card-header+p {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ship-action-progress {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ship-card-ai {
|
||||
margin-top: 2px;
|
||||
padding-top: 6px;
|
||||
@@ -495,7 +588,7 @@ canvas {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100vw;
|
||||
width: 50vw;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ClaimVisual,
|
||||
ConstructionSiteVisual,
|
||||
NodeVisual,
|
||||
PlanetVisual,
|
||||
Selectable,
|
||||
ShipVisual,
|
||||
SpatialNodeVisual,
|
||||
@@ -19,7 +20,7 @@ interface ResolveSelectionPositionParams {
|
||||
selection: Selectable;
|
||||
worldTimeSyncMs: number;
|
||||
nodeVisuals: Map<string, NodeVisual>;
|
||||
planetVisuals: { systemId: string; planet: { label: string }; mesh: THREE.Mesh }[];
|
||||
planetVisuals: PlanetVisual[];
|
||||
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
|
||||
resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined;
|
||||
resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3;
|
||||
@@ -49,7 +50,7 @@ interface SeedSystemFocusParams {
|
||||
systemFocusLocal: THREE.Vector3;
|
||||
worldTimeSyncMs: number;
|
||||
nodeVisuals: Map<string, NodeVisual>;
|
||||
planetVisuals: { systemId: string; planet: { label: string }; mesh: THREE.Mesh }[];
|
||||
planetVisuals: PlanetVisual[];
|
||||
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
|
||||
resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined;
|
||||
resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3;
|
||||
@@ -217,9 +218,7 @@ export function resolveSelectionPosition(params: ResolveSelectionPositionParams)
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const visual = planetVisuals.find((candidate) =>
|
||||
candidate.systemId === selection.systemId && candidate.planet === planet);
|
||||
return visual?.mesh.position.clone() ?? computePlanetLocalPosition(planet, currentWorldTimeSeconds(world, worldTimeSyncMs));
|
||||
return computePlanetLocalPosition(planet, currentWorldTimeSeconds(world, worldTimeSyncMs));
|
||||
}
|
||||
|
||||
const system = world.systems.get(selection.id);
|
||||
@@ -240,8 +239,14 @@ export function focusOnSelection(params: FocusOnSelectionParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection.kind === "system") {
|
||||
galaxyFocus.copy(nextFocus);
|
||||
systemFocusLocal.set(0, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionSystemId = resolveSelectableSystemId(world, selection);
|
||||
if (selectionSystemId && selection.kind !== "system" && world) {
|
||||
if (selectionSystemId && world) {
|
||||
const system = world.systems.get(selectionSystemId);
|
||||
if (system) {
|
||||
galaxyFocus.copy(toThreeVector(system.galaxyPosition));
|
||||
@@ -282,6 +287,11 @@ export function seedSystemFocusLocal(params: SeedSystemFocusParams) {
|
||||
|
||||
const selected = selectedItems[0];
|
||||
if (selected && resolveSelectableSystemId(world, selected) === systemId) {
|
||||
if (selected.kind === "system") {
|
||||
systemFocusLocal.set(0, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedPosition = resolveSelectionPosition({
|
||||
world,
|
||||
selection: selected,
|
||||
|
||||
@@ -9,7 +9,8 @@ import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
|
||||
export function createViewerControllers(host: any) {
|
||||
const sceneDataController = new ViewerSceneDataController({
|
||||
documentRef: document,
|
||||
getWorldGeneratedAtUtc: () => host.world?.generatedAtUtc,
|
||||
getWorldOrbitalTimeSeconds: () => host.world?.orbitalTimeSeconds,
|
||||
getOrbitalSimulationSpeed: () => host.world?.orbitalSimulation.simulatedSecondsPerRealSecond ?? 0,
|
||||
getWorldSeed: () => host.world?.seed ?? 1,
|
||||
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
|
||||
getWorldPresentationContext: () => host.createWorldPresentationContext(),
|
||||
@@ -77,6 +78,9 @@ export function createViewerControllers(host: any) {
|
||||
scene: host.scene,
|
||||
camera: host.camera,
|
||||
ambienceGroup: host.ambienceGroup,
|
||||
gameSummaryEl: host.gameSummaryEl,
|
||||
networkSummaryEl: host.networkSummaryEl,
|
||||
performanceSummaryEl: host.performanceSummaryEl,
|
||||
statusEl: host.statusEl,
|
||||
networkPanelEl: host.networkPanelEl,
|
||||
performancePanelEl: host.performancePanelEl,
|
||||
@@ -90,6 +94,7 @@ export function createViewerControllers(host: any) {
|
||||
getCameraMode: () => host.cameraMode,
|
||||
getCameraTargetShipId: () => host.cameraTargetShipId,
|
||||
getZoomLevel: () => host.zoomLevel,
|
||||
getSelectedItems: () => host.selectedItems,
|
||||
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
|
||||
getCurrentDistance: () => host.currentDistance,
|
||||
systemFocusLocal: host.systemFocusLocal,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as THREE from "three";
|
||||
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, ZOOM_DISTANCE } from "./viewerConstants";
|
||||
import { rawObject } from "./viewerScenePrimitives";
|
||||
import type {
|
||||
CameraMode,
|
||||
Selectable,
|
||||
@@ -166,14 +167,15 @@ export function updateFollowCamera(params: {
|
||||
|
||||
export function updateSystemDetailVisibility(systemVisuals: Map<string, SystemVisual>, activeSystemId?: string) {
|
||||
for (const [systemId, visual] of systemVisuals.entries()) {
|
||||
visual.detailGroup.visible = systemId === activeSystemId;
|
||||
visual.detailGroup.setVisible(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 setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
|
||||
sprite.setVisible(opacity > 0.02);
|
||||
const material = (rawObject(sprite) as THREE.Sprite).material;
|
||||
material.opacity = opacity;
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function zoomFromWheel(desiredDistance: number, deltaY: number) {
|
||||
@@ -203,8 +205,17 @@ export function applyKeyboardControl(params: {
|
||||
desiredDistance = ZOOM_DISTANCE.system;
|
||||
} else if (key === "3") {
|
||||
desiredDistance = ZOOM_DISTANCE.universe;
|
||||
} else if (key === "=") {
|
||||
desiredDistance = desiredDistance <= ZOOM_DISTANCE.system
|
||||
? ZOOM_DISTANCE.local
|
||||
: ZOOM_DISTANCE.system;
|
||||
} else if (key === "-") {
|
||||
desiredDistance = desiredDistance >= ZOOM_DISTANCE.system
|
||||
? ZOOM_DISTANCE.universe
|
||||
: ZOOM_DISTANCE.system;
|
||||
} else if (key === "/") {
|
||||
desiredDistance = ZOOM_DISTANCE.system;
|
||||
}
|
||||
|
||||
return { cameraMode, desiredDistance };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import { inventoryAmount } from "./viewerMath";
|
||||
import type { CameraMode, Selectable, WorldState } from "./viewerTypes";
|
||||
import { describeShipCurrentAction, describeShipLocation, describeShipState } from "./viewerSelection";
|
||||
import type { CameraMode, Selectable, WorldState, ZoomLevel } from "./viewerTypes";
|
||||
|
||||
export function renderFactionStrip(
|
||||
world: WorldState | undefined,
|
||||
selectedItems: Selectable[],
|
||||
cameraMode: CameraMode,
|
||||
cameraTargetShipId?: string,
|
||||
zoomLevel?: ZoomLevel,
|
||||
activeSystemId?: string,
|
||||
) {
|
||||
if (!world) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const ships = [...world.ships.values()]
|
||||
.filter((ship) => {
|
||||
if (zoomLevel === "universe" || !activeSystemId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ship.systemId === activeSystemId;
|
||||
})
|
||||
.sort((left, right) => left.label.localeCompare(right.label));
|
||||
|
||||
return ships
|
||||
.map((ship) => {
|
||||
const fuel = inventoryAmount(ship.inventory, "gas");
|
||||
const fuel = inventoryAmount(ship.inventory, "fuel");
|
||||
const cargo = ship.cargoItemId
|
||||
? inventoryAmount(ship.inventory, ship.cargoItemId)
|
||||
: 0;
|
||||
const shipLocation = describeShipLocation(world, ship);
|
||||
const shipState = describeShipState(world, ship);
|
||||
const shipAction = describeShipCurrentAction(ship);
|
||||
const isSelected = selectedItems.length === 1
|
||||
&& selectedItems[0].kind === "ship"
|
||||
&& selectedItems[0].id === ship.id;
|
||||
@@ -37,9 +53,20 @@ export function renderFactionStrip(
|
||||
>🕔</button>
|
||||
</div>
|
||||
</div>
|
||||
<p>${ship.systemId}</p>
|
||||
<p>Fuel ${fuel.toFixed(0)} · Energy ${ship.energyStored.toFixed(0)}</p>
|
||||
<p>State ${ship.state}</p>
|
||||
<p>${shipLocation.system}${shipLocation.local ? `<br>${shipLocation.local}` : ""}</p>
|
||||
<p>Fuel ${fuel.toFixed(1)} · Cap ${ship.energyStored.toFixed(1)}${ship.cargoCapacity > 0 ? ` · Cargo ${cargo.toFixed(0)}` : ""}</p>
|
||||
<p>State ${shipState}</p>
|
||||
${shipAction ? `
|
||||
<div class="ship-action-progress">
|
||||
<div class="ship-action-progress-label">
|
||||
<span>${shipAction.label}</span>
|
||||
<span>${Math.round(shipAction.progress * 100)}%</span>
|
||||
</div>
|
||||
<div class="ship-action-progress-track">
|
||||
<div class="ship-action-progress-fill" style="width: ${(shipAction.progress * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
<div class="ship-card-ai">
|
||||
<p>Order ${ship.orderKind ?? "none"}</p>
|
||||
<p>Behavior ${ship.defaultBehaviorKind}</p>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
export interface ViewerHudElements {
|
||||
root: HTMLDivElement;
|
||||
gamePanelEl: HTMLDivElement;
|
||||
statusEl: HTMLDivElement;
|
||||
gameSummaryEl: HTMLSpanElement;
|
||||
networkSectionEl: HTMLDivElement;
|
||||
systemPanelEl: HTMLDivElement;
|
||||
systemTitleEl: HTMLHeadingElement;
|
||||
systemBodyEl: HTMLDivElement;
|
||||
detailTitleEl: HTMLHeadingElement;
|
||||
detailBodyEl: HTMLDivElement;
|
||||
factionStripEl: HTMLDivElement;
|
||||
networkSummaryEl: HTMLSpanElement;
|
||||
networkPanelEl: HTMLDivElement;
|
||||
performanceSectionEl: HTMLDivElement;
|
||||
performanceSummaryEl: HTMLSpanElement;
|
||||
performancePanelEl: HTMLDivElement;
|
||||
errorEl: HTMLDivElement;
|
||||
historyLayerEl: HTMLDivElement;
|
||||
@@ -20,16 +26,34 @@ export function createViewerHud(documentRef: Document): ViewerHudElements {
|
||||
root.className = "viewer-shell";
|
||||
root.innerHTML = `
|
||||
<div class="left-panel-stack">
|
||||
<header class="topbar">
|
||||
<h2>Game</h2>
|
||||
<header class="topbar collapsible-panel is-collapsed" data-panel-name="game">
|
||||
<div class="panel-heading">
|
||||
<h2>Game</h2>
|
||||
<div class="panel-heading-meta">
|
||||
<span class="panel-summary game-summary">Bootstrapping</span>
|
||||
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Game panel">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-body">Bootstrapping</div>
|
||||
</header>
|
||||
<aside class="network-panel">
|
||||
<h2>Network</h2>
|
||||
<aside class="network-panel collapsible-panel is-collapsed" data-panel-name="network">
|
||||
<div class="panel-heading">
|
||||
<h2>Network</h2>
|
||||
<div class="panel-heading-meta">
|
||||
<span class="panel-summary network-summary">Waiting</span>
|
||||
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Network panel">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="network-body">Waiting for snapshot.</div>
|
||||
</aside>
|
||||
<aside class="performance-panel">
|
||||
<h2>Performance</h2>
|
||||
<aside class="performance-panel collapsible-panel is-collapsed" data-panel-name="performance">
|
||||
<div class="panel-heading">
|
||||
<h2>Performance</h2>
|
||||
<div class="panel-heading-meta">
|
||||
<span class="panel-summary performance-summary">Waiting</span>
|
||||
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Performance panel">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="performance-body">Waiting for frame samples.</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -54,14 +78,20 @@ export function createViewerHud(documentRef: Document): ViewerHudElements {
|
||||
|
||||
return {
|
||||
root,
|
||||
gamePanelEl: root.querySelector(".topbar") as HTMLDivElement,
|
||||
statusEl: root.querySelector(".topbar-body") as HTMLDivElement,
|
||||
gameSummaryEl: root.querySelector(".game-summary") as HTMLSpanElement,
|
||||
networkSectionEl: root.querySelector(".network-panel") 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,
|
||||
networkSummaryEl: root.querySelector(".network-summary") as HTMLSpanElement,
|
||||
networkPanelEl: root.querySelector(".network-body") as HTMLDivElement,
|
||||
performanceSectionEl: root.querySelector(".performance-panel") as HTMLDivElement,
|
||||
performanceSummaryEl: root.querySelector(".performance-summary") as HTMLSpanElement,
|
||||
performancePanelEl: root.querySelector(".performance-body") as HTMLDivElement,
|
||||
errorEl: root.querySelector(".error-strip") as HTMLDivElement,
|
||||
historyLayerEl: root.querySelector(".history-layer") as HTMLDivElement,
|
||||
|
||||
@@ -140,6 +140,9 @@ export class ViewerInteractionController {
|
||||
|
||||
const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY);
|
||||
this.context.setSelectedItems(picked ? [picked] : []);
|
||||
if (picked && this.shouldFocusSelectionOnClick(picked)) {
|
||||
this.context.focusOnSelection(picked);
|
||||
}
|
||||
this.context.syncFollowStateFromSelection();
|
||||
this.context.updatePanels();
|
||||
};
|
||||
@@ -294,4 +297,12 @@ export class ViewerInteractionController {
|
||||
this.context.syncFollowStateFromSelection();
|
||||
this.context.updatePanels();
|
||||
}
|
||||
|
||||
private shouldFocusSelectionOnClick(selection: Selectable) {
|
||||
if (selection.kind === "planet") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return selection.kind === "system" && selection.id !== this.context.getActiveSystemId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +76,8 @@ export function currentWorldTimeSeconds(world: WorldState | undefined, worldTime
|
||||
return 0;
|
||||
}
|
||||
|
||||
const baseUtcMs = Date.parse(world.generatedAtUtc);
|
||||
const elapsedMs = performance.now() - worldTimeSyncMs;
|
||||
return ((baseUtcMs + elapsedMs) / 1000) + (world.seed * 97);
|
||||
return world.orbitalTimeSeconds + ((elapsedMs / 1000) * world.orbitalSimulation.simulatedSecondsPerRealSecond);
|
||||
}
|
||||
|
||||
export function hashUnit(seed: number, value: string): number {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatInventory, formatVector } from "./viewerMath";
|
||||
import { describeOrbitalParent, describeSelectable, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
|
||||
import { formatInventory, formatVector, inventoryAmount } from "./viewerMath";
|
||||
import { describeOrbitalParent, describeSelectable, describeShipCurrentAction, describeShipState, describeSpatialNodePathWithinSystem, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
|
||||
import type {
|
||||
CameraMode,
|
||||
HistoryWindowState,
|
||||
@@ -31,6 +31,29 @@ interface SystemPanelParams {
|
||||
cameraTargetShipId?: string;
|
||||
}
|
||||
|
||||
function renderSystemOwnership(world: WorldState, systemId: string): string {
|
||||
const claims = [...world.claims.values()].filter((claim) =>
|
||||
claim.systemId === systemId && claim.state !== "destroyed");
|
||||
if (claims.length === 0) {
|
||||
return "Ownership none";
|
||||
}
|
||||
|
||||
const ownershipByFaction = new Map<string, number>();
|
||||
for (const claim of claims) {
|
||||
ownershipByFaction.set(claim.factionId, (ownershipByFaction.get(claim.factionId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return [...ownershipByFaction.entries()]
|
||||
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
||||
.map(([factionId, count]) => {
|
||||
const faction = world.factions.get(factionId);
|
||||
const label = faction?.label ?? factionId;
|
||||
const share = Math.round((count / claims.length) * 100);
|
||||
return `${label} ${count}/${claims.length} (${share}%)`;
|
||||
})
|
||||
.join("<br>");
|
||||
}
|
||||
|
||||
export function updateDetailPanel(
|
||||
detailTitleEl: HTMLHeadingElement,
|
||||
detailBodyEl: HTMLDivElement,
|
||||
@@ -79,12 +102,30 @@ export function updateDetailPanel(
|
||||
return;
|
||||
}
|
||||
const parent = describeSelectionParent(selected);
|
||||
const cargoUsed = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0);
|
||||
const fuelStored = inventoryAmount(ship.inventory, "fuel");
|
||||
const cargoUsed = ship.cargoItemId
|
||||
? inventoryAmount(ship.inventory, ship.cargoItemId)
|
||||
: 0;
|
||||
const cargoLabel = ship.cargoItemId ?? "none";
|
||||
const shipState = describeShipState(world, ship);
|
||||
const shipAction = describeShipCurrentAction(ship);
|
||||
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>State ${shipState}</p>
|
||||
${shipAction ? `
|
||||
<div class="detail-progress">
|
||||
<div class="detail-progress-label">
|
||||
<span>${shipAction.label}</span>
|
||||
<span>${Math.round(shipAction.progress * 100)}%</span>
|
||||
</div>
|
||||
<div class="detail-progress-track">
|
||||
<div class="detail-progress-fill" style="width: ${(shipAction.progress * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
<p>Fuel ${fuelStored.toFixed(1)}<br>Capacitor ${ship.energyStored.toFixed(1)}</p>
|
||||
<p>Cargo ${cargoLabel} ${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>
|
||||
@@ -98,12 +139,43 @@ export function updateDetailPanel(
|
||||
return;
|
||||
}
|
||||
const parent = describeSelectionParent(selected);
|
||||
const installedModules = station.installedModules.length > 0
|
||||
? station.installedModules.join("<br>")
|
||||
: "none";
|
||||
const activeConstruction = [...world.constructionSites.values()]
|
||||
.filter((site) => site.stationId === station.id && site.state !== "completed")
|
||||
.map((site) => `${site.blueprintId ?? site.targetDefinitionId} (${site.state})`)
|
||||
.join("<br>") || "none";
|
||||
const dockedShipLabels = station.dockedShipIds.length > 0
|
||||
? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.label ?? shipId).join("<br>")
|
||||
: "none";
|
||||
const stationInventory = station.inventory.filter((entry) => entry.itemId !== "fuel");
|
||||
const stationProcesses = station.currentProcesses;
|
||||
const stationProcessingHtml = stationProcesses.length > 0
|
||||
? stationProcesses.map((process) => `
|
||||
<div class="detail-progress">
|
||||
<div class="detail-progress-label">
|
||||
<span>${process.label}</span>
|
||||
<span>${Math.round(process.progress * 100)}%</span>
|
||||
</div>
|
||||
<div class="detail-progress-track">
|
||||
<div class="detail-progress-fill" style="width: ${(process.progress * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
`).join("")
|
||||
: "";
|
||||
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>
|
||||
${stationProcessingHtml}
|
||||
<p>Fuel ${station.fuelStored.toFixed(1)} / ${station.fuelCapacity.toFixed(1)}<br>Capacitor ${station.energyStored.toFixed(1)} / ${station.energyCapacity.toFixed(1)}</p>
|
||||
<p>Docked ${station.dockedShips} / ${station.dockingPads}
|
||||
<br>
|
||||
${dockedShipLabels}</p>
|
||||
<p>Modules ${installedModules}</p>
|
||||
<p>Constructing ${activeConstruction}</p>
|
||||
<p>Inventory ${formatInventory(stationInventory)}</p>
|
||||
<p>History available in the separate history window.</p>
|
||||
`;
|
||||
return;
|
||||
@@ -115,11 +187,23 @@ export function updateDetailPanel(
|
||||
return;
|
||||
}
|
||||
const parent = describeSelectionParent(selected);
|
||||
const nodeLevel = node.maxOre > 0
|
||||
? Math.max(0, Math.min(node.oreRemaining / node.maxOre, 1))
|
||||
: 0;
|
||||
detailTitleEl.textContent = `Node ${node.id}`;
|
||||
detailBodyEl.innerHTML = `
|
||||
<p>${node.systemId}</p>
|
||||
<p>Parent ${parent}</p>
|
||||
<p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p>
|
||||
<div class="detail-progress">
|
||||
<div class="detail-progress-label">
|
||||
<span>Level</span>
|
||||
<span>${Math.round(nodeLevel * 100)}%</span>
|
||||
</div>
|
||||
<div class="detail-progress-track">
|
||||
<div class="detail-progress-fill" style="width: ${(nodeLevel * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
|
||||
`;
|
||||
return;
|
||||
@@ -240,7 +324,9 @@ export function updateSystemPanel(params: SystemPanelParams) {
|
||||
}
|
||||
|
||||
systemTitleEl.textContent = activeSystem.label;
|
||||
systemBodyEl.innerHTML = renderSystemDetails(world, activeSystem, true, cameraMode, cameraTargetShipId);
|
||||
systemBodyEl.innerHTML = `
|
||||
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
export function describeSelectionParent(
|
||||
@@ -270,8 +356,13 @@ export function describeSelectionParent(
|
||||
}
|
||||
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 (!station) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
return station.anchorNodeId
|
||||
? describeSpatialNodePathWithinSystem(world, station.systemId, station.anchorNodeId) ?? `${station.systemId} network`
|
||||
: "unknown";
|
||||
}
|
||||
if (selection.kind === "node") {
|
||||
const node = world.nodes.get(selection.id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as THREE from "three";
|
||||
import { ACTIVE_SYSTEM_DETAIL_SCALE, PROJECTED_GALAXY_RADIUS } from "./viewerConstants";
|
||||
import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds } from "./viewerMath";
|
||||
import { rawObject } from "./viewerScenePrimitives";
|
||||
import type { PlanetVisual, ShipVisual, SystemSummaryVisual, SystemVisual, WorldState } from "./viewerTypes";
|
||||
|
||||
export function getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) {
|
||||
@@ -40,19 +41,21 @@ export function updatePlanetPresentation(
|
||||
? 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);
|
||||
visual.orbit.setScaleScalar(scale);
|
||||
visual.orbit.setPosition(orbitOffset);
|
||||
visual.mesh.setPosition(position);
|
||||
visual.icon.setPosition(position);
|
||||
if (visual.ring) {
|
||||
visual.ring.position.copy(position);
|
||||
visual.ring.setPosition(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),
|
||||
moon.orbit.setPosition(position);
|
||||
moon.orbit.setScaleScalar(scale);
|
||||
moon.mesh.setPosition(
|
||||
position.clone().add(
|
||||
computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds, world?.seed ?? 1).multiplyScalar(scale),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -69,7 +72,7 @@ export function updateSystemSummaryPresentation(
|
||||
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);
|
||||
rawObject(visual.sprite).scale.set(scale, scale * 0.3125, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,49 +81,49 @@ export function updateSystemStarPresentation(
|
||||
activeSystemId: string | undefined,
|
||||
systemFocusLocal: THREE.Vector3,
|
||||
camera: THREE.PerspectiveCamera,
|
||||
setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void,
|
||||
setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], 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);
|
||||
visual.root.setPosition(visual.galaxyPosition);
|
||||
visual.shellReticle.setScaleScalar(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;
|
||||
visual.starCluster.setPosition(new THREE.Vector3(0, 0, 0));
|
||||
visual.icon.setPosition(new THREE.Vector3(0, 0, 0));
|
||||
visual.icon.setVisible(true);
|
||||
visual.shellReticle.setPosition(new THREE.Vector3(0, 0, 0));
|
||||
visual.shellReticle.setVisible(false);
|
||||
setShellReticleOpacity(visual.shellReticle, 0);
|
||||
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;
|
||||
visual.starCluster.setPosition(new THREE.Vector3(0, 0, 0));
|
||||
visual.icon.setPosition(new THREE.Vector3(0, 0, 0));
|
||||
visual.icon.setVisible(false);
|
||||
visual.shellReticle.setPosition(new THREE.Vector3(0, 0, 0));
|
||||
visual.shellReticle.setVisible(true);
|
||||
setShellReticleOpacity(visual.shellReticle, 1);
|
||||
const direction = visual.galaxyPosition.clone().sub(activeSystem.galaxyPosition);
|
||||
if (direction.lengthSq() > 0.0001) {
|
||||
visual.root.position.copy(
|
||||
visual.root.setPosition(
|
||||
activeSystem.galaxyPosition.clone().add(direction.normalize().multiplyScalar(PROJECTED_GALAXY_RADIUS)),
|
||||
);
|
||||
}
|
||||
const reticleWorldPosition = visual.root.getWorldPosition(new THREE.Vector3());
|
||||
const reticleDistance = camera.position.distanceTo(reticleWorldPosition);
|
||||
const reticleScale = Math.max(900, reticleDistance * 0.032);
|
||||
visual.shellReticle.scale.setScalar(reticleScale);
|
||||
visual.shellReticle.setScaleScalar(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;
|
||||
visual.starCluster.setPosition(offset);
|
||||
visual.icon.setPosition(offset);
|
||||
visual.icon.setVisible(true);
|
||||
visual.shellReticle.setVisible(false);
|
||||
setShellReticleOpacity(visual.shellReticle, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,24 @@ import { computeZoomBlend } from "./viewerMath";
|
||||
import {
|
||||
updateNetworkPanel as renderNetworkPanel,
|
||||
recordPerformanceStats,
|
||||
summarizeNetworkStats,
|
||||
summarizePerformanceStats,
|
||||
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";
|
||||
import type { OrbitLineVisual, Selectable } from "./viewerTypes";
|
||||
|
||||
export interface ViewerPresentationContext {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
ambienceGroup: THREE.Group;
|
||||
gameSummaryEl: HTMLSpanElement;
|
||||
networkSummaryEl: HTMLSpanElement;
|
||||
performanceSummaryEl: HTMLSpanElement;
|
||||
statusEl: HTMLDivElement;
|
||||
networkPanelEl: HTMLDivElement;
|
||||
performancePanelEl: HTMLDivElement;
|
||||
@@ -28,13 +34,14 @@ export interface ViewerPresentationContext {
|
||||
getCameraMode: () => any;
|
||||
getCameraTargetShipId: () => string | undefined;
|
||||
getZoomLevel: () => any;
|
||||
getSelectedItems: () => Selectable[];
|
||||
getWorldTimeSyncMs: () => number;
|
||||
getCurrentDistance: () => number;
|
||||
systemFocusLocal: THREE.Vector3;
|
||||
planetVisuals: any[];
|
||||
systemSummaryVisuals: Map<any, any>;
|
||||
presentationEntries: any[];
|
||||
orbitLines: THREE.Object3D[];
|
||||
orbitLines: OrbitLineVisual[];
|
||||
systemVisuals: Map<any, any>;
|
||||
createWorldPresentationContext: () => any;
|
||||
}
|
||||
@@ -74,20 +81,20 @@ export class ViewerPresentationController {
|
||||
? blend.systemWeight * (isActiveDetail ? 1 : 0)
|
||||
: Math.max(blend.systemWeight, blend.universeWeight);
|
||||
|
||||
this.setObjectOpacity(entry.detail, detailAlpha);
|
||||
this.setObjectOpacity(entry.icon, iconAlpha);
|
||||
entry.detail.setOpacity(detailAlpha);
|
||||
entry.icon.setOpacity(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);
|
||||
const alpha = this.resolveOrbitLineOpacity(orbitLine, blend, activeSystemId);
|
||||
orbitLine.line.setOpacity(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);
|
||||
summaryVisual.sprite.setOpacity(summaryOpacity);
|
||||
}
|
||||
|
||||
this.context.scene.fog = new THREE.FogExp2(0x040912, 0.000035);
|
||||
@@ -95,6 +102,7 @@ export class ViewerPresentationController {
|
||||
|
||||
updateNetworkPanel() {
|
||||
renderNetworkPanel(this.context.networkPanelEl, this.context.networkStats);
|
||||
this.context.networkSummaryEl.textContent = summarizeNetworkStats(this.context.networkStats);
|
||||
}
|
||||
|
||||
recordPerformanceStats(frameMs: number) {
|
||||
@@ -103,6 +111,7 @@ export class ViewerPresentationController {
|
||||
|
||||
updatePerformancePanel() {
|
||||
renderPerformancePanel(this.context.performancePanelEl, this.context.performanceStats, this.context.renderer);
|
||||
this.context.performanceSummaryEl.textContent = summarizePerformanceStats(this.context.performanceStats);
|
||||
}
|
||||
|
||||
updateShipPresentation() {
|
||||
@@ -131,10 +140,12 @@ export class ViewerPresentationController {
|
||||
updateGamePanel(mode: string) {
|
||||
updateGameStatus({
|
||||
statusEl: this.context.statusEl,
|
||||
summaryEl: this.context.gameSummaryEl,
|
||||
world: this.context.getWorld(),
|
||||
activeSystemId: this.context.getActiveSystemId(),
|
||||
cameraMode: this.context.getCameraMode(),
|
||||
zoomLevel: this.context.getZoomLevel(),
|
||||
selectedItems: this.context.getSelectedItems(),
|
||||
mode,
|
||||
});
|
||||
}
|
||||
@@ -161,22 +172,21 @@ export class ViewerPresentationController {
|
||||
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;
|
||||
}
|
||||
});
|
||||
private resolveOrbitLineOpacity(orbitLine: OrbitLineVisual, blend: ReturnType<typeof computeZoomBlend>, activeSystemId?: string) {
|
||||
if (!activeSystemId || orbitLine.systemId !== activeSystemId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const selected = this.context.getSelectedItems();
|
||||
const selectedItem = selected.length === 1 ? selected[0] : undefined;
|
||||
const baseAlpha = Math.max(blend.localWeight * 0.55, blend.systemWeight);
|
||||
|
||||
if (selectedItem?.kind === "planet" && selectedItem.systemId === activeSystemId) {
|
||||
return orbitLine.kind === "moon" && orbitLine.planetIndex === selectedItem.planetIndex
|
||||
? baseAlpha
|
||||
: 0;
|
||||
}
|
||||
|
||||
return orbitLine.kind === "planet" ? baseAlpha : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,13 +48,13 @@ import type {
|
||||
StationSnapshot,
|
||||
SystemSnapshot,
|
||||
} from "./contracts";
|
||||
import type {
|
||||
OrbitalAnchor,
|
||||
} from "./viewerTypes";
|
||||
import type { OrbitLineVisual, OrbitalAnchor } from "./viewerTypes";
|
||||
import type { SceneNode } from "./viewerScenePrimitives";
|
||||
|
||||
export interface ViewerSceneDataContext {
|
||||
documentRef: Document;
|
||||
getWorldGeneratedAtUtc: () => string | undefined;
|
||||
getWorldOrbitalTimeSeconds: () => number | undefined;
|
||||
getOrbitalSimulationSpeed: () => number;
|
||||
getWorldSeed: () => number;
|
||||
getWorldTimeSyncMs: () => number;
|
||||
getWorldPresentationContext: () => any;
|
||||
@@ -71,7 +71,7 @@ export interface ViewerSceneDataContext {
|
||||
systemVisuals: Map<any, any>;
|
||||
systemSummaryVisuals: Map<any, any>;
|
||||
planetVisuals: any[];
|
||||
orbitLines: THREE.Object3D[];
|
||||
orbitLines: OrbitLineVisual[];
|
||||
spatialNodeVisuals: Map<any, any>;
|
||||
bubbleVisuals: Map<any, any>;
|
||||
nodeVisuals: Map<any, any>;
|
||||
@@ -79,7 +79,7 @@ export interface ViewerSceneDataContext {
|
||||
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;
|
||||
registerPresentation: (detail: SceneNode, icon: SceneNode, hideDetailInUniverse: boolean, hideIconInUniverse?: boolean, systemId?: string) => void;
|
||||
}
|
||||
|
||||
export class ViewerSceneDataController {
|
||||
@@ -153,7 +153,7 @@ export class ViewerSceneDataController {
|
||||
systemFocusLocal: THREE.Vector3;
|
||||
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
|
||||
updateSystemDetailVisibility: () => void;
|
||||
setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void;
|
||||
setShellReticleOpacity: (sprite: any, opacity: number) => void;
|
||||
}) {
|
||||
return {
|
||||
world: overrides.world,
|
||||
@@ -181,7 +181,8 @@ export class ViewerSceneDataController {
|
||||
private createSceneSyncContext() {
|
||||
return {
|
||||
documentRef: this.context.documentRef,
|
||||
worldGeneratedAtUtc: this.context.getWorldGeneratedAtUtc(),
|
||||
worldOrbitalTimeSeconds: this.context.getWorldOrbitalTimeSeconds(),
|
||||
orbitalSimulationSpeed: this.context.getOrbitalSimulationSpeed(),
|
||||
worldSeed: this.context.getWorldSeed(),
|
||||
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
|
||||
systemGroup: this.context.systemGroup,
|
||||
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
starHaloOpacity,
|
||||
toThreeVector,
|
||||
} from "./viewerMath";
|
||||
import { createSceneNode } from "./viewerScenePrimitives";
|
||||
import type { SceneNode } from "./viewerScenePrimitives";
|
||||
|
||||
export function createNodeMesh(node: ResourceNodeSnapshot): THREE.Mesh {
|
||||
export function createNodeMesh(node: ResourceNodeSnapshot): SceneNode {
|
||||
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),
|
||||
@@ -39,12 +41,12 @@ export function createNodeMesh(node: ResourceNodeSnapshot): THREE.Mesh {
|
||||
);
|
||||
mesh.position.copy(toThreeVector(node.localPosition));
|
||||
mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
|
||||
return mesh;
|
||||
return createSceneNode(mesh);
|
||||
}
|
||||
|
||||
export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColor: (kind: string) => string): THREE.Mesh {
|
||||
export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColor: (kind: string) => string): SceneNode {
|
||||
const color = spatialNodeColor(node.kind);
|
||||
return new THREE.Mesh(
|
||||
return createSceneNode(new THREE.Mesh(
|
||||
new THREE.OctahedronGeometry(10, 0),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color,
|
||||
@@ -52,14 +54,14 @@ export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColo
|
||||
roughness: 0.35,
|
||||
metalness: 0.45,
|
||||
}),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
export function createBubbleRing(
|
||||
bubble: LocalBubbleSnapshot,
|
||||
localPosition: THREE.Vector3,
|
||||
createCirclePoints: (radius: number, segments: number) => THREE.Vector3[],
|
||||
): THREE.LineLoop {
|
||||
): SceneNode {
|
||||
const ring = new THREE.LineLoop(
|
||||
new THREE.BufferGeometry().setFromPoints(createCirclePoints(Math.max(bubble.radius, 60), 64)),
|
||||
new THREE.LineBasicMaterial({
|
||||
@@ -69,11 +71,11 @@ export function createBubbleRing(
|
||||
}),
|
||||
);
|
||||
ring.position.copy(localPosition);
|
||||
return ring;
|
||||
return createSceneNode(ring);
|
||||
}
|
||||
|
||||
export function createClaimMesh(claim: ClaimSnapshot): THREE.Mesh {
|
||||
return new THREE.Mesh(
|
||||
export function createClaimMesh(claim: ClaimSnapshot): SceneNode {
|
||||
return createSceneNode(new THREE.Mesh(
|
||||
new THREE.ConeGeometry(9, 20, 4),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: claim.state === "active" ? 0xff7f50 : 0xff5b5b,
|
||||
@@ -81,11 +83,11 @@ export function createClaimMesh(claim: ClaimSnapshot): THREE.Mesh {
|
||||
roughness: 0.4,
|
||||
metalness: 0.28,
|
||||
}),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): THREE.Mesh {
|
||||
return new THREE.Mesh(
|
||||
export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): SceneNode {
|
||||
return createSceneNode(new THREE.Mesh(
|
||||
new THREE.TorusKnotGeometry(7, 2.2, 54, 8),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: site.state === "completed" ? 0x46d37f : 0x9df29c,
|
||||
@@ -93,10 +95,10 @@ export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): THRE
|
||||
roughness: 0.34,
|
||||
metalness: 0.48,
|
||||
}),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
export function createStarCluster(system: SystemSnapshot): THREE.Group {
|
||||
export function createStarCluster(system: SystemSnapshot): SceneNode {
|
||||
const root = new THREE.Group();
|
||||
const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02);
|
||||
const offsets = system.starCount > 1
|
||||
@@ -123,22 +125,22 @@ export function createStarCluster(system: SystemSnapshot): THREE.Group {
|
||||
root.add(star, halo);
|
||||
}
|
||||
|
||||
return root;
|
||||
return createSceneNode(root);
|
||||
}
|
||||
|
||||
export function createPlanetOrbit(planet: PlanetSnapshot): THREE.LineLoop {
|
||||
export function createPlanetOrbit(planet: PlanetSnapshot): SceneNode {
|
||||
const points = Array.from({ length: 120 }, (_, index) => {
|
||||
const phaseDegrees = (index / 120) * 360;
|
||||
return computePlanetLocalPosition(planet, 0, phaseDegrees);
|
||||
});
|
||||
|
||||
return new THREE.LineLoop(
|
||||
return createSceneNode(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 {
|
||||
export function createPlanetRing(planet: PlanetSnapshot): SceneNode {
|
||||
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),
|
||||
@@ -151,7 +153,7 @@ export function createPlanetRing(planet: PlanetSnapshot): THREE.Mesh {
|
||||
);
|
||||
ring.rotation.x = Math.PI / 2;
|
||||
ring.rotation.z = THREE.MathUtils.degToRad(planet.orbitInclination * 0.25);
|
||||
return ring;
|
||||
return createSceneNode(ring);
|
||||
}
|
||||
|
||||
export function createMoonVisuals(planet: PlanetSnapshot, seed: number): MoonVisual[] {
|
||||
@@ -185,23 +187,23 @@ export function createMoonVisuals(planet: PlanetSnapshot, seed: number): MoonVis
|
||||
}),
|
||||
);
|
||||
|
||||
moons.push({ mesh, orbit });
|
||||
moons.push({ systemId: "", planetIndex: -1, mesh: createSceneNode(mesh), orbit: createSceneNode(orbit) });
|
||||
}
|
||||
|
||||
return moons;
|
||||
}
|
||||
|
||||
export function createStationMesh(station: StationSnapshot): THREE.Mesh {
|
||||
export function createStationMesh(station: StationSnapshot): SceneNode {
|
||||
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;
|
||||
return createSceneNode(mesh);
|
||||
}
|
||||
|
||||
export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): THREE.Mesh {
|
||||
export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): SceneNode {
|
||||
const geometry = new THREE.ConeGeometry(size, length, 7);
|
||||
geometry.rotateX(Math.PI / 2);
|
||||
const mesh = new THREE.Mesh(
|
||||
@@ -212,7 +214,7 @@ export function createShipMesh(ship: ShipSnapshot, size: number, length: number,
|
||||
}),
|
||||
);
|
||||
mesh.position.copy(toThreeVector(ship.localPosition));
|
||||
return mesh;
|
||||
return createSceneNode(mesh);
|
||||
}
|
||||
|
||||
export function createBackdropStars(): THREE.Points {
|
||||
@@ -324,7 +326,7 @@ export function createNebulaClouds(texture: THREE.Texture): THREE.Sprite[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function createTacticalIcon(documentRef: Document, color: string, size: number): THREE.Sprite {
|
||||
export function createTacticalIcon(documentRef: Document, color: string, size: number): SceneNode {
|
||||
const canvas = documentRef.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
@@ -356,7 +358,7 @@ export function createTacticalIcon(documentRef: Document, color: string, size: n
|
||||
}));
|
||||
sprite.scale.setScalar(size);
|
||||
sprite.visible = false;
|
||||
return sprite;
|
||||
return createSceneNode(sprite);
|
||||
}
|
||||
|
||||
export function createSystemSummaryVisual(documentRef: Document, anchor: THREE.Vector3): SystemSummaryVisual {
|
||||
@@ -364,18 +366,18 @@ export function createSystemSummaryVisual(documentRef: Document, anchor: THREE.V
|
||||
canvas.width = 512;
|
||||
canvas.height = 160;
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
|
||||
const sprite = createSceneNode(new THREE.Sprite(new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
}));
|
||||
sprite.scale.set(520, 160, 1);
|
||||
sprite.visible = false;
|
||||
})));
|
||||
sprite.object.scale.set(520, 160, 1);
|
||||
sprite.setVisible(false);
|
||||
return { sprite, texture, anchor };
|
||||
}
|
||||
|
||||
export function createShellReticle(documentRef: Document, color: string, size: number): THREE.Sprite {
|
||||
export function createShellReticle(documentRef: Document, color: string, size: number): SceneNode {
|
||||
const canvas = documentRef.createElement("canvas");
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
@@ -412,9 +414,9 @@ export function createShellReticle(documentRef: Document, color: string, size: n
|
||||
blending: THREE.AdditiveBlending,
|
||||
fog: false,
|
||||
});
|
||||
const sprite = new THREE.Sprite(material);
|
||||
sprite.scale.setScalar(size);
|
||||
sprite.visible = false;
|
||||
sprite.renderOrder = 1000;
|
||||
const sprite = createSceneNode(new THREE.Sprite(material));
|
||||
sprite.setScaleScalar(size);
|
||||
sprite.setVisible(false);
|
||||
sprite.setRenderOrder(1000);
|
||||
return sprite;
|
||||
}
|
||||
|
||||
159
apps/viewer/src/viewerScenePrimitives.ts
Normal file
159
apps/viewer/src/viewerScenePrimitives.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
export interface SceneNode {
|
||||
readonly object: THREE.Object3D;
|
||||
setPosition(position: THREE.Vector3): void;
|
||||
setVisible(visible: boolean): void;
|
||||
setScaleScalar(scale: number): void;
|
||||
setRotationX(radians: number): void;
|
||||
setRotationY(radians: number): void;
|
||||
setRotationZ(radians: number): void;
|
||||
setRenderOrder(order: number): void;
|
||||
add(...children: SceneNode[]): void;
|
||||
clear(): void;
|
||||
lookAt(target: THREE.Vector3): void;
|
||||
getWorldPosition(target?: THREE.Vector3): THREE.Vector3;
|
||||
traverse(visitor: (child: THREE.Object3D) => void): void;
|
||||
setOpacity(opacity: number): void;
|
||||
setColor(color: THREE.ColorRepresentation): void;
|
||||
setEmissive(color: THREE.ColorRepresentation, intensity?: number): void;
|
||||
}
|
||||
|
||||
class ThreeSceneNode implements SceneNode {
|
||||
constructor(public readonly object: THREE.Object3D) {}
|
||||
|
||||
setPosition(position: THREE.Vector3) {
|
||||
this.object.position.copy(position);
|
||||
}
|
||||
|
||||
setVisible(visible: boolean) {
|
||||
this.object.visible = visible;
|
||||
}
|
||||
|
||||
setScaleScalar(scale: number) {
|
||||
this.object.scale.setScalar(scale);
|
||||
}
|
||||
|
||||
setRotationX(radians: number) {
|
||||
this.object.rotation.x = radians;
|
||||
}
|
||||
|
||||
setRotationY(radians: number) {
|
||||
this.object.rotation.y = radians;
|
||||
}
|
||||
|
||||
setRotationZ(radians: number) {
|
||||
this.object.rotation.z = radians;
|
||||
}
|
||||
|
||||
setRenderOrder(order: number) {
|
||||
this.object.renderOrder = order;
|
||||
}
|
||||
|
||||
add(...children: SceneNode[]) {
|
||||
this.object.add(...children.map((child) => child.object));
|
||||
}
|
||||
|
||||
clear() {
|
||||
if ("clear" in this.object && typeof this.object.clear === "function") {
|
||||
this.object.clear();
|
||||
}
|
||||
}
|
||||
|
||||
lookAt(target: THREE.Vector3) {
|
||||
this.object.lookAt(target);
|
||||
}
|
||||
|
||||
getWorldPosition(target = new THREE.Vector3()) {
|
||||
return this.object.getWorldPosition(target);
|
||||
}
|
||||
|
||||
traverse(visitor: (child: THREE.Object3D) => void) {
|
||||
this.object.traverse(visitor);
|
||||
}
|
||||
|
||||
setOpacity(opacity: number) {
|
||||
const visible = opacity > 0.02;
|
||||
this.object.visible = visible;
|
||||
this.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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setColor(color: THREE.ColorRepresentation) {
|
||||
this.object.traverse((child) => {
|
||||
if (!("material" in child)) {
|
||||
return;
|
||||
}
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||
for (const material of materials) {
|
||||
if ("color" in material) {
|
||||
material.color.set(color);
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setEmissive(color: THREE.ColorRepresentation, intensity = 1) {
|
||||
this.object.traverse((child) => {
|
||||
if (!("material" in child)) {
|
||||
return;
|
||||
}
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||
for (const material of materials) {
|
||||
if ("emissive" in material) {
|
||||
material.emissive.set(color);
|
||||
if ("emissiveIntensity" in material) {
|
||||
material.emissiveIntensity = intensity;
|
||||
}
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createSceneNode<T extends THREE.Object3D>(object: T): SceneNode {
|
||||
return new ThreeSceneNode(object);
|
||||
}
|
||||
|
||||
export function rawObject(node: SceneNode) {
|
||||
return node.object;
|
||||
}
|
||||
|
||||
export function addToRawScene(scene: THREE.Scene, ...nodes: SceneNode[]) {
|
||||
scene.add(...nodes.map((node) => node.object));
|
||||
}
|
||||
|
||||
export function registerSelectableTarget(
|
||||
selectableTargets: Map<THREE.Object3D, unknown>,
|
||||
node: SceneNode,
|
||||
selectable: unknown,
|
||||
) {
|
||||
selectableTargets.set(node.object, selectable);
|
||||
}
|
||||
|
||||
export function registerSelectableDescendants(
|
||||
selectableTargets: Map<THREE.Object3D, unknown>,
|
||||
node: SceneNode,
|
||||
selectable: unknown,
|
||||
predicate: (child: THREE.Object3D) => boolean,
|
||||
) {
|
||||
node.traverse((child) => {
|
||||
if (predicate(child)) {
|
||||
selectableTargets.set(child, selectable);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ClaimVisual,
|
||||
ConstructionSiteVisual,
|
||||
NodeVisual,
|
||||
OrbitLineVisual,
|
||||
PlanetVisual,
|
||||
PresentationEntry,
|
||||
Selectable,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
computePlanetLocalPosition,
|
||||
toThreeVector,
|
||||
} from "./viewerMath";
|
||||
import { getAnimatedShipLocalPosition } from "./viewerPresentation";
|
||||
import {
|
||||
createBubbleRing,
|
||||
createClaimMesh,
|
||||
@@ -55,10 +57,18 @@ import {
|
||||
createSystemSummaryVisual,
|
||||
createTacticalIcon,
|
||||
} from "./viewerSceneFactory";
|
||||
import {
|
||||
createSceneNode,
|
||||
rawObject,
|
||||
registerSelectableDescendants,
|
||||
registerSelectableTarget,
|
||||
} from "./viewerScenePrimitives";
|
||||
import type { SceneNode } from "./viewerScenePrimitives";
|
||||
|
||||
interface SceneSyncContext {
|
||||
documentRef: Document;
|
||||
worldGeneratedAtUtc?: string;
|
||||
worldOrbitalTimeSeconds?: number;
|
||||
orbitalSimulationSpeed: number;
|
||||
worldSeed: number;
|
||||
worldTimeSyncMs: number;
|
||||
systemGroup: THREE.Group;
|
||||
@@ -74,7 +84,7 @@ interface SceneSyncContext {
|
||||
systemVisuals: Map<string, SystemVisual>;
|
||||
systemSummaryVisuals: Map<string, SystemSummaryVisual>;
|
||||
planetVisuals: PlanetVisual[];
|
||||
orbitLines: THREE.Object3D[];
|
||||
orbitLines: OrbitLineVisual[];
|
||||
spatialNodeVisuals: Map<string, SpatialNodeVisual>;
|
||||
bubbleVisuals: Map<string, BubbleVisual>;
|
||||
nodeVisuals: Map<string, NodeVisual>;
|
||||
@@ -83,8 +93,8 @@ interface SceneSyncContext {
|
||||
constructionSiteVisuals: Map<string, ConstructionSiteVisual>;
|
||||
shipVisuals: Map<string, ShipVisual>;
|
||||
registerPresentation: (
|
||||
detail: THREE.Object3D,
|
||||
icon: THREE.Sprite,
|
||||
detail: SceneNode,
|
||||
icon: SceneNode,
|
||||
hideDetailInUniverse: boolean,
|
||||
hideIconInUniverse?: boolean,
|
||||
systemId?: string,
|
||||
@@ -111,8 +121,8 @@ interface SceneSyncContext {
|
||||
}
|
||||
|
||||
export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapshot[]) {
|
||||
const worldTimeSeconds = context.worldGeneratedAtUtc
|
||||
? ((Date.parse(context.worldGeneratedAtUtc) + (performance.now() - context.worldTimeSyncMs)) / 1000) + (context.worldSeed * 97)
|
||||
const worldTimeSeconds = context.worldOrbitalTimeSeconds !== undefined
|
||||
? context.worldOrbitalTimeSeconds + ((performance.now() - context.worldTimeSyncMs) / 1000 * context.orbitalSimulationSpeed)
|
||||
: 0;
|
||||
|
||||
context.systemGroup.clear();
|
||||
@@ -124,9 +134,9 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
|
||||
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 root = createSceneNode(new THREE.Group());
|
||||
root.setPosition(new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y, system.galaxyPosition.z));
|
||||
const detailGroup = createSceneNode(new THREE.Group());
|
||||
const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02);
|
||||
|
||||
const starCluster = createStarCluster(system);
|
||||
@@ -136,7 +146,7 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
|
||||
context.documentRef,
|
||||
new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y + renderedStarSize + 140, system.galaxyPosition.z),
|
||||
);
|
||||
summaryVisual.sprite.position.set(0, renderedStarSize + 110, 0);
|
||||
summaryVisual.sprite.setPosition(new THREE.Vector3(0, renderedStarSize + 110, 0));
|
||||
root.add(starCluster, systemIcon, shellReticle, summaryVisual.sprite, detailGroup);
|
||||
context.registerPresentation(starCluster, systemIcon, true);
|
||||
context.systemVisuals.set(system.id, {
|
||||
@@ -150,18 +160,14 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
|
||||
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 });
|
||||
registerSelectableDescendants(context.selectableTargets, starCluster, { kind: "system", id: system.id }, (child) => child instanceof THREE.Mesh);
|
||||
registerSelectableTarget(context.selectableTargets, systemIcon, { kind: "system", id: system.id });
|
||||
registerSelectableTarget(context.selectableTargets, shellReticle, { kind: "system", id: system.id });
|
||||
|
||||
for (const [planetIndex, planet] of system.planets.entries()) {
|
||||
const orbit = createPlanetOrbit(planet);
|
||||
const renderedPlanetRadius = celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06);
|
||||
const planetMesh = new THREE.Mesh(
|
||||
const planetMesh = createSceneNode(new THREE.Mesh(
|
||||
new THREE.SphereGeometry(renderedPlanetRadius, 18, 18),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: planet.color,
|
||||
@@ -169,13 +175,13 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
|
||||
metalness: 0.08,
|
||||
emissive: new THREE.Color(planet.color).multiplyScalar(0.04),
|
||||
}),
|
||||
);
|
||||
planetMesh.position.copy(computePlanetLocalPosition(planet, worldTimeSeconds));
|
||||
));
|
||||
planetMesh.setPosition(computePlanetLocalPosition(planet, worldTimeSeconds));
|
||||
const planetIcon = createTacticalIcon(context.documentRef, planet.color, Math.max(24, renderedPlanetRadius * 2));
|
||||
planetIcon.position.copy(planetMesh.position);
|
||||
planetIcon.setPosition(rawObject(planetMesh).position.clone());
|
||||
const ring = planet.hasRing ? createPlanetRing(planet) : undefined;
|
||||
if (ring) {
|
||||
ring.position.copy(planetMesh.position);
|
||||
ring.setPosition(rawObject(planetMesh).position.clone());
|
||||
}
|
||||
const moons = createMoonVisuals(planet, context.worldSeed);
|
||||
detailGroup.add(orbit, planetMesh, planetIcon);
|
||||
@@ -183,23 +189,35 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
|
||||
detailGroup.add(ring);
|
||||
}
|
||||
for (const moon of moons) {
|
||||
moon.orbit.position.copy(planetMesh.position);
|
||||
moon.mesh.position.copy(planetMesh.position);
|
||||
moon.systemId = system.id;
|
||||
moon.planetIndex = planetIndex;
|
||||
moon.orbit.setPosition(rawObject(planetMesh).position.clone());
|
||||
moon.mesh.setPosition(rawObject(planetMesh).position.clone());
|
||||
detailGroup.add(moon.orbit, moon.mesh);
|
||||
context.orbitLines.push(moon.orbit);
|
||||
context.orbitLines.push({
|
||||
line: moon.orbit,
|
||||
systemId: system.id,
|
||||
kind: "moon",
|
||||
planetIndex,
|
||||
});
|
||||
context.registerPresentation(moon.mesh, planetIcon, true, true, system.id);
|
||||
}
|
||||
context.orbitLines.push(orbit);
|
||||
context.orbitLines.push({
|
||||
line: orbit,
|
||||
systemId: system.id,
|
||||
kind: "planet",
|
||||
planetIndex,
|
||||
});
|
||||
context.registerPresentation(planetMesh, planetIcon, true, true, system.id);
|
||||
if (ring) {
|
||||
context.registerPresentation(ring, planetIcon, true, true, system.id);
|
||||
}
|
||||
context.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, ring, moons });
|
||||
context.selectableTargets.set(planetMesh, { kind: "planet", systemId: system.id, planetIndex });
|
||||
context.selectableTargets.set(planetIcon, { kind: "planet", systemId: system.id, planetIndex });
|
||||
registerSelectableTarget(context.selectableTargets, planetMesh, { kind: "planet", systemId: system.id, planetIndex });
|
||||
registerSelectableTarget(context.selectableTargets, planetIcon, { kind: "planet", systemId: system.id, planetIndex });
|
||||
}
|
||||
|
||||
context.systemGroup.add(root);
|
||||
context.systemGroup.add(rawObject(root));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,8 +229,8 @@ export function syncSpatialNodes(context: SceneSyncContext, nodes: SpatialNodeSn
|
||||
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);
|
||||
mesh.setPosition(localPosition);
|
||||
icon.setPosition(localPosition);
|
||||
context.spatialNodeVisuals.set(node.id, {
|
||||
id: node.id,
|
||||
systemId: node.systemId,
|
||||
@@ -221,10 +239,10 @@ export function syncSpatialNodes(context: SceneSyncContext, nodes: SpatialNodeSn
|
||||
kind: node.kind,
|
||||
localPosition,
|
||||
});
|
||||
context.spatialNodeGroup.add(mesh, icon);
|
||||
context.spatialNodeGroup.add(rawObject(mesh), rawObject(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 });
|
||||
registerSelectableTarget(context.selectableTargets, mesh, { kind: "spatial-node", id: node.id });
|
||||
registerSelectableTarget(context.selectableTargets, icon, { kind: "spatial-node", id: node.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,8 +256,8 @@ export function syncLocalBubbles(context: SceneSyncContext, bubbles: LocalBubble
|
||||
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 });
|
||||
context.bubbleGroup.add(rawObject(mesh));
|
||||
registerSelectableTarget(context.selectableTargets, mesh, { kind: "bubble", id: bubble.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +268,7 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot
|
||||
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);
|
||||
icon.setPosition(rawObject(mesh).position.clone());
|
||||
const localPosition = toThreeVector(node.localPosition);
|
||||
const anchor = context.resolveOrbitalAnchor(node.systemId, localPosition);
|
||||
const orbital = context.deriveNodeOrbital(node, anchor);
|
||||
@@ -265,10 +283,10 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot
|
||||
orbitPhase: orbital.phase,
|
||||
orbitInclination: orbital.inclination,
|
||||
});
|
||||
context.nodeGroup.add(mesh, icon);
|
||||
context.nodeGroup.add(rawObject(mesh), rawObject(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 });
|
||||
registerSelectableTarget(context.selectableTargets, mesh, { kind: "node", id: node.id });
|
||||
registerSelectableTarget(context.selectableTargets, icon, { kind: "node", id: node.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +297,7 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho
|
||||
for (const station of stations) {
|
||||
const mesh = createStationMesh(station);
|
||||
const icon = createTacticalIcon(context.documentRef, station.color, 26);
|
||||
icon.position.copy(mesh.position);
|
||||
icon.setPosition(rawObject(mesh).position.clone());
|
||||
const localPosition = toThreeVector(station.localPosition);
|
||||
const anchor = context.resolveOrbitalAnchor(station.systemId, localPosition);
|
||||
const orbital = context.deriveOrbitalFromLocalPosition(localPosition, station.systemId, anchor);
|
||||
@@ -294,10 +312,10 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho
|
||||
orbitInclination: orbital.inclination,
|
||||
localPosition,
|
||||
});
|
||||
context.stationGroup.add(mesh, icon);
|
||||
context.stationGroup.add(rawObject(mesh), rawObject(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 });
|
||||
registerSelectableTarget(context.selectableTargets, mesh, { kind: "station", id: station.id });
|
||||
registerSelectableTarget(context.selectableTargets, icon, { kind: "station", id: station.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,8 +327,8 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[]) {
|
||||
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);
|
||||
mesh.setPosition(localPosition);
|
||||
icon.setPosition(localPosition);
|
||||
context.claimVisuals.set(claim.id, {
|
||||
id: claim.id,
|
||||
nodeId: claim.nodeId,
|
||||
@@ -319,10 +337,10 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[]) {
|
||||
icon,
|
||||
localPosition,
|
||||
});
|
||||
context.claimGroup.add(mesh, icon);
|
||||
context.claimGroup.add(rawObject(mesh), rawObject(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 });
|
||||
registerSelectableTarget(context.selectableTargets, mesh, { kind: "claim", id: claim.id });
|
||||
registerSelectableTarget(context.selectableTargets, icon, { kind: "claim", id: claim.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,8 +352,8 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc
|
||||
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);
|
||||
mesh.setPosition(localPosition);
|
||||
icon.setPosition(localPosition);
|
||||
context.constructionSiteVisuals.set(site.id, {
|
||||
id: site.id,
|
||||
nodeId: site.nodeId,
|
||||
@@ -344,10 +362,10 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc
|
||||
icon,
|
||||
localPosition,
|
||||
});
|
||||
context.constructionSiteGroup.add(mesh, icon);
|
||||
context.constructionSiteGroup.add(rawObject(mesh), rawObject(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 });
|
||||
registerSelectableTarget(context.selectableTargets, mesh, { kind: "construction-site", id: site.id });
|
||||
registerSelectableTarget(context.selectableTargets, icon, { kind: "construction-site", id: site.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,11 +378,11 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick
|
||||
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 });
|
||||
icon.setPosition(position);
|
||||
icon.setColor(shipColor);
|
||||
context.shipGroup.add(rawObject(mesh), rawObject(icon));
|
||||
registerSelectableTarget(context.selectableTargets, mesh, { kind: "ship", id: ship.id });
|
||||
registerSelectableTarget(context.selectableTargets, icon, { kind: "ship", id: ship.id });
|
||||
context.registerPresentation(mesh, icon, true, true, ship.systemId);
|
||||
context.shipVisuals.set(ship.id, {
|
||||
systemId: ship.systemId,
|
||||
@@ -390,9 +408,9 @@ export function applySpatialNodeDeltas(context: SceneSyncContext, nodes: Spatial
|
||||
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));
|
||||
visual.mesh.setPosition(visual.localPosition);
|
||||
visual.icon.setPosition(visual.localPosition);
|
||||
visual.mesh.setColor(context.spatialNodeColor(node.kind));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,8 +424,8 @@ export function applyLocalBubbleDeltas(context: SceneSyncContext, bubbles: Local
|
||||
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));
|
||||
visual.mesh.setPosition(visual.localPosition);
|
||||
visual.mesh.setScaleScalar(Math.max(bubble.radius, 60));
|
||||
context.setBubbleVisualState(visual, bubble);
|
||||
}
|
||||
}
|
||||
@@ -427,7 +445,7 @@ export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDe
|
||||
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);
|
||||
visual.mesh.setScaleScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,9 +463,8 @@ export function applyStationDeltas(context: SceneSyncContext, stations: StationD
|
||||
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);
|
||||
visual.mesh.setColor(station.color);
|
||||
visual.mesh.setEmissive(station.color, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,11 +477,10 @@ export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[]
|
||||
|
||||
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");
|
||||
visual.mesh.setPosition(visual.localPosition);
|
||||
visual.icon.setPosition(visual.localPosition);
|
||||
visual.mesh.setColor(claim.state === "active" ? "#ff7f50" : "#ff5b5b");
|
||||
visual.mesh.setEmissive(claim.state === "active" ? "#ffb27d" : "#7a2020");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,11 +493,10 @@ export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: Co
|
||||
|
||||
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);
|
||||
visual.mesh.setPosition(visual.localPosition);
|
||||
visual.icon.setPosition(visual.localPosition);
|
||||
visual.mesh.setColor(site.state === "completed" ? "#46d37f" : "#9df29c");
|
||||
visual.mesh.setScaleScalar(0.75 + site.progress * 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,16 +508,15 @@ export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], t
|
||||
}
|
||||
|
||||
visual.systemId = ship.systemId;
|
||||
visual.startPosition.copy(visual.authoritativePosition);
|
||||
visual.startPosition.copy(getAnimatedShipLocalPosition(visual));
|
||||
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);
|
||||
visual.mesh.setColor(shipColor);
|
||||
visual.mesh.setEmissive(shipColor, 0.18);
|
||||
visual.icon.setColor(shipColor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SystemSnapshot } from "./contracts";
|
||||
import type { ShipSnapshot, SpatialNodeSnapshot, SystemSnapshot } from "./contracts";
|
||||
import type {
|
||||
CameraMode,
|
||||
OrbitalAnchor,
|
||||
@@ -214,3 +214,205 @@ export function renderSystemDetails(
|
||||
${followText}
|
||||
`;
|
||||
}
|
||||
|
||||
export function describeShipState(world: WorldState | undefined, ship: ShipSnapshot): string {
|
||||
const baseState = ship.state;
|
||||
if (baseState === "capacitor-starved") {
|
||||
return `${baseState} while ${describeControllerTask(ship.controllerTaskKind)}`;
|
||||
}
|
||||
|
||||
if (!world || (baseState !== "ftl" && baseState !== "spooling-ftl" && baseState !== "warping" && baseState !== "spooling-warp")) {
|
||||
return baseState;
|
||||
}
|
||||
|
||||
const destinationNodeId = ship.spatialState.destinationNodeId ?? ship.spatialState.transit?.destinationNodeId;
|
||||
if (!destinationNodeId) {
|
||||
return baseState;
|
||||
}
|
||||
|
||||
const destinationNode = world.spatialNodes.get(destinationNodeId);
|
||||
if (!destinationNode) {
|
||||
return `${baseState} -> ${destinationNodeId}`;
|
||||
}
|
||||
|
||||
if (baseState === "warping" || baseState === "spooling-warp") {
|
||||
const destinationPath = describeSpatialNodePathWithinSystem(world, destinationNode.systemId, destinationNodeId);
|
||||
return `${baseState} -> ${destinationPath ?? destinationNodeId}`;
|
||||
}
|
||||
|
||||
const destinationSystem = world.systems.get(destinationNode.systemId);
|
||||
return `${baseState} -> ${destinationSystem?.label ?? destinationNode.systemId}`;
|
||||
}
|
||||
|
||||
function describeControllerTask(taskKind: string): string {
|
||||
switch (taskKind) {
|
||||
case "travel":
|
||||
return "travel";
|
||||
case "extract":
|
||||
return "mining";
|
||||
case "dock":
|
||||
return "docking";
|
||||
case "unload":
|
||||
return "transfer";
|
||||
case "refuel":
|
||||
return "refuel";
|
||||
case "deliver-construction":
|
||||
return "material delivery";
|
||||
case "build-construction-site":
|
||||
return "site construction";
|
||||
case "construct-module":
|
||||
return "module construction";
|
||||
case "undock":
|
||||
return "undocking";
|
||||
case "load-workers":
|
||||
return "worker loading";
|
||||
case "unload-workers":
|
||||
return "worker unloading";
|
||||
default:
|
||||
return taskKind;
|
||||
}
|
||||
}
|
||||
|
||||
export function describeShipCurrentAction(ship: ShipSnapshot): { label: string; progress: number } | undefined {
|
||||
if (!ship.currentAction) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
label: ship.currentAction.label,
|
||||
progress: Math.max(0, Math.min(ship.currentAction.progress, 1)),
|
||||
};
|
||||
}
|
||||
|
||||
export function describeShipLocation(world: WorldState | undefined, ship: ShipSnapshot): { system: string; local?: string } {
|
||||
const systemId = ship.spatialState.currentSystemId || ship.systemId;
|
||||
const system = world?.systems.get(systemId);
|
||||
const systemLabel = system?.label ?? systemId;
|
||||
if (!world || !system) {
|
||||
return { system: systemLabel };
|
||||
}
|
||||
|
||||
if (ship.dockedStationId) {
|
||||
const station = world.stations.get(ship.dockedStationId);
|
||||
if (station) {
|
||||
const anchorPath = station.anchorNodeId
|
||||
? describeSpatialNodePathWithinSystem(world, station.systemId, station.anchorNodeId)
|
||||
: undefined;
|
||||
return {
|
||||
system: systemLabel,
|
||||
local: anchorPath ? `${anchorPath}/${station.label}` : station.label,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const currentNodeId = ship.spatialState.currentNodeId ?? ship.nodeId;
|
||||
if (currentNodeId) {
|
||||
const nodePath = describeSpatialNodePathWithinSystem(world, systemId, currentNodeId);
|
||||
if (nodePath) {
|
||||
return { system: systemLabel, local: nodePath };
|
||||
}
|
||||
}
|
||||
|
||||
const currentBubbleId = ship.spatialState.currentBubbleId ?? ship.bubbleId;
|
||||
if (currentBubbleId) {
|
||||
const bubble = world.localBubbles.get(currentBubbleId);
|
||||
if (bubble?.nodeId) {
|
||||
const nodePath = describeSpatialNodePathWithinSystem(world, systemId, bubble.nodeId);
|
||||
if (nodePath) {
|
||||
return { system: systemLabel, local: nodePath };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { system: systemLabel };
|
||||
}
|
||||
|
||||
export function describeActiveSpace(
|
||||
world: WorldState | undefined,
|
||||
zoomLevel: "local" | "system" | "universe",
|
||||
activeSystemId: string | undefined,
|
||||
selectedItems: Selectable[],
|
||||
): string {
|
||||
if (!world || zoomLevel === "universe") {
|
||||
return "deep-space";
|
||||
}
|
||||
|
||||
const activeSystem = activeSystemId ? world.systems.get(activeSystemId) : undefined;
|
||||
if (!activeSystem) {
|
||||
return "deep-space";
|
||||
}
|
||||
|
||||
if (zoomLevel !== "local") {
|
||||
return activeSystem.label;
|
||||
}
|
||||
|
||||
const bubbleId = resolveFocusedBubbleId(world, selectedItems);
|
||||
if (bubbleId) {
|
||||
const bubble = world.localBubbles.get(bubbleId);
|
||||
const localPath = bubble?.nodeId
|
||||
? describeSpatialNodePathWithinSystem(world, activeSystem.id, bubble.nodeId)
|
||||
: undefined;
|
||||
return localPath
|
||||
? `${activeSystem.label} / ${localPath}`
|
||||
: activeSystem.label;
|
||||
}
|
||||
|
||||
const selected = selectedItems.length === 1 ? selectedItems[0] : undefined;
|
||||
if (selected?.kind === "planet" && selected.systemId === activeSystem.id) {
|
||||
const planet = activeSystem.planets[selected.planetIndex];
|
||||
return planet
|
||||
? `${activeSystem.label} / ${planet.label}`
|
||||
: activeSystem.label;
|
||||
}
|
||||
|
||||
return activeSystem.label;
|
||||
}
|
||||
|
||||
export function describeSpatialNodePathWithinSystem(world: WorldState, systemId: string, nodeId: string): string | undefined {
|
||||
const node = world.spatialNodes.get(nodeId);
|
||||
const system = world.systems.get(systemId);
|
||||
if (!node || !system) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (node.parentNodeId) {
|
||||
const parentPath = describeSpatialNodePathWithinSystem(world, systemId, node.parentNodeId);
|
||||
const segment = describeSpatialNodeSegment(world, system, node);
|
||||
return parentPath ? `${parentPath}/${segment}` : segment;
|
||||
}
|
||||
|
||||
if (node.kind === "star") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return describeSpatialNodeSegment(world, system, node);
|
||||
}
|
||||
|
||||
function describeSpatialNodeSegment(world: WorldState, system: SystemSnapshot, node: SpatialNodeSnapshot): string {
|
||||
const moonMatch = node.id.match(/-planet-(\d+)-moon-(\d+)$/);
|
||||
if (moonMatch) {
|
||||
const moonIndex = Number.parseInt(moonMatch[2], 10);
|
||||
return `Moon ${moonIndex}`;
|
||||
}
|
||||
|
||||
const lagrangeMatch = node.id.match(/-planet-\d+-(l[1-5])$/);
|
||||
if (lagrangeMatch) {
|
||||
return lagrangeMatch[1].toUpperCase();
|
||||
}
|
||||
|
||||
const planetMatch = node.id.match(/-planet-(\d+)$/);
|
||||
if (planetMatch) {
|
||||
const planetIndex = Number.parseInt(planetMatch[1], 10) - 1;
|
||||
return system.planets[planetIndex]?.label ?? `Planet ${planetMatch[1]}`;
|
||||
}
|
||||
|
||||
if (node.kind === "station" && node.occupyingStructureId) {
|
||||
return world.stations.get(node.occupyingStructureId)?.label ?? node.occupyingStructureId;
|
||||
}
|
||||
|
||||
if (node.kind === "resource-site") {
|
||||
return node.orbitReferenceId ?? "Resource Site";
|
||||
}
|
||||
|
||||
return node.orbitReferenceId ?? node.kind;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ export function createWorldState(snapshot: WorldSnapshot): WorldState {
|
||||
seed: snapshot.seed,
|
||||
sequence: snapshot.sequence,
|
||||
tickIntervalMs: snapshot.tickIntervalMs,
|
||||
orbitalTimeSeconds: snapshot.orbitalTimeSeconds,
|
||||
orbitalSimulation: snapshot.orbitalSimulation,
|
||||
generatedAtUtc: snapshot.generatedAtUtc,
|
||||
systems: new Map(snapshot.systems.map((system) => [system.id, system])),
|
||||
spatialNodes: new Map(snapshot.spatialNodes.map((node) => [node.id, node])),
|
||||
@@ -55,6 +57,8 @@ export function createWorldState(snapshot: WorldSnapshot): WorldState {
|
||||
export function applyDeltaToWorld(world: WorldState, delta: WorldDelta): boolean {
|
||||
world.sequence = delta.sequence;
|
||||
world.tickIntervalMs = delta.tickIntervalMs;
|
||||
world.orbitalTimeSeconds = delta.orbitalTimeSeconds;
|
||||
world.orbitalSimulation = delta.orbitalSimulation;
|
||||
world.generatedAtUtc = delta.generatedAtUtc;
|
||||
world.recentEvents = [...delta.events, ...world.recentEvents].slice(0, 18);
|
||||
|
||||
|
||||
@@ -36,6 +36,17 @@ export function updateNetworkPanel(networkPanelEl: HTMLDivElement, networkStats:
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function summarizeNetworkStats(networkStats: NetworkStats): string {
|
||||
const now = performance.now();
|
||||
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 direction = networkStats.streamConnected ? "live" : "offline";
|
||||
return `${direction} | down ${kbPerSecond.toFixed(1)} KB/s | ${networkStats.deltasReceived} d`;
|
||||
}
|
||||
|
||||
export function recordPerformanceStats(performanceStats: PerformanceStats, frameMs: number) {
|
||||
const now = performance.now();
|
||||
performanceStats.lastFrameMs = frameMs;
|
||||
@@ -89,3 +100,14 @@ export function updatePerformancePanel(
|
||||
].join("\n");
|
||||
performanceStats.lastPanelUpdateAtMs = now;
|
||||
}
|
||||
|
||||
export function summarizePerformanceStats(performanceStats: PerformanceStats): string {
|
||||
const samples = performanceStats.frameSamples;
|
||||
const elapsedWindowSeconds = samples.length > 1
|
||||
? Math.max((samples[samples.length - 1].atMs - samples[0].atMs) / 1000, 0.25)
|
||||
: 1;
|
||||
const fps = samples.length > 1
|
||||
? (samples.length - 1) / elapsedWindowSeconds
|
||||
: 0;
|
||||
return `FPS ${fps.toFixed(1)} | ${performanceStats.lastFrameMs.toFixed(1)} ms`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as THREE from "three";
|
||||
import type { SceneNode } from "./viewerScenePrimitives";
|
||||
import type {
|
||||
ClaimSnapshot,
|
||||
ConstructionSiteSnapshot,
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
SpatialNodeSnapshot,
|
||||
StationSnapshot,
|
||||
SystemSnapshot,
|
||||
OrbitalSimulationSnapshot,
|
||||
} from "./contracts";
|
||||
|
||||
export type ZoomLevel = "local" | "system" | "universe";
|
||||
@@ -33,8 +35,8 @@ export type Selectable =
|
||||
|
||||
export interface ShipVisual {
|
||||
systemId: string;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
mesh: SceneNode;
|
||||
icon: SceneNode;
|
||||
startPosition: THREE.Vector3;
|
||||
authoritativePosition: THREE.Vector3;
|
||||
targetPosition: THREE.Vector3;
|
||||
@@ -46,16 +48,25 @@ export interface ShipVisual {
|
||||
export interface PlanetVisual {
|
||||
systemId: string;
|
||||
planet: PlanetSnapshot;
|
||||
orbit: THREE.LineLoop;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
ring?: THREE.Mesh;
|
||||
orbit: SceneNode;
|
||||
mesh: SceneNode;
|
||||
icon: SceneNode;
|
||||
ring?: SceneNode;
|
||||
moons: MoonVisual[];
|
||||
}
|
||||
|
||||
export interface MoonVisual {
|
||||
mesh: THREE.Mesh;
|
||||
orbit: THREE.LineLoop;
|
||||
systemId: string;
|
||||
planetIndex: number;
|
||||
mesh: SceneNode;
|
||||
orbit: SceneNode;
|
||||
}
|
||||
|
||||
export interface OrbitLineVisual {
|
||||
line: SceneNode;
|
||||
systemId: string;
|
||||
kind: "planet" | "moon";
|
||||
planetIndex: number;
|
||||
}
|
||||
|
||||
export type OrbitalAnchor =
|
||||
@@ -65,8 +76,8 @@ export type OrbitalAnchor =
|
||||
|
||||
export interface NodeVisual {
|
||||
systemId: string;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
mesh: SceneNode;
|
||||
icon: SceneNode;
|
||||
sourceKind: string;
|
||||
anchor: OrbitalAnchor;
|
||||
localPosition: THREE.Vector3;
|
||||
@@ -78,8 +89,8 @@ export interface NodeVisual {
|
||||
export interface SpatialNodeVisual {
|
||||
id: string;
|
||||
systemId: string;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
mesh: SceneNode;
|
||||
icon: SceneNode;
|
||||
kind: string;
|
||||
localPosition: THREE.Vector3;
|
||||
}
|
||||
@@ -87,7 +98,7 @@ export interface SpatialNodeVisual {
|
||||
export interface BubbleVisual {
|
||||
id: string;
|
||||
systemId: string;
|
||||
mesh: THREE.LineLoop;
|
||||
mesh: SceneNode;
|
||||
localPosition: THREE.Vector3;
|
||||
radius: number;
|
||||
}
|
||||
@@ -96,8 +107,8 @@ export interface ClaimVisual {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
systemId: string;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
mesh: SceneNode;
|
||||
icon: SceneNode;
|
||||
localPosition: THREE.Vector3;
|
||||
}
|
||||
|
||||
@@ -105,16 +116,16 @@ export interface ConstructionSiteVisual {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
systemId: string;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
mesh: SceneNode;
|
||||
icon: SceneNode;
|
||||
localPosition: THREE.Vector3;
|
||||
}
|
||||
|
||||
export interface StructureVisual {
|
||||
id: string;
|
||||
systemId: string;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
mesh: SceneNode;
|
||||
icon: SceneNode;
|
||||
anchor: OrbitalAnchor;
|
||||
orbitRadius: number;
|
||||
orbitPhase: number;
|
||||
@@ -123,12 +134,12 @@ export interface StructureVisual {
|
||||
}
|
||||
|
||||
export interface SystemVisual {
|
||||
root: THREE.Group;
|
||||
starCluster: THREE.Group;
|
||||
icon: THREE.Sprite;
|
||||
shellReticle: THREE.Sprite;
|
||||
root: SceneNode;
|
||||
starCluster: SceneNode;
|
||||
icon: SceneNode;
|
||||
shellReticle: SceneNode;
|
||||
shellReticleBaseScale: number;
|
||||
detailGroup: THREE.Group;
|
||||
detailGroup: SceneNode;
|
||||
summary: SystemSummaryVisual;
|
||||
galaxyPosition: THREE.Vector3;
|
||||
}
|
||||
@@ -138,6 +149,8 @@ export interface WorldState {
|
||||
seed: number;
|
||||
sequence: number;
|
||||
tickIntervalMs: number;
|
||||
orbitalTimeSeconds: number;
|
||||
orbitalSimulation: OrbitalSimulationSnapshot;
|
||||
generatedAtUtc: string;
|
||||
systems: Map<string, SystemSnapshot>;
|
||||
spatialNodes: Map<string, SpatialNodeSnapshot>;
|
||||
@@ -183,15 +196,15 @@ export interface PerformanceStats {
|
||||
}
|
||||
|
||||
export interface PresentationEntry {
|
||||
detail: THREE.Object3D;
|
||||
icon: THREE.Sprite;
|
||||
detail: SceneNode;
|
||||
icon: SceneNode;
|
||||
systemId?: string;
|
||||
hideDetailInUniverse?: boolean;
|
||||
hideIconInUniverse?: boolean;
|
||||
}
|
||||
|
||||
export interface SystemSummaryVisual {
|
||||
sprite: THREE.Sprite;
|
||||
sprite: SceneNode;
|
||||
texture: THREE.CanvasTexture;
|
||||
anchor: THREE.Vector3;
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ export class ViewerWorldLifecycle {
|
||||
}
|
||||
|
||||
this.context.setWorldTimeSyncMs(performance.now());
|
||||
const factionsChanged = applyDeltaToWorld(world, delta);
|
||||
applyDeltaToWorld(world, delta);
|
||||
this.context.applySpatialNodeDeltas(delta.spatialNodes);
|
||||
this.context.applyLocalBubbleDeltas(delta.localBubbles);
|
||||
this.context.applyNodeDeltas(delta.nodes);
|
||||
@@ -189,9 +189,7 @@ export class ViewerWorldLifecycle {
|
||||
this.context.applyClaimDeltas(delta.claims);
|
||||
this.context.applyConstructionSiteDeltas(delta.constructionSites);
|
||||
this.context.applyShipDeltas(delta.ships, delta.tickIntervalMs);
|
||||
if (factionsChanged) {
|
||||
this.rebuildFactions(cloneFactions(world));
|
||||
}
|
||||
this.rebuildFactions(cloneFactions(world));
|
||||
this.context.updateSystemSummaries();
|
||||
}
|
||||
|
||||
@@ -201,6 +199,8 @@ export class ViewerWorldLifecycle {
|
||||
this.context.getSelectedItems(),
|
||||
this.context.getCameraMode(),
|
||||
this.context.getCameraTargetShipId(),
|
||||
this.context.getZoomLevel(),
|
||||
this.context.getActiveSystemId(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
resolveOrbitalAnchorPosition,
|
||||
toThreeVector,
|
||||
} from "./viewerMath";
|
||||
import { describeActiveSpace } from "./viewerSelection";
|
||||
import {
|
||||
resolveShipHeading,
|
||||
updateSystemStarPresentation,
|
||||
updateSystemSummaryPresentation,
|
||||
getAnimatedShipLocalPosition,
|
||||
} from "./viewerPresentation";
|
||||
import { rawObject } from "./viewerScenePrimitives";
|
||||
import type {
|
||||
LocalBubbleDelta,
|
||||
LocalBubbleSnapshot,
|
||||
@@ -22,6 +24,7 @@ import type {
|
||||
import type {
|
||||
BubbleVisual,
|
||||
ClaimVisual,
|
||||
Selectable,
|
||||
ConstructionSiteVisual,
|
||||
NodeVisual,
|
||||
OrbitalAnchor,
|
||||
@@ -59,15 +62,17 @@ export interface WorldPresentationContext extends WorldOrbitalContext {
|
||||
systemSummaryVisuals: Map<string, SystemSummaryVisual>;
|
||||
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
|
||||
updateSystemDetailVisibility: () => void;
|
||||
setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void;
|
||||
setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], opacity: number) => void;
|
||||
}
|
||||
|
||||
export interface GameStatusParams {
|
||||
statusEl: HTMLDivElement;
|
||||
summaryEl?: HTMLSpanElement;
|
||||
world?: WorldState;
|
||||
activeSystemId?: string;
|
||||
cameraMode: CameraMode;
|
||||
zoomLevel: ZoomLevel;
|
||||
selectedItems: Selectable[];
|
||||
mode: string;
|
||||
}
|
||||
|
||||
@@ -77,59 +82,59 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
|
||||
|
||||
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);
|
||||
visual.mesh.setPosition(context.toDisplayLocalPosition(worldPosition, visual.systemId));
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
const shipVisible = visual.systemId === context.activeSystemId;
|
||||
visual.mesh.visible = shipVisible;
|
||||
visual.icon.visible = shipVisible && visual.icon.visible;
|
||||
visual.mesh.setVisible(shipVisible);
|
||||
visual.icon.setVisible(shipVisible && rawObject(visual.icon).visible);
|
||||
const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw);
|
||||
if (desiredHeading.lengthSq() > 0.01) {
|
||||
visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading));
|
||||
visual.mesh.lookAt(rawObject(visual.mesh).position.clone().add(desiredHeading));
|
||||
}
|
||||
}
|
||||
|
||||
for (const visual of context.nodeVisuals.values()) {
|
||||
const animatedLocalPosition = computeNodeLocalPosition(context, visual, worldTimeSeconds);
|
||||
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
|
||||
visual.icon.position.copy(visual.mesh.position);
|
||||
visual.mesh.visible = visual.systemId === context.activeSystemId;
|
||||
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
|
||||
}
|
||||
|
||||
for (const visual of context.spatialNodeVisuals.values()) {
|
||||
const animatedLocalPosition = computeSpatialNodeLocalPosition(context, visual, worldTimeSeconds);
|
||||
visual.mesh.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;
|
||||
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
|
||||
visual.icon.setVisible(visual.systemId === context.activeSystemId);
|
||||
}
|
||||
|
||||
for (const visual of context.bubbleVisuals.values()) {
|
||||
const animatedLocalPosition = resolveBubbleAnimatedLocalPosition(context, visual, worldTimeSeconds);
|
||||
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
|
||||
visual.mesh.visible = visual.systemId === context.activeSystemId;
|
||||
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
|
||||
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
|
||||
}
|
||||
|
||||
for (const visual of context.stationVisuals.values()) {
|
||||
const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds);
|
||||
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
|
||||
visual.icon.position.copy(visual.mesh.position);
|
||||
visual.mesh.visible = visual.systemId === context.activeSystemId;
|
||||
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
|
||||
}
|
||||
|
||||
for (const visual of context.claimVisuals.values()) {
|
||||
const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone();
|
||||
visual.mesh.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;
|
||||
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
|
||||
visual.icon.setVisible(visual.systemId === context.activeSystemId);
|
||||
}
|
||||
|
||||
for (const visual of context.constructionSiteVisuals.values()) {
|
||||
const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone();
|
||||
visual.mesh.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;
|
||||
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
|
||||
visual.icon.setVisible(visual.systemId === context.activeSystemId);
|
||||
}
|
||||
|
||||
updateSystemStarPresentation(
|
||||
@@ -218,22 +223,26 @@ export function renderRecentEvents(world: WorldState | undefined, entityKind: st
|
||||
}
|
||||
|
||||
export function updateGameStatus(params: GameStatusParams) {
|
||||
const { statusEl, world, activeSystemId, cameraMode, zoomLevel, mode } = params;
|
||||
const { statusEl, summaryEl, world, activeSystemId, cameraMode, zoomLevel, selectedItems, 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";
|
||||
const displayZoomLevel = activeSystemId ? zoomLevel : "universe";
|
||||
const activeSpace = describeActiveSpace(world, displayZoomLevel, activeSystemId, selectedItems);
|
||||
const cameraModeLabel = cameraMode === "follow" ? "follow" : "map";
|
||||
|
||||
statusEl.textContent = [
|
||||
`mode: ${mode}`,
|
||||
`camera: ${cameraModeLabel}`,
|
||||
`zoom: ${zoomLevel}`,
|
||||
`system: ${activeSystem}`,
|
||||
`zoom: ${displayZoomLevel}`,
|
||||
`space: ${activeSpace}`,
|
||||
`sequence: ${sequence}`,
|
||||
`snapshot: ${generatedAt}`,
|
||||
].join("\n");
|
||||
if (summaryEl) {
|
||||
summaryEl.textContent = `${mode} | ${displayZoomLevel} | ${activeSpace}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveNodeOrbital(
|
||||
@@ -371,7 +380,7 @@ export function computeSpatialNodeLocalPositionById(
|
||||
|
||||
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;
|
||||
const material = (rawObject(visual.mesh) as THREE.LineLoop).material as THREE.LineBasicMaterial;
|
||||
material.opacity = THREE.MathUtils.clamp(0.18 + intensity * 0.05, 0.18, 0.72);
|
||||
material.color.set(intensity > 0 ? "#7fffd4" : "#6ed6ff");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user