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>
196 lines
6.0 KiB
TypeScript
196 lines
6.0 KiB
TypeScript
import * as THREE from "three";
|
|
import { describeHoverLabel, getSelectionGroup } from "./viewerSelection";
|
|
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
|
|
import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, formatAdaptiveDistanceFromKilometers, formatSystemDistance } from "./viewerMath";
|
|
import type { Selectable, SelectionGroup, WorldState, ZoomLevel } from "./viewerTypes";
|
|
|
|
export interface HoverPickResult {
|
|
selection: Selectable;
|
|
object: THREE.Object3D;
|
|
}
|
|
|
|
export function pickSelectableAtClientPosition(
|
|
renderer: THREE.WebGLRenderer,
|
|
raycaster: THREE.Raycaster,
|
|
mouse: THREE.Vector2,
|
|
camera: THREE.Camera,
|
|
selectableTargets: Map<THREE.Object3D, Selectable>,
|
|
clientX: number,
|
|
clientY: number,
|
|
) {
|
|
const hit = pickSelectableHitAtClientPosition(renderer, raycaster, mouse, camera, selectableTargets, clientX, clientY);
|
|
return hit?.selection;
|
|
}
|
|
|
|
export function pickSelectableHitAtClientPosition(
|
|
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];
|
|
const selection = hit ? selectableTargets.get(hit.object) : undefined;
|
|
return hit && selection
|
|
? { selection, object: hit.object }
|
|
: undefined;
|
|
}
|
|
|
|
export function updateHoverLabel(params: {
|
|
dragMode?: string;
|
|
hoverLabelEl: HTMLDivElement;
|
|
hoverPick: HoverPickResult | undefined;
|
|
activeSystemId?: string;
|
|
zoomLevel: ZoomLevel;
|
|
world?: WorldState;
|
|
point: THREE.Vector2;
|
|
camera: THREE.Camera;
|
|
}) {
|
|
const {
|
|
dragMode,
|
|
hoverLabelEl,
|
|
hoverPick,
|
|
activeSystemId,
|
|
zoomLevel,
|
|
world,
|
|
point,
|
|
camera,
|
|
} = params;
|
|
|
|
if (dragMode) {
|
|
hoverLabelEl.hidden = true;
|
|
return;
|
|
}
|
|
|
|
if (!hoverPick) {
|
|
hoverLabelEl.hidden = true;
|
|
return;
|
|
}
|
|
|
|
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 = `${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,
|
|
dragLast: THREE.Vector2,
|
|
) {
|
|
const minX = Math.min(dragStart.x, dragLast.x);
|
|
const minY = Math.min(dragStart.y, dragLast.y);
|
|
const maxX = Math.max(dragStart.x, dragLast.x);
|
|
const maxY = Math.max(dragStart.y, dragLast.y);
|
|
marqueeEl.style.left = `${minX}px`;
|
|
marqueeEl.style.top = `${minY}px`;
|
|
marqueeEl.style.width = `${maxX - minX}px`;
|
|
marqueeEl.style.height = `${maxY - minY}px`;
|
|
}
|
|
|
|
export function hideMarqueeBox(marqueeEl: HTMLDivElement) {
|
|
marqueeEl.style.display = "none";
|
|
marqueeEl.style.width = "0";
|
|
marqueeEl.style.height = "0";
|
|
}
|
|
|
|
export function completeMarqueeSelection(params: {
|
|
renderer: THREE.WebGLRenderer;
|
|
camera: THREE.Camera;
|
|
dragStart: THREE.Vector2;
|
|
dragLast: THREE.Vector2;
|
|
selectableTargets: Map<THREE.Object3D, Selectable>;
|
|
}) {
|
|
const {
|
|
renderer,
|
|
camera,
|
|
dragStart,
|
|
dragLast,
|
|
selectableTargets,
|
|
} = params;
|
|
|
|
const bounds = renderer.domElement.getBoundingClientRect();
|
|
const minX = Math.min(dragStart.x, dragLast.x);
|
|
const minY = Math.min(dragStart.y, dragLast.y);
|
|
const maxX = Math.max(dragStart.x, dragLast.x);
|
|
const maxY = Math.max(dragStart.y, dragLast.y);
|
|
const grouped = new Map<SelectionGroup, Selectable[]>();
|
|
|
|
for (const [object, selectable] of selectableTargets.entries()) {
|
|
if (object instanceof THREE.Sprite && !object.visible) {
|
|
continue;
|
|
}
|
|
if (!object.visible) {
|
|
continue;
|
|
}
|
|
|
|
const worldPosition = new THREE.Vector3();
|
|
object.getWorldPosition(worldPosition);
|
|
worldPosition.project(camera);
|
|
const screenX = ((worldPosition.x + 1) * 0.5) * bounds.width;
|
|
const screenY = ((1 - worldPosition.y) * 0.5) * bounds.height;
|
|
if (screenX < minX || screenX > maxX || screenY < minY || screenY > maxY) {
|
|
continue;
|
|
}
|
|
|
|
const group = getSelectionGroup(selectable);
|
|
const list = grouped.get(group) ?? [];
|
|
if (!list.some((entry) => JSON.stringify(entry) === JSON.stringify(selectable))) {
|
|
list.push(selectable);
|
|
}
|
|
grouped.set(group, list);
|
|
}
|
|
|
|
return [...grouped.entries()]
|
|
.sort((left, right) => right[1].length - left[1].length)[0]?.[1] ?? [];
|
|
}
|