feat: migrate simulation to physically-based unit system
Replace arbitrary game units with real-world measurements throughout the simulation and viewer: planet orbits in AU, sizes in km, galaxy positions in light-years. Add SimulationUnits helpers for conversions, separate WarpSpeed from FtlSpeed for ships, fix FTL transit progress to use galaxy-space distances, overhaul Lagrange point placement with Hill sphere approximation, and update the viewer to scale and format all distances correctly. Ships in FTL transit now render in galaxy view. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -263,6 +263,7 @@ export class ViewerAppController {
|
||||
return this.sceneDataController.createWorldPresentationContext({
|
||||
world: this.world,
|
||||
activeSystemId: this.activeSystemId,
|
||||
zoomLevel: this.zoomLevel,
|
||||
orbitYaw: this.orbitYaw,
|
||||
camera: this.camera,
|
||||
systemFocusLocal: this.systemFocusLocal,
|
||||
@@ -337,6 +338,7 @@ export class ViewerAppController {
|
||||
this.keyState,
|
||||
this.orbitYaw,
|
||||
this.currentDistance,
|
||||
this.zoomLevel,
|
||||
this.activeSystemId,
|
||||
this.systemFocusLocal,
|
||||
this.galaxyFocus,
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface ShipSnapshot {
|
||||
cargoItemId?: string | null;
|
||||
workerPopulation: number;
|
||||
energyStored: number;
|
||||
travelSpeed: number;
|
||||
travelSpeedUnit: string;
|
||||
inventory: InventoryEntry[];
|
||||
factionId: string;
|
||||
health: number;
|
||||
|
||||
@@ -68,14 +68,14 @@ canvas {
|
||||
.hover-label {
|
||||
position: absolute;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
border-radius: 14px;
|
||||
background: rgba(7, 15, 28, 0.88);
|
||||
border: 1px solid rgba(255, 88, 72, 0.5);
|
||||
color: #fff2ef;
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
line-height: 1.35;
|
||||
white-space: pre-line;
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as THREE from "three";
|
||||
import { ACTIVE_SYSTEM_CAPTURE_RADIUS, ACTIVE_SYSTEM_DETAIL_SCALE, GALAXY_PARALLAX_FACTOR } from "./viewerConstants";
|
||||
import { computePlanetLocalPosition, currentWorldTimeSeconds, toThreeVector } from "./viewerMath";
|
||||
import { KILOMETERS_PER_AU, computePlanetLocalPosition, currentWorldTimeSeconds, scaleGalaxyVector, scaleLocalVector, toThreeVector } from "./viewerMath";
|
||||
import { resolveSelectableSystemId } from "./viewerSelection";
|
||||
import type {
|
||||
BubbleVisual,
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
SpatialNodeVisual,
|
||||
StructureVisual,
|
||||
WorldState,
|
||||
ZoomLevel,
|
||||
} from "./viewerTypes";
|
||||
|
||||
interface ResolveSelectionPositionParams {
|
||||
@@ -75,6 +76,7 @@ export function updatePanFromKeyboard(
|
||||
keyState: Set<string>,
|
||||
orbitYaw: number,
|
||||
currentDistance: number,
|
||||
zoomLevel: ZoomLevel,
|
||||
activeSystemId: string | undefined,
|
||||
systemFocusLocal: THREE.Vector3,
|
||||
galaxyFocus: THREE.Vector3,
|
||||
@@ -103,12 +105,15 @@ export function updatePanFromKeyboard(
|
||||
const forward = new THREE.Vector3(Math.cos(orbitYaw), 0, Math.sin(orbitYaw));
|
||||
const right = new THREE.Vector3(-forward.z, 0, forward.x);
|
||||
const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z));
|
||||
const speed = THREE.MathUtils.mapLinear(currentDistance, minimumDistance, maximumDistance, 320, 6800);
|
||||
if (activeSystemId) {
|
||||
systemFocusLocal.addScaledVector(pan, speed * delta);
|
||||
const speedKilometers = zoomLevel === "system"
|
||||
? THREE.MathUtils.mapLinear(currentDistance, 80, 4000, KILOMETERS_PER_AU * 0.002, KILOMETERS_PER_AU * 0.35)
|
||||
: THREE.MathUtils.mapLinear(currentDistance, minimumDistance, 120, 40, 180000);
|
||||
systemFocusLocal.addScaledVector(pan, speedKilometers * delta);
|
||||
return;
|
||||
}
|
||||
|
||||
const speed = THREE.MathUtils.mapLinear(currentDistance, minimumDistance, maximumDistance, 320, 6800);
|
||||
galaxyFocus.addScaledVector(pan, speed * delta);
|
||||
}
|
||||
|
||||
@@ -127,7 +132,14 @@ export function determineActiveSystemId(params: DetermineActiveSystemParams): st
|
||||
}
|
||||
|
||||
if (cameraMode === "follow" && cameraTargetShipId) {
|
||||
return world.ships.get(cameraTargetShipId)?.systemId;
|
||||
const followedShip = world.ships.get(cameraTargetShipId);
|
||||
if (!followedShip) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return followedShip.spatialState.movementRegime === "ftl-transit"
|
||||
? undefined
|
||||
: followedShip.systemId;
|
||||
}
|
||||
|
||||
if (currentDistance >= 12000) {
|
||||
@@ -152,7 +164,7 @@ export function determineActiveSystemId(params: DetermineActiveSystemParams): st
|
||||
let nearestSystemId: string | undefined;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
for (const system of world.systems.values()) {
|
||||
const center = toThreeVector(system.galaxyPosition);
|
||||
const center = scaleGalaxyVector(toThreeVector(system.galaxyPosition));
|
||||
const distance = center.distanceTo(galaxyFocus);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
@@ -222,7 +234,7 @@ export function resolveSelectionPosition(params: ResolveSelectionPositionParams)
|
||||
}
|
||||
|
||||
const system = world.systems.get(selection.id);
|
||||
return system ? toThreeVector(system.galaxyPosition) : undefined;
|
||||
return system ? scaleGalaxyVector(toThreeVector(system.galaxyPosition)) : undefined;
|
||||
}
|
||||
|
||||
export function focusOnSelection(params: FocusOnSelectionParams) {
|
||||
@@ -249,7 +261,7 @@ export function focusOnSelection(params: FocusOnSelectionParams) {
|
||||
if (selectionSystemId && world) {
|
||||
const system = world.systems.get(selectionSystemId);
|
||||
if (system) {
|
||||
galaxyFocus.copy(toThreeVector(system.galaxyPosition));
|
||||
galaxyFocus.copy(scaleGalaxyVector(toThreeVector(system.galaxyPosition)));
|
||||
systemFocusLocal.copy(nextFocus);
|
||||
return;
|
||||
}
|
||||
@@ -325,8 +337,8 @@ export function getCameraFocusWorldPosition(params: CameraFocusParams): THREE.Ve
|
||||
|
||||
const system = world.systems.get(activeSystemId);
|
||||
return system
|
||||
? toThreeVector(system.galaxyPosition).add(
|
||||
systemFocusLocal.clone().multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE * GALAXY_PARALLAX_FACTOR),
|
||||
? scaleGalaxyVector(toThreeVector(system.galaxyPosition)).add(
|
||||
scaleLocalVector(systemFocusLocal).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE * GALAXY_PARALLAX_FACTOR),
|
||||
)
|
||||
: galaxyFocus;
|
||||
}
|
||||
@@ -341,18 +353,20 @@ export function toDisplayLocalPosition(params: DisplayLocalPositionParams): THRE
|
||||
} = params;
|
||||
|
||||
if (!world || !systemId) {
|
||||
return localPosition.clone();
|
||||
return scaleLocalVector(localPosition);
|
||||
}
|
||||
|
||||
const system = world.systems.get(systemId);
|
||||
if (!system) {
|
||||
return localPosition.clone();
|
||||
return scaleLocalVector(localPosition);
|
||||
}
|
||||
|
||||
const center = toThreeVector(system.galaxyPosition);
|
||||
const center = scaleGalaxyVector(toThreeVector(system.galaxyPosition));
|
||||
const scaledLocalPosition = scaleLocalVector(localPosition);
|
||||
const scaledSystemFocus = scaleLocalVector(systemFocusLocal);
|
||||
if (systemId !== activeSystemId) {
|
||||
return center.clone().add(localPosition);
|
||||
return center.clone().add(scaledLocalPosition);
|
||||
}
|
||||
|
||||
return center.clone().add(localPosition.clone().sub(systemFocusLocal).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE));
|
||||
return center.clone().add(scaledLocalPosition.sub(scaledSystemFocus).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ZoomLevel } from "./viewerTypes";
|
||||
|
||||
export const ZOOM_DISTANCE: Record<ZoomLevel, number> = {
|
||||
local: 900,
|
||||
local: 18,
|
||||
system: 3200,
|
||||
universe: 26000,
|
||||
universe: 32000,
|
||||
};
|
||||
|
||||
export const ACTIVE_SYSTEM_DETAIL_SCALE = 10;
|
||||
@@ -13,8 +13,8 @@ export const PROJECTED_GALAXY_RADIUS = 65000;
|
||||
export const STAR_RENDER_SCALE = 0.18;
|
||||
export const PLANET_RENDER_SCALE = 0.95;
|
||||
export const MOON_RENDER_SCALE = 1.1;
|
||||
export const MIN_CAMERA_DISTANCE = 450;
|
||||
export const MAX_CAMERA_DISTANCE = 42000;
|
||||
export const MIN_CAMERA_DISTANCE = 2;
|
||||
export const MAX_CAMERA_DISTANCE = 52000;
|
||||
|
||||
export interface ZoomBlend {
|
||||
localWeight: number;
|
||||
|
||||
@@ -54,6 +54,7 @@ export function createViewerControllers(host: any) {
|
||||
host.cameraTargetShipId = value;
|
||||
},
|
||||
getCurrentDistance: () => host.currentDistance,
|
||||
getZoomLevel: () => host.zoomLevel,
|
||||
getSelectedItems: () => host.selectedItems,
|
||||
getOrbitYaw: () => host.orbitYaw,
|
||||
galaxyFocus: host.galaxyFocus,
|
||||
@@ -199,6 +200,7 @@ export function createViewerControllers(host: any) {
|
||||
keyState: host.keyState,
|
||||
getWorld: () => host.world,
|
||||
getActiveSystemId: () => host.activeSystemId,
|
||||
getZoomLevel: () => host.zoomLevel,
|
||||
getSelectedItems: () => host.selectedItems,
|
||||
setSelectedItems: (items) => {
|
||||
host.selectedItems = items;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import * as THREE from "three";
|
||||
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, ZOOM_DISTANCE } from "./viewerConstants";
|
||||
import { scaleGalaxyVector, toDisplayGalaxyVector, toThreeVector } from "./viewerMath";
|
||||
import { rawObject } from "./viewerScenePrimitives";
|
||||
import { resolveShipWorldPosition } from "./viewerWorldPresentation";
|
||||
import type {
|
||||
CameraMode,
|
||||
Selectable,
|
||||
ShipVisual,
|
||||
SystemVisual,
|
||||
WorldState,
|
||||
ZoomLevel,
|
||||
} from "./viewerTypes";
|
||||
|
||||
export function syncFollowStateFromSelection(
|
||||
@@ -129,10 +132,32 @@ export function updateFollowCamera(params: {
|
||||
}
|
||||
|
||||
const shipLocalPosition = getAnimatedShipLocalPosition(visual);
|
||||
const shipWorldPosition = toDisplayLocalPosition(shipLocalPosition, ship.systemId);
|
||||
systemFocusLocal.lerp(shipLocalPosition, 1 - Math.exp(-delta * 8));
|
||||
const shipWorldPosition = resolveShipWorldPosition(
|
||||
{
|
||||
world,
|
||||
toDisplayLocalPosition,
|
||||
},
|
||||
ship,
|
||||
visual,
|
||||
shipLocalPosition,
|
||||
);
|
||||
|
||||
if (ship.spatialState.movementRegime === "ftl-transit") {
|
||||
systemFocusLocal.set(0, 0, 0);
|
||||
const destinationNodeId = ship.spatialState.transit?.destinationNodeId;
|
||||
const destinationNode = destinationNodeId ? world.spatialNodes.get(destinationNodeId) : undefined;
|
||||
const destinationSystem = destinationNode ? world.systems.get(destinationNode.systemId) : undefined;
|
||||
const originSystem = world.systems.get(ship.systemId);
|
||||
if (originSystem && destinationSystem) {
|
||||
followCameraDesiredDirection
|
||||
.copy(scaleGalaxyVector(toThreeVector(destinationSystem.galaxyPosition)).sub(scaleGalaxyVector(toThreeVector(originSystem.galaxyPosition))))
|
||||
.normalize();
|
||||
}
|
||||
} else {
|
||||
systemFocusLocal.lerp(shipLocalPosition, 1 - Math.exp(-delta * 8));
|
||||
followCameraDesiredDirection.copy(resolveShipHeading(visual, shipLocalPosition)).normalize();
|
||||
}
|
||||
|
||||
followCameraDesiredDirection.copy(resolveShipHeading(visual, shipLocalPosition)).normalize();
|
||||
followCameraDirection.lerp(followCameraDesiredDirection, 1 - Math.exp(-delta * 5));
|
||||
followCameraDirection.normalize();
|
||||
|
||||
@@ -165,9 +190,10 @@ export function updateFollowCamera(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function updateSystemDetailVisibility(systemVisuals: Map<string, SystemVisual>, activeSystemId?: string) {
|
||||
export function updateSystemDetailVisibility(systemVisuals: Map<string, SystemVisual>, activeSystemId?: string, zoomLevel?: ZoomLevel) {
|
||||
const detailVisible = !!activeSystemId && zoomLevel !== "universe";
|
||||
for (const [systemId, visual] of systemVisuals.entries()) {
|
||||
visual.detailGroup.setVisible(systemId === activeSystemId);
|
||||
visual.detailGroup.setVisible(detailVisible && systemId === activeSystemId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import * as THREE from "three";
|
||||
import { getSelectionGroup } from "./viewerSelection";
|
||||
import type { Selectable, SelectionGroup, WorldState } from "./viewerTypes";
|
||||
import { describeHoverLabel, getSelectionGroup } from "./viewerSelection";
|
||||
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
|
||||
import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, formatAdaptiveDistanceFromKilometers, formatSystemDistance } from "./viewerMath";
|
||||
import type { Selectable, SelectionGroup, WorldState, ZoomLevel } from "./viewerTypes";
|
||||
|
||||
export interface HoverPickResult {
|
||||
selection: Selectable;
|
||||
object: THREE.Object3D;
|
||||
}
|
||||
|
||||
export function pickSelectableAtClientPosition(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
@@ -11,29 +18,49 @@ export function pickSelectableAtClientPosition(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) {
|
||||
const hit = pickSelectableHitAtClientPosition(renderer, raycaster, mouse, camera, selectableTargets, clientX, clientY);
|
||||
return hit?.selection;
|
||||
}
|
||||
|
||||
export function pickSelectableHitAtClientPosition(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
raycaster: THREE.Raycaster,
|
||||
mouse: THREE.Vector2,
|
||||
camera: THREE.Camera,
|
||||
selectableTargets: Map<THREE.Object3D, Selectable>,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): HoverPickResult | undefined {
|
||||
const bounds = renderer.domElement.getBoundingClientRect();
|
||||
mouse.x = ((clientX - bounds.left) / bounds.width) * 2 - 1;
|
||||
mouse.y = -(((clientY - bounds.top) / bounds.height) * 2 - 1);
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const hit = raycaster.intersectObjects([...selectableTargets.keys()], false)[0];
|
||||
return hit ? selectableTargets.get(hit.object) : undefined;
|
||||
const selection = hit ? selectableTargets.get(hit.object) : undefined;
|
||||
return hit && selection
|
||||
? { selection, object: hit.object }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function updateHoverLabel(params: {
|
||||
dragMode?: string;
|
||||
hoverLabelEl: HTMLDivElement;
|
||||
selection: Selectable | undefined;
|
||||
hoverPick: HoverPickResult | undefined;
|
||||
activeSystemId?: string;
|
||||
zoomLevel: ZoomLevel;
|
||||
world?: WorldState;
|
||||
point: THREE.Vector2;
|
||||
camera: THREE.Camera;
|
||||
}) {
|
||||
const {
|
||||
dragMode,
|
||||
hoverLabelEl,
|
||||
selection,
|
||||
hoverPick,
|
||||
activeSystemId,
|
||||
zoomLevel,
|
||||
world,
|
||||
point,
|
||||
camera,
|
||||
} = params;
|
||||
|
||||
if (dragMode) {
|
||||
@@ -41,23 +68,60 @@ export function updateHoverLabel(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selection || selection.kind !== "system" || selection.id === activeSystemId) {
|
||||
if (!hoverPick) {
|
||||
hoverLabelEl.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const system = world?.systems.get(selection.id);
|
||||
if (!system) {
|
||||
const { selection, object } = hoverPick;
|
||||
const label = describeHoverLabel(world, selection);
|
||||
if (!label) {
|
||||
hoverLabelEl.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const distance = formatHoverDistance(camera, object, selection, zoomLevel, activeSystemId);
|
||||
|
||||
hoverLabelEl.hidden = false;
|
||||
hoverLabelEl.textContent = system.label;
|
||||
hoverLabelEl.textContent = `${label}\n${distance}`;
|
||||
hoverLabelEl.style.left = `${point.x + 14}px`;
|
||||
hoverLabelEl.style.top = `${point.y + 14}px`;
|
||||
}
|
||||
|
||||
function formatHoverDistance(
|
||||
camera: THREE.Camera,
|
||||
object: THREE.Object3D,
|
||||
selection: Selectable,
|
||||
zoomLevel: ZoomLevel,
|
||||
activeSystemId?: string,
|
||||
) {
|
||||
const worldPosition = object.getWorldPosition(new THREE.Vector3());
|
||||
const displayDistance = camera.position.distanceTo(worldPosition);
|
||||
|
||||
if (selection.kind === "system") {
|
||||
return formatAdaptiveDistanceFromKilometers((displayDistance / DISPLAY_UNITS_PER_LIGHT_YEAR) * 9.4607e12);
|
||||
}
|
||||
|
||||
const inActiveSystem = selection.kind === "planet"
|
||||
? selection.systemId === activeSystemId
|
||||
: selection.kind === "ship"
|
||||
|| selection.kind === "station"
|
||||
|| selection.kind === "node"
|
||||
|| selection.kind === "spatial-node"
|
||||
|| selection.kind === "bubble"
|
||||
|| selection.kind === "claim"
|
||||
|| selection.kind === "construction-site";
|
||||
|
||||
if (inActiveSystem && activeSystemId) {
|
||||
const kilometers = displayDistance / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
|
||||
return zoomLevel === "system"
|
||||
? formatSystemDistance(kilometers / KILOMETERS_PER_AU)
|
||||
: formatAdaptiveDistanceFromKilometers(kilometers);
|
||||
}
|
||||
|
||||
return formatAdaptiveDistanceFromKilometers(displayDistance / DISPLAY_UNITS_PER_KILOMETER);
|
||||
}
|
||||
|
||||
export function updateMarqueeBox(
|
||||
marqueeEl: HTMLDivElement,
|
||||
dragStart: THREE.Vector2,
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as THREE from "three";
|
||||
import {
|
||||
completeMarqueeSelection,
|
||||
hideMarqueeBox,
|
||||
pickSelectableHitAtClientPosition,
|
||||
pickSelectableAtClientPosition,
|
||||
updateHoverLabel,
|
||||
updateMarqueeBox,
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
DragMode,
|
||||
Selectable,
|
||||
WorldState,
|
||||
ZoomLevel,
|
||||
} from "./viewerTypes";
|
||||
|
||||
export interface ViewerInteractionContext {
|
||||
@@ -30,6 +32,7 @@ export interface ViewerInteractionContext {
|
||||
keyState: Set<string>;
|
||||
getWorld: () => WorldState | undefined;
|
||||
getActiveSystemId: () => string | undefined;
|
||||
getZoomLevel: () => ZoomLevel;
|
||||
getSelectedItems: () => Selectable[];
|
||||
setSelectedItems: (items: Selectable[]) => void;
|
||||
getDragMode: () => DragMode | undefined;
|
||||
@@ -247,10 +250,12 @@ export class ViewerInteractionController {
|
||||
updateHoverLabel({
|
||||
dragMode: this.context.getDragMode(),
|
||||
hoverLabelEl: this.context.hoverLabelEl,
|
||||
selection: this.pickSelectableAtClientPosition(event.clientX, event.clientY),
|
||||
hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY),
|
||||
activeSystemId: this.context.getActiveSystemId(),
|
||||
zoomLevel: this.context.getZoomLevel(),
|
||||
world: this.context.getWorld(),
|
||||
point: this.context.screenPointFromClient(event.clientX, event.clientY),
|
||||
camera: this.context.camera,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -285,6 +290,18 @@ export class ViewerInteractionController {
|
||||
);
|
||||
}
|
||||
|
||||
private pickSelectableHitAtClientPosition(clientX: number, clientY: number) {
|
||||
return pickSelectableHitAtClientPosition(
|
||||
this.context.renderer,
|
||||
this.context.raycaster,
|
||||
this.context.mouse,
|
||||
this.context.camera,
|
||||
this.context.selectableTargets,
|
||||
clientX,
|
||||
clientY,
|
||||
);
|
||||
}
|
||||
|
||||
private completeMarqueeSelection() {
|
||||
const selection = completeMarqueeSelection({
|
||||
renderer: this.context.renderer,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as THREE from "three";
|
||||
import { MOON_RENDER_SCALE } from "./viewerConstants";
|
||||
import type {
|
||||
ShipSnapshot,
|
||||
PlanetSnapshot,
|
||||
Vector3Dto,
|
||||
WorldSnapshot,
|
||||
@@ -12,6 +13,10 @@ import type {
|
||||
} from "./viewerTypes";
|
||||
import type { ZoomBlend } from "./viewerConstants";
|
||||
|
||||
export const KILOMETERS_PER_AU = 149_597_870.7;
|
||||
export const DISPLAY_UNITS_PER_KILOMETER = 0.0000015;
|
||||
export const DISPLAY_UNITS_PER_LIGHT_YEAR = 2600;
|
||||
|
||||
export function formatInventory(entries: { itemId: string; amount: number }[]): string {
|
||||
if (entries.length === 0) {
|
||||
return "empty";
|
||||
@@ -30,6 +35,61 @@ export function formatVector(vector: Vector3Dto): string {
|
||||
return `${vector.x.toFixed(1)}, ${vector.y.toFixed(1)}, ${vector.z.toFixed(1)}`;
|
||||
}
|
||||
|
||||
function formatNumber(value: number, fractionDigits: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatLocalDistance(value: number): string {
|
||||
return `${formatNumber(value, 0)} km`;
|
||||
}
|
||||
|
||||
export function formatSystemDistance(value: number): string {
|
||||
return `${formatNumber(value, 2)} AU`;
|
||||
}
|
||||
|
||||
export function formatGalaxyDistance(value: number): string {
|
||||
return `${formatNumber(value, 2)} ly`;
|
||||
}
|
||||
|
||||
export function formatAdaptiveDistanceFromKilometers(kilometers: number): string {
|
||||
const absoluteKilometers = Math.max(0, kilometers);
|
||||
const meters = absoluteKilometers * 1000;
|
||||
const astronomicalUnits = absoluteKilometers / KILOMETERS_PER_AU;
|
||||
const lightYears = absoluteKilometers / (KILOMETERS_PER_AU * 63_241.077);
|
||||
|
||||
if (lightYears >= 0.1) {
|
||||
return `${formatNumber(lightYears, 2)} ly`;
|
||||
}
|
||||
|
||||
if (astronomicalUnits >= 0.1) {
|
||||
return `${formatNumber(astronomicalUnits, astronomicalUnits >= 10 ? 1 : 3)} AU`;
|
||||
}
|
||||
|
||||
if (absoluteKilometers >= 1) {
|
||||
return `${formatNumber(absoluteKilometers, absoluteKilometers >= 100 ? 0 : 2)} km`;
|
||||
}
|
||||
|
||||
return `${formatNumber(meters, meters >= 100 ? 0 : 1)} m`;
|
||||
}
|
||||
|
||||
export function formatShipSpeed(ship: ShipSnapshot): string {
|
||||
const speed = Math.max(0, ship.travelSpeed);
|
||||
const unit = ship.travelSpeedUnit;
|
||||
if (unit === "ly/s") {
|
||||
return `${formatNumber(speed, 3)} ly/s`;
|
||||
}
|
||||
if (unit === "AU/s") {
|
||||
return `${formatNumber(speed, 4)} AU/s`;
|
||||
}
|
||||
if (speed >= 1000) {
|
||||
return `${formatNumber(speed / 1000, 2)} km/s`;
|
||||
}
|
||||
return `${formatNumber(speed, 0)} m/s`;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
@@ -71,6 +131,26 @@ export function toThreeVector(vector: Vector3Dto): THREE.Vector3 {
|
||||
return new THREE.Vector3(vector.x, vector.y, vector.z);
|
||||
}
|
||||
|
||||
export function scaleGalaxyScalar(lightYears: number): number {
|
||||
return lightYears * DISPLAY_UNITS_PER_LIGHT_YEAR;
|
||||
}
|
||||
|
||||
export function scaleLocalScalar(kilometers: number): number {
|
||||
return kilometers * DISPLAY_UNITS_PER_KILOMETER;
|
||||
}
|
||||
|
||||
export function scaleGalaxyVector(vector: THREE.Vector3): THREE.Vector3 {
|
||||
return vector.clone().multiplyScalar(DISPLAY_UNITS_PER_LIGHT_YEAR);
|
||||
}
|
||||
|
||||
export function toDisplayGalaxyVector(vector: Vector3Dto): THREE.Vector3 {
|
||||
return scaleGalaxyVector(toThreeVector(vector));
|
||||
}
|
||||
|
||||
export function scaleLocalVector(vector: THREE.Vector3): THREE.Vector3 {
|
||||
return vector.clone().multiplyScalar(DISPLAY_UNITS_PER_KILOMETER);
|
||||
}
|
||||
|
||||
export function currentWorldTimeSeconds(world: WorldState | undefined, worldTimeSyncMs: number): number {
|
||||
if (!world) {
|
||||
return 0;
|
||||
@@ -150,7 +230,7 @@ export function celestialRenderRadius(size: number, scale: number, minRadius: nu
|
||||
}
|
||||
|
||||
export function computeMoonRenderRadius(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
|
||||
return celestialRenderRadius(computeMoonSize(planet, moonIndex, seed), MOON_RENDER_SCALE, 2.5, 1.04);
|
||||
return celestialRenderRadius(computeMoonSize(planet, moonIndex, seed), 0.00011, 0.025, 0.62);
|
||||
}
|
||||
|
||||
export function starHaloOpacity(starKind: string): number {
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
ShipVisual,
|
||||
SystemVisual,
|
||||
WorldState,
|
||||
ZoomLevel,
|
||||
} from "./viewerTypes";
|
||||
|
||||
export interface ViewerNavigationContext {
|
||||
@@ -34,6 +35,7 @@ export interface ViewerNavigationContext {
|
||||
getCameraTargetShipId: () => string | undefined;
|
||||
setCameraTargetShipId: (value: string | undefined) => void;
|
||||
getCurrentDistance: () => number;
|
||||
getZoomLevel: () => ZoomLevel;
|
||||
getSelectedItems: () => Selectable[];
|
||||
getOrbitYaw: () => number;
|
||||
galaxyFocus: THREE.Vector3;
|
||||
@@ -149,7 +151,7 @@ export class ViewerNavigationController {
|
||||
}
|
||||
|
||||
updateSystemDetailVisibility() {
|
||||
updateSystemDetailVisibility(this.context.systemVisuals, this.context.getActiveSystemId());
|
||||
updateSystemDetailVisibility(this.context.systemVisuals, this.context.getActiveSystemId(), this.context.getZoomLevel());
|
||||
}
|
||||
|
||||
getCameraFocusWorldPosition() {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { formatInventory, formatVector, inventoryAmount } from "./viewerMath";
|
||||
import {
|
||||
formatInventory,
|
||||
formatLocalDistance,
|
||||
formatShipSpeed,
|
||||
formatSystemDistance,
|
||||
inventoryAmount,
|
||||
} from "./viewerMath";
|
||||
import { describeOrbitalParent, describeSelectable, describeShipCurrentAction, describeShipState, describeSpatialNodePathWithinSystem, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
|
||||
import type {
|
||||
CameraMode,
|
||||
@@ -127,7 +133,7 @@ export function updateDetailPanel(
|
||||
<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>Speed ${formatShipSpeed(ship)}</p>
|
||||
<p>Camera ${cameraMode === "follow" && cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}<br>Press C to toggle follow</p>
|
||||
`;
|
||||
return;
|
||||
@@ -234,7 +240,7 @@ export function updateDetailPanel(
|
||||
detailTitleEl.textContent = `Bubble ${bubble.id}`;
|
||||
detailBodyEl.innerHTML = `
|
||||
<p>${bubble.systemId}</p>
|
||||
<p>Anchor node ${bubble.nodeId}<br>Radius ${bubble.radius.toFixed(0)}</p>
|
||||
<p>Anchor node ${bubble.nodeId}<br>Radius ${formatLocalDistance(bubble.radius)}</p>
|
||||
<p>Ships ${bubble.occupantShipIds.length}<br>Stations ${bubble.occupantStationIds.length}</p>
|
||||
<p>Claims ${bubble.occupantClaimIds.length}<br>Construction sites ${bubble.occupantConstructionSiteIds.length}</p>
|
||||
`;
|
||||
@@ -285,7 +291,7 @@ export function updateDetailPanel(
|
||||
<p>${system.label}</p>
|
||||
<p>Parent ${parent}</p>
|
||||
<p>${planet.planetType} · ${planet.shape} · Moons ${planet.moonCount}</p>
|
||||
<p>Orbit ${planet.orbitRadius.toFixed(0)}<br>Speed ${planet.orbitSpeed.toFixed(3)}<br>Ecc ${planet.orbitEccentricity.toFixed(3)}<br>Inc ${planet.orbitInclination.toFixed(1)}°</p>
|
||||
<p>Orbit ${formatSystemDistance(planet.orbitRadius)}<br>Speed ${planet.orbitSpeed.toFixed(3)}<br>Ecc ${planet.orbitEccentricity.toFixed(3)}<br>Inc ${planet.orbitInclination.toFixed(1)}°</p>
|
||||
<p>Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°</p>
|
||||
`;
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as THREE from "three";
|
||||
import { ACTIVE_SYSTEM_DETAIL_SCALE, PROJECTED_GALAXY_RADIUS } from "./viewerConstants";
|
||||
import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds } from "./viewerMath";
|
||||
import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds, scaleLocalVector } from "./viewerMath";
|
||||
import { rawObject } from "./viewerScenePrimitives";
|
||||
import type { PlanetVisual, ShipVisual, SystemSummaryVisual, SystemVisual, WorldState } from "./viewerTypes";
|
||||
|
||||
@@ -33,7 +33,7 @@ export function updatePlanetPresentation(
|
||||
const nowSeconds = currentWorldTimeSeconds(world, worldTimeSyncMs);
|
||||
for (const visual of planetVisuals) {
|
||||
const scale = visual.systemId === activeSystemId ? ACTIVE_SYSTEM_DETAIL_SCALE : 1;
|
||||
const localPosition = computePlanetLocalPosition(visual.planet, nowSeconds);
|
||||
const localPosition = scaleLocalVector(computePlanetLocalPosition(visual.planet, nowSeconds));
|
||||
const orbitOffset = visual.systemId === activeSystemId
|
||||
? systemFocusLocal.clone().multiplyScalar(-scale)
|
||||
: new THREE.Vector3();
|
||||
@@ -54,7 +54,7 @@ export function updatePlanetPresentation(
|
||||
moon.orbit.setScaleScalar(scale);
|
||||
moon.mesh.setPosition(
|
||||
position.clone().add(
|
||||
computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds, world?.seed ?? 1).multiplyScalar(scale),
|
||||
scaleLocalVector(computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds, world?.seed ?? 1)).multiplyScalar(scale),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as THREE from "three";
|
||||
import { computeZoomBlend } from "./viewerMath";
|
||||
import {
|
||||
updateNetworkPanel as renderNetworkPanel,
|
||||
recordPerformanceStats,
|
||||
@@ -63,37 +62,32 @@ export class ViewerPresentationController {
|
||||
|
||||
applyZoomPresentation() {
|
||||
const activeSystemId = this.context.getActiveSystemId();
|
||||
const blend = computeZoomBlend(this.context.getCurrentDistance());
|
||||
const zoomLevel = this.context.getZoomLevel();
|
||||
const isUniverse = zoomLevel === "universe";
|
||||
|
||||
for (const entry of this.context.presentationEntries) {
|
||||
const systemId = entry.systemId;
|
||||
const isActiveDetail = !systemId || systemId === activeSystemId;
|
||||
const isProjectedSystemIcon = !!activeSystemId
|
||||
&& !!systemId
|
||||
&& systemId !== activeSystemId
|
||||
&& this.context.systemVisuals.get(systemId)?.icon === entry.icon;
|
||||
const detailAlpha = entry.hideDetailInUniverse
|
||||
? Math.max(blend.localWeight, blend.systemWeight) * (isActiveDetail ? 1 : 0)
|
||||
? (!isUniverse && isActiveDetail ? 1 : 0)
|
||||
: 1;
|
||||
const iconAlpha = isProjectedSystemIcon
|
||||
? 0
|
||||
: entry.hideIconInUniverse
|
||||
? blend.systemWeight * (isActiveDetail ? 1 : 0)
|
||||
: Math.max(blend.systemWeight, blend.universeWeight);
|
||||
const iconAlpha = entry.hideIconInUniverse
|
||||
? (isUniverse ? 1 : 0)
|
||||
: (isUniverse ? 1 : 0);
|
||||
|
||||
entry.detail.setOpacity(detailAlpha);
|
||||
entry.icon.setOpacity(iconAlpha);
|
||||
}
|
||||
|
||||
for (const orbitLine of this.context.orbitLines) {
|
||||
const alpha = this.resolveOrbitLineOpacity(orbitLine, blend, activeSystemId);
|
||||
const alpha = this.resolveOrbitLineOpacity(orbitLine, zoomLevel, activeSystemId);
|
||||
orbitLine.line.setOpacity(alpha);
|
||||
}
|
||||
|
||||
for (const [systemId, summaryVisual] of this.context.systemSummaryVisuals.entries()) {
|
||||
const summaryOpacity = systemId === activeSystemId
|
||||
? 0
|
||||
: (activeSystemId ? 0.72 : 0.96);
|
||||
const summaryOpacity = isUniverse
|
||||
? 0.96
|
||||
: 0;
|
||||
summaryVisual.sprite.setOpacity(summaryOpacity);
|
||||
}
|
||||
|
||||
@@ -172,14 +166,14 @@ export class ViewerPresentationController {
|
||||
return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top);
|
||||
}
|
||||
|
||||
private resolveOrbitLineOpacity(orbitLine: OrbitLineVisual, blend: ReturnType<typeof computeZoomBlend>, activeSystemId?: string) {
|
||||
if (!activeSystemId || orbitLine.systemId !== activeSystemId) {
|
||||
private resolveOrbitLineOpacity(orbitLine: OrbitLineVisual, zoomLevel: "local" | "system" | "universe", activeSystemId?: string) {
|
||||
if (zoomLevel === "universe" || !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);
|
||||
const baseAlpha = zoomLevel === "local" ? 0.55 : 0.9;
|
||||
|
||||
if (selectedItem?.kind === "planet" && selectedItem.systemId === activeSystemId) {
|
||||
return orbitLine.kind === "moon" && orbitLine.planetIndex === selectedItem.planetIndex
|
||||
|
||||
@@ -148,6 +148,7 @@ export class ViewerSceneDataController {
|
||||
createWorldPresentationContext(overrides: {
|
||||
world: any;
|
||||
activeSystemId?: string;
|
||||
zoomLevel: any;
|
||||
orbitYaw: number;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
systemFocusLocal: THREE.Vector3;
|
||||
@@ -160,6 +161,7 @@ export class ViewerSceneDataController {
|
||||
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
|
||||
worldSeed: this.context.getWorldSeed(),
|
||||
activeSystemId: overrides.activeSystemId,
|
||||
zoomLevel: overrides.zoomLevel,
|
||||
orbitYaw: overrides.orbitYaw,
|
||||
camera: overrides.camera,
|
||||
systemFocusLocal: overrides.systemFocusLocal,
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
computeMoonOrbitRadius,
|
||||
computeMoonRenderRadius,
|
||||
computePlanetLocalPosition,
|
||||
scaleLocalScalar,
|
||||
scaleLocalVector,
|
||||
starHaloOpacity,
|
||||
toThreeVector,
|
||||
} from "./viewerMath";
|
||||
@@ -100,7 +102,7 @@ export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): Scen
|
||||
|
||||
export function createStarCluster(system: SystemSnapshot): SceneNode {
|
||||
const root = new THREE.Group();
|
||||
const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02);
|
||||
const renderedStarSize = celestialRenderRadius(system.starSize, 0.00018, 0.16, 0.62);
|
||||
const offsets = system.starCount > 1
|
||||
? [new THREE.Vector3(-renderedStarSize * 0.55, 0, 0), new THREE.Vector3(renderedStarSize * 0.75, renderedStarSize * 0.08, 0)]
|
||||
: [new THREE.Vector3(0, 0, 0)];
|
||||
@@ -131,7 +133,7 @@ export function createStarCluster(system: SystemSnapshot): SceneNode {
|
||||
export function createPlanetOrbit(planet: PlanetSnapshot): SceneNode {
|
||||
const points = Array.from({ length: 120 }, (_, index) => {
|
||||
const phaseDegrees = (index / 120) * 360;
|
||||
return computePlanetLocalPosition(planet, 0, phaseDegrees);
|
||||
return scaleLocalVector(computePlanetLocalPosition(planet, 0, phaseDegrees));
|
||||
});
|
||||
|
||||
return createSceneNode(new THREE.LineLoop(
|
||||
@@ -141,7 +143,7 @@ export function createPlanetOrbit(planet: PlanetSnapshot): SceneNode {
|
||||
}
|
||||
|
||||
export function createPlanetRing(planet: PlanetSnapshot): SceneNode {
|
||||
const renderedPlanetRadius = celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06);
|
||||
const renderedPlanetRadius = celestialRenderRadius(planet.size, 0.00012, 0.03, 0.62);
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.RingGeometry(renderedPlanetRadius * 1.35, renderedPlanetRadius * 2.15, 48),
|
||||
new THREE.MeshBasicMaterial({
|
||||
@@ -161,7 +163,7 @@ export function createMoonVisuals(planet: PlanetSnapshot, seed: number): MoonVis
|
||||
const moons: MoonVisual[] = [];
|
||||
|
||||
for (let moonIndex = 0; moonIndex < moonCount; moonIndex += 1) {
|
||||
const orbitRadius = computeMoonOrbitRadius(planet, moonIndex, seed);
|
||||
const orbitRadius = scaleLocalScalar(computeMoonOrbitRadius(planet, moonIndex, seed));
|
||||
const orbit = new THREE.LineLoop(
|
||||
new THREE.BufferGeometry().setFromPoints(
|
||||
Array.from({ length: 48 }, (_, index) => {
|
||||
|
||||
@@ -38,6 +38,9 @@ import type {
|
||||
import {
|
||||
celestialRenderRadius,
|
||||
computePlanetLocalPosition,
|
||||
scaleLocalScalar,
|
||||
scaleLocalVector,
|
||||
toDisplayGalaxyVector,
|
||||
toThreeVector,
|
||||
} from "./viewerMath";
|
||||
import { getAnimatedShipLocalPosition } from "./viewerPresentation";
|
||||
@@ -135,16 +138,16 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
|
||||
|
||||
for (const system of systems) {
|
||||
const root = createSceneNode(new THREE.Group());
|
||||
root.setPosition(new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y, system.galaxyPosition.z));
|
||||
root.setPosition(toDisplayGalaxyVector(system.galaxyPosition));
|
||||
const detailGroup = createSceneNode(new THREE.Group());
|
||||
const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02);
|
||||
const renderedStarSize = celestialRenderRadius(system.starSize, 0.00018, 0.16, 0.62);
|
||||
|
||||
const starCluster = createStarCluster(system);
|
||||
const systemIcon = createTacticalIcon(context.documentRef, system.starColor, 96);
|
||||
const shellReticle = createShellReticle(context.documentRef, "#ff3b30", 400);
|
||||
const summaryVisual = createSystemSummaryVisual(
|
||||
context.documentRef,
|
||||
new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y + renderedStarSize + 140, system.galaxyPosition.z),
|
||||
toDisplayGalaxyVector(system.galaxyPosition).add(new THREE.Vector3(0, renderedStarSize + 140, 0)),
|
||||
);
|
||||
summaryVisual.sprite.setPosition(new THREE.Vector3(0, renderedStarSize + 110, 0));
|
||||
root.add(starCluster, systemIcon, shellReticle, summaryVisual.sprite, detailGroup);
|
||||
@@ -157,7 +160,7 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
|
||||
shellReticleBaseScale: 400,
|
||||
detailGroup,
|
||||
summary: summaryVisual,
|
||||
galaxyPosition: toThreeVector(system.galaxyPosition),
|
||||
galaxyPosition: toDisplayGalaxyVector(system.galaxyPosition),
|
||||
});
|
||||
context.systemSummaryVisuals.set(system.id, summaryVisual);
|
||||
registerSelectableDescendants(context.selectableTargets, starCluster, { kind: "system", id: system.id }, (child) => child instanceof THREE.Mesh);
|
||||
@@ -166,7 +169,7 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
|
||||
|
||||
for (const [planetIndex, planet] of system.planets.entries()) {
|
||||
const orbit = createPlanetOrbit(planet);
|
||||
const renderedPlanetRadius = celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06);
|
||||
const renderedPlanetRadius = celestialRenderRadius(planet.size, 0.00012, 0.03, 0.62);
|
||||
const planetMesh = createSceneNode(new THREE.Mesh(
|
||||
new THREE.SphereGeometry(renderedPlanetRadius, 18, 18),
|
||||
new THREE.MeshStandardMaterial({
|
||||
@@ -176,7 +179,7 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
|
||||
emissive: new THREE.Color(planet.color).multiplyScalar(0.04),
|
||||
}),
|
||||
));
|
||||
planetMesh.setPosition(computePlanetLocalPosition(planet, worldTimeSeconds));
|
||||
planetMesh.setPosition(scaleLocalVector(computePlanetLocalPosition(planet, worldTimeSeconds)));
|
||||
const planetIcon = createTacticalIcon(context.documentRef, planet.color, Math.max(24, renderedPlanetRadius * 2));
|
||||
planetIcon.setPosition(rawObject(planetMesh).position.clone());
|
||||
const ring = planet.hasRing ? createPlanetRing(planet) : undefined;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
SelectionGroup,
|
||||
WorldState,
|
||||
} from "./viewerTypes";
|
||||
import { formatGalaxyDistance } from "./viewerMath";
|
||||
|
||||
export function describeSelectable(world: WorldState | undefined, item: Selectable): string {
|
||||
if (!world) {
|
||||
@@ -38,6 +39,83 @@ export function describeSelectable(world: WorldState | undefined, item: Selectab
|
||||
return world.systems.get(item.id)?.label ?? item.id;
|
||||
}
|
||||
|
||||
export function describeHoverLabel(world: WorldState | undefined, item: Selectable): string | undefined {
|
||||
if (!world) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (item.kind === "ship") {
|
||||
return world.ships.get(item.id)?.label ?? item.id;
|
||||
}
|
||||
|
||||
if (item.kind === "station") {
|
||||
return world.stations.get(item.id)?.label ?? item.id;
|
||||
}
|
||||
|
||||
if (item.kind === "system") {
|
||||
return world.systems.get(item.id)?.label ?? item.id;
|
||||
}
|
||||
|
||||
if (item.kind === "planet") {
|
||||
const system = world.systems.get(item.systemId);
|
||||
const planet = system?.planets[item.planetIndex];
|
||||
return planet ? `${system?.label ?? item.systemId} / ${planet.label}` : `${item.systemId} / planet ${item.planetIndex + 1}`;
|
||||
}
|
||||
|
||||
if (item.kind === "node") {
|
||||
const node = world.nodes.get(item.id);
|
||||
if (!node) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
const anchorPath = node.anchorNodeId
|
||||
? describeSpatialNodePathWithinSystem(world, node.systemId, node.anchorNodeId)
|
||||
: undefined;
|
||||
return anchorPath ? `${anchorPath} / ${node.itemId}` : `${node.systemId} / ${node.itemId}`;
|
||||
}
|
||||
|
||||
if (item.kind === "spatial-node") {
|
||||
const node = world.spatialNodes.get(item.id);
|
||||
if (!node) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
if (node.kind === "star") {
|
||||
const system = world.systems.get(node.systemId);
|
||||
return system ? `${system.label} star` : `${node.systemId} star`;
|
||||
}
|
||||
|
||||
return describeSpatialNodePathWithinSystem(world, node.systemId, node.id) ?? `${node.systemId} / ${node.kind}`;
|
||||
}
|
||||
|
||||
if (item.kind === "bubble") {
|
||||
const bubble = world.localBubbles.get(item.id);
|
||||
const anchorPath = bubble?.nodeId
|
||||
? describeSpatialNodePathWithinSystem(world, bubble.systemId, bubble.nodeId)
|
||||
: undefined;
|
||||
return anchorPath ? `${anchorPath} bubble` : `Bubble ${item.id}`;
|
||||
}
|
||||
|
||||
if (item.kind === "claim") {
|
||||
const claim = world.claims.get(item.id);
|
||||
const anchorPath = claim?.nodeId
|
||||
? describeSpatialNodePathWithinSystem(world, claim.systemId, claim.nodeId)
|
||||
: undefined;
|
||||
return anchorPath ? `${anchorPath} claim` : `Claim ${item.id}`;
|
||||
}
|
||||
|
||||
if (item.kind === "construction-site") {
|
||||
const site = world.constructionSites.get(item.id);
|
||||
const anchorPath = site?.nodeId
|
||||
? describeSpatialNodePathWithinSystem(world, site.systemId, site.nodeId)
|
||||
: undefined;
|
||||
const siteLabel = site ? (site.blueprintId ?? site.targetDefinitionId) : item.id;
|
||||
return anchorPath ? `${anchorPath} / ${siteLabel}` : `Construction ${siteLabel}`;
|
||||
}
|
||||
|
||||
return describeSelectable(world, item);
|
||||
}
|
||||
|
||||
export function getSelectionGroup(item: Selectable): SelectionGroup {
|
||||
if (item.kind === "ship") {
|
||||
return "ships";
|
||||
@@ -209,7 +287,7 @@ export function renderSystemDetails(
|
||||
<p>Planets ${system.planets.length}<br>Moons ${moonCount}<br>Ships ${shipCount}<br>Stations ${stationCount}</p>
|
||||
<p>Spatial nodes ${spatialNodeCount}<br>Resource nodes ${nodeCount}<br>Bubbles ${bubbleCount}</p>
|
||||
<p>Claims ${claimCount}<br>Construction sites ${constructionCount}</p>
|
||||
<p>Height ${system.galaxyPosition.y.toFixed(0)}</p>
|
||||
<p>Height ${formatGalaxyDistance(system.galaxyPosition.y)}</p>
|
||||
<p>${system.planets.slice(0, 8).map((planet) => `${planet.label} (${planet.planetType})`).join("<br>")}</p>
|
||||
${followText}
|
||||
`;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
computePlanetLocalPosition,
|
||||
currentWorldTimeSeconds,
|
||||
resolveOrbitalAnchorPosition,
|
||||
toDisplayGalaxyVector,
|
||||
toThreeVector,
|
||||
} from "./viewerMath";
|
||||
import { describeActiveSpace } from "./viewerSelection";
|
||||
@@ -20,6 +21,7 @@ import type {
|
||||
LocalBubbleSnapshot,
|
||||
ResourceNodeDelta,
|
||||
ResourceNodeSnapshot,
|
||||
ShipSnapshot,
|
||||
} from "./contracts";
|
||||
import type {
|
||||
BubbleVisual,
|
||||
@@ -52,6 +54,7 @@ export interface WorldOrbitalContext {
|
||||
|
||||
export interface WorldPresentationContext extends WorldOrbitalContext {
|
||||
activeSystemId?: string;
|
||||
zoomLevel: ZoomLevel;
|
||||
orbitYaw: number;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
systemFocusLocal: THREE.Vector3;
|
||||
@@ -79,12 +82,19 @@ export interface GameStatusParams {
|
||||
export function updateWorldPresentation(context: WorldPresentationContext) {
|
||||
const now = performance.now();
|
||||
const worldTimeSeconds = currentWorldTimeSeconds(context.world, context.worldTimeSyncMs);
|
||||
const renderMode = resolveRenderSpaceMode(context.activeSystemId, context.zoomLevel);
|
||||
|
||||
for (const [shipId, visual] of context.shipVisuals.entries()) {
|
||||
const ship = context.world?.ships.get(shipId);
|
||||
if (!ship) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const visual of context.shipVisuals.values()) {
|
||||
const worldPosition = getAnimatedShipLocalPosition(visual, now);
|
||||
visual.mesh.setPosition(context.toDisplayLocalPosition(worldPosition, visual.systemId));
|
||||
const displayPosition = resolveShipWorldPosition(context, ship, visual, worldPosition);
|
||||
visual.mesh.setPosition(displayPosition);
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
const shipVisible = visual.systemId === context.activeSystemId;
|
||||
const shipVisible = isShipVisible(renderMode, context.activeSystemId, ship);
|
||||
visual.mesh.setVisible(shipVisible);
|
||||
visual.icon.setVisible(shipVisible && rawObject(visual.icon).visible);
|
||||
const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw);
|
||||
@@ -148,6 +158,49 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
|
||||
updateSystemSummaryPresentation(context.systemSummaryVisuals, context.camera, context.activeSystemId);
|
||||
}
|
||||
|
||||
type RenderSpaceMode = "galaxy" | "system" | "local";
|
||||
|
||||
function resolveRenderSpaceMode(activeSystemId: string | undefined, zoomLevel: ZoomLevel): RenderSpaceMode {
|
||||
if (!activeSystemId || zoomLevel === "universe") {
|
||||
return "galaxy";
|
||||
}
|
||||
|
||||
return zoomLevel === "local" ? "local" : "system";
|
||||
}
|
||||
|
||||
function isShipVisible(mode: RenderSpaceMode, activeSystemId: string | undefined, ship: ShipSnapshot) {
|
||||
if (ship.spatialState.movementRegime === "ftl-transit") {
|
||||
return mode === "galaxy";
|
||||
}
|
||||
|
||||
if (!activeSystemId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ship.systemId === activeSystemId;
|
||||
}
|
||||
|
||||
export function resolveShipWorldPosition(
|
||||
context: Pick<WorldPresentationContext, "world" | "toDisplayLocalPosition">,
|
||||
ship: ShipSnapshot,
|
||||
visual: ShipVisual,
|
||||
animatedLocalPosition = getAnimatedShipLocalPosition(visual),
|
||||
) {
|
||||
if (ship.spatialState.movementRegime === "ftl-transit") {
|
||||
const destinationNodeId = ship.spatialState.transit?.destinationNodeId;
|
||||
const destinationNode = destinationNodeId ? context.world?.spatialNodes.get(destinationNodeId) : undefined;
|
||||
const originSystem = context.world?.systems.get(ship.systemId);
|
||||
const destinationSystem = destinationNode ? context.world?.systems.get(destinationNode.systemId) : undefined;
|
||||
if (originSystem && destinationSystem) {
|
||||
const origin = toDisplayGalaxyVector(originSystem.galaxyPosition);
|
||||
const destination = toDisplayGalaxyVector(destinationSystem.galaxyPosition);
|
||||
return origin.lerp(destination, THREE.MathUtils.clamp(ship.spatialState.transit?.progress ?? 0, 0, 1));
|
||||
}
|
||||
}
|
||||
|
||||
return context.toDisplayLocalPosition(animatedLocalPosition, ship.systemId);
|
||||
}
|
||||
|
||||
export function updateSystemSummaries(world: WorldState | undefined, systemSummaryVisuals: Map<string, SystemSummaryVisual>) {
|
||||
if (!world) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user