feat: improved visualisation and x4 data import

This commit is contained in:
2026-03-18 20:58:17 -04:00
parent 358122a74a
commit f98c47a8a7
45 changed files with 32840 additions and 1482 deletions

View File

@@ -28,19 +28,12 @@ import { SystemLayer } from "./viewerSystemLayer";
import { LocalLayer } from "./viewerLocalLayer";
import type { FactionSnapshot } from "./contracts";
import type {
CelestialVisual,
CameraMode,
ClaimVisual,
ConstructionSiteVisual,
DragMode,
HistoryWindowState,
NetworkStats,
NodeVisual,
OrbitLineVisual,
PerformanceStats,
Selectable,
ShipVisual,
StructureVisual,
SystemVisual,
WorldState,
PovLevel,
@@ -51,10 +44,10 @@ export class ViewerAppController {
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
// ── Three independent rendering layers ───────────────────────────────────
private readonly universeLayer = new UniverseLayer();
private readonly galaxyLayer = new GalaxyLayer();
private readonly systemLayer = new SystemLayer();
private readonly localLayer = new LocalLayer();
readonly universeLayer = new UniverseLayer();
readonly galaxyLayer = new GalaxyLayer();
readonly systemLayer = new SystemLayer();
readonly localLayer = new LocalLayer();
private readonly clock = new THREE.Clock();
private readonly raycaster = new THREE.Raycaster();
@@ -70,16 +63,6 @@ export class ViewerAppController {
private readonly gamePanelEl: HTMLDivElement;
private readonly celestialVisuals = new Map<string, CelestialVisual>();
private readonly stationVisuals = new Map<string, StructureVisual>();
private readonly claimVisuals = new Map<string, ClaimVisual>();
private readonly constructionSiteVisuals = new Map<string, ConstructionSiteVisual>();
private readonly shipVisuals = new Map<string, ShipVisual>();
private readonly systemVisuals = new Map<string, SystemVisual>();
private readonly nodeVisuals = new Map<string, NodeVisual>();
private readonly planetVisuals: any[] = [];
private readonly orbitLines: OrbitLineVisual[] = [];
private readonly statusEl: HTMLDivElement;
private readonly gameSummaryEl: HTMLSpanElement;
private readonly systemPanelEl: HTMLDivElement;
@@ -98,6 +81,7 @@ export class ViewerAppController {
private readonly historyLayerEl: HTMLDivElement;
private readonly marqueeEl: HTMLDivElement;
private readonly hoverLabelEl: HTMLDivElement;
private readonly hoverConnectorLineEl: SVGLineElement;
private world?: WorldState;
private worldTimeSyncMs = performance.now();
@@ -165,6 +149,7 @@ export class ViewerAppController {
this.historyLayerEl = hud.historyLayerEl;
this.marqueeEl = hud.marqueeEl;
this.hoverLabelEl = hud.hoverLabelEl;
this.hoverConnectorLineEl = hud.hoverConnectorLineEl;
({
sceneDataController: this.sceneDataController,
navigationController: this.navigationController,
@@ -231,13 +216,10 @@ export class ViewerAppController {
renderFrame({
clock: this.clock,
renderer: this.renderer,
universeScene: this.universeLayer.scene,
galaxyScene: this.galaxyLayer.scene,
galaxyCamera: this.galaxyLayer.camera,
systemScene: this.systemLayer.scene,
systemCamera: this.systemLayer.camera,
localScene: this.localLayer.scene,
localCamera: this.localLayer.camera,
universeLayer: this.universeLayer,
galaxyLayer: this.galaxyLayer,
systemLayer: this.systemLayer,
localLayer: this.localLayer,
getPovLevel: () => this.povLevel,
updateCamera: (delta) => this.updateCamera(delta),
updateAmbience: (delta) => this.presentationController.updateAmbience(delta),
@@ -294,7 +276,7 @@ export class ViewerAppController {
// Update star dot scales in galaxy scene
updateSystemStarPresentation(
this.systemVisuals,
this.galaxyLayer.systemVisuals,
this.activeSystemId,
this.galaxyLayer.camera,
(sprite, opacity) => this.setShellReticleOpacity(sprite, opacity),
@@ -343,9 +325,9 @@ export class ViewerAppController {
private onResize = () => {
resizeViewer({
renderer: this.renderer,
galaxyCamera: this.galaxyLayer.camera,
systemCamera: this.systemLayer.camera,
localCamera: this.localLayer.camera,
galaxyLayer: this.galaxyLayer,
systemLayer: this.systemLayer,
localLayer: this.localLayer,
});
};
@@ -354,7 +336,7 @@ export class ViewerAppController {
}
private describeSelectionParent(selection: Selectable) {
return describeSelectionParent(this.world, selection, this.stationVisuals, this.nodeVisuals);
return describeSelectionParent(this.world, selection, this.systemLayer.stationVisuals, this.systemLayer.nodeVisuals);
}
private toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) {

View File

@@ -7,6 +7,8 @@ export type {
OrbitalSimulationSnapshot,
} from "./contractsWorld";
export type {
StarSnapshot,
MoonSnapshot,
SystemSnapshot,
PlanetSnapshot,
ResourceNodeSnapshot,

View File

@@ -1,13 +1,31 @@
import type { Vector3Dto } from "./contractsCommon";
export interface StarSnapshot {
kind: string;
color: string;
glow: string;
size: number;
orbitRadius: number;
orbitSpeed: number;
orbitPhaseAtEpoch: number;
}
export interface MoonSnapshot {
label: string;
size: number;
color: string;
orbitRadius: number;
orbitSpeed: number;
orbitPhaseAtEpoch: number;
orbitInclination: number;
orbitLongitudeOfAscendingNode: number;
}
export interface SystemSnapshot {
id: string;
label: string;
galaxyPosition: Vector3Dto;
starKind: string;
starCount: number;
starColor: string;
starSize: number;
stars: StarSnapshot[];
planets: PlanetSnapshot[];
}
@@ -15,7 +33,7 @@ export interface PlanetSnapshot {
label: string;
planetType: string;
shape: string;
moonCount: number;
moons: MoonSnapshot[];
orbitRadius: number;
orbitSpeed: number;
orbitEccentricity: number;

View File

@@ -65,6 +65,25 @@ canvas {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
}
.hover-connector-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: visible;
}
.hover-connector-line {
stroke: rgba(255, 88, 72, 0.45);
stroke-width: 1.5;
stroke-dasharray: 4 3;
}
.hover-connector-line[hidden] {
display: none;
}
.hover-label {
position: absolute;
padding: 8px 10px;

View File

@@ -215,6 +215,12 @@ export function resolveSelectionPosition(params: ResolveSelectionPositionParams)
return computePlanetLocalPosition(planet, currentWorldTimeSeconds(world, worldTimeSyncMs));
}
if (selection.kind === "moon") {
const system = world.systems.get(selection.systemId);
const planet = system?.planets[selection.planetIndex];
return planet ? computePlanetLocalPosition(planet, currentWorldTimeSeconds(world, worldTimeSyncMs)) : undefined;
}
const system = world.systems.get(selection.id);
return system ? scaleGalaxyVector(toThreeVector(system.galaxyPosition)) : undefined;
}

View File

@@ -6,6 +6,10 @@ export const NAV_DISTANCE: Record<PovLevel, number> = {
galaxy: 32000,
};
// Close-orbit distance when double-clicking a planet (display units).
// 0.005 units = ~333 km from planet center in system space.
export const NAV_DISTANCE_PLANET_ORBIT = 0.005;
export const ACTIVE_SYSTEM_DETAIL_SCALE = 10;
export const GALAXY_PARALLAX_FACTOR = 0.025;
export const ACTIVE_SYSTEM_CAPTURE_RADIUS = 9000;
@@ -13,7 +17,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 = 2;
// 0.002 units = ~133 km — allows scrolling into low orbit around planets.
export const MIN_CAMERA_DISTANCE = 0.002;
export const MAX_CAMERA_DISTANCE = 150000;
export interface ZoomBlend {

View File

@@ -25,15 +25,14 @@ export function createViewerControllers(host: any) {
shipGroup: host.systemLayer.shipGroup,
galaxySelectableTargets: host.galaxyLayer.selectableTargets,
systemSelectableTargets: host.systemLayer.selectableTargets,
systemVisuals: host.systemVisuals,
planetVisuals: host.planetVisuals,
orbitLines: host.orbitLines,
celestialVisuals: host.celestialVisuals,
nodeVisuals: host.nodeVisuals,
stationVisuals: host.stationVisuals,
claimVisuals: host.claimVisuals,
constructionSiteVisuals: host.constructionSiteVisuals,
shipVisuals: host.shipVisuals,
systemVisuals: host.galaxyLayer.systemVisuals,
planetVisuals: host.systemLayer.planetVisuals,
celestialVisuals: host.systemLayer.celestialVisuals,
nodeVisuals: host.systemLayer.nodeVisuals,
stationVisuals: host.systemLayer.stationVisuals,
claimVisuals: host.systemLayer.claimVisuals,
constructionSiteVisuals: host.systemLayer.constructionSiteVisuals,
shipVisuals: host.systemLayer.shipVisuals,
});
const navigationController = new ViewerNavigationController({
@@ -62,10 +61,10 @@ export function createViewerControllers(host: any) {
systemAnchor: host.systemAnchor,
galaxyCamera: host.galaxyLayer.camera,
systemCamera: host.systemLayer.camera,
shipVisuals: host.shipVisuals,
nodeVisuals: host.nodeVisuals,
planetVisuals: host.planetVisuals,
systemVisuals: host.systemVisuals,
shipVisuals: host.systemLayer.shipVisuals,
nodeVisuals: host.systemLayer.nodeVisuals,
planetVisuals: host.systemLayer.planetVisuals,
systemVisuals: host.galaxyLayer.systemVisuals,
followCameraPosition: host.followCameraPosition,
followCameraFocus: host.followCameraFocus,
followCameraDirection: host.followCameraDirection,
@@ -103,9 +102,8 @@ export function createViewerControllers(host: any) {
getSelectedItems: () => host.selectedItems,
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
getCurrentDistance: () => host.currentDistance,
planetVisuals: host.planetVisuals,
orbitLines: host.orbitLines,
systemVisuals: host.systemVisuals,
planetVisuals: host.systemLayer.planetVisuals,
systemVisuals: host.galaxyLayer.systemVisuals,
createWorldPresentationContext: () => host.createWorldPresentationContext(),
});
@@ -198,6 +196,7 @@ export function createViewerControllers(host: any) {
galaxySelectableTargets: host.galaxyLayer.selectableTargets,
systemSelectableTargets: host.systemLayer.selectableTargets,
hoverLabelEl: host.hoverLabelEl,
hoverConnectorLineEl: host.hoverConnectorLineEl,
marqueeEl: host.marqueeEl,
keyState: host.keyState,
getWorld: () => host.world,

View File

@@ -1,5 +1,5 @@
import * as THREE from "three";
import type { Selectable } from "./viewerTypes";
import type { Selectable, SystemVisual } from "./viewerTypes";
/**
* Galaxy rendering layer — the galaxy map.
@@ -15,6 +15,7 @@ export class GalaxyLayer {
readonly systemGroup = new THREE.Group();
readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
readonly systemVisuals = new Map<string, SystemVisual>();
constructor() {
this.scene.fog = new THREE.FogExp2(0x040912, 0.000035);
@@ -34,4 +35,8 @@ export class GalaxyLayer {
this.camera.aspect = aspect;
this.camera.updateProjectionMatrix();
}
render(renderer: THREE.WebGLRenderer) {
renderer.render(this.scene, this.camera);
}
}

View File

@@ -19,6 +19,7 @@ export interface ViewerHudElements {
historyLayerEl: HTMLDivElement;
marqueeEl: HTMLDivElement;
hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement;
}
export function createViewerHud(documentRef: Document): ViewerHudElements {
@@ -73,6 +74,9 @@ export function createViewerHud(documentRef: Document): ViewerHudElements {
<div class="history-layer"></div>
<section class="ops-strip"></section>
<div class="marquee-box"></div>
<svg class="hover-connector-svg" aria-hidden="true">
<line class="hover-connector-line" x1="0" y1="0" x2="0" y2="0" hidden></line>
</svg>
<div class="hover-label" hidden></div>
`;
@@ -97,5 +101,6 @@ export function createViewerHud(documentRef: Document): ViewerHudElements {
historyLayerEl: root.querySelector(".history-layer") as HTMLDivElement,
marqueeEl: root.querySelector(".marquee-box") as HTMLDivElement,
hoverLabelEl: root.querySelector(".hover-label") as HTMLDivElement,
hoverConnectorLineEl: root.querySelector(".hover-connector-line") as unknown as SVGLineElement,
};
}

View File

@@ -68,6 +68,7 @@ export function pickSelectableHitAtClientPosition(
export function updateHoverLabel(params: {
dragMode?: string;
hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement;
hoverPick: HoverPickResult | undefined;
activeSystemId?: string;
povLevel: PovLevel;
@@ -77,6 +78,7 @@ export function updateHoverLabel(params: {
const {
dragMode,
hoverLabelEl,
hoverConnectorLineEl,
hoverPick,
activeSystemId,
povLevel,
@@ -84,13 +86,9 @@ export function updateHoverLabel(params: {
point,
} = params;
if (dragMode) {
hoverLabelEl.hidden = true;
return;
}
if (!hoverPick) {
if (dragMode || !hoverPick) {
hoverLabelEl.hidden = true;
hoverConnectorLineEl.setAttribute("hidden", "");
return;
}
@@ -98,6 +96,7 @@ export function updateHoverLabel(params: {
const label = describeHoverLabel(world, selection);
if (!label) {
hoverLabelEl.hidden = true;
hoverConnectorLineEl.setAttribute("hidden", "");
return;
}
@@ -105,8 +104,16 @@ export function updateHoverLabel(params: {
hoverLabelEl.hidden = false;
hoverLabelEl.textContent = `${label}\n${distance}`;
hoverLabelEl.style.left = `${point.x + 14}px`;
hoverLabelEl.style.top = `${point.y + 14}px`;
hoverLabelEl.style.left = `${point.x + 44}px`;
hoverLabelEl.style.top = `${point.y - 90}px`;
const rect = hoverLabelEl.getBoundingClientRect();
const svgRect = (hoverConnectorLineEl.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
hoverConnectorLineEl.removeAttribute("hidden");
hoverConnectorLineEl.setAttribute("x1", String(point.x));
hoverConnectorLineEl.setAttribute("y1", String(point.y));
hoverConnectorLineEl.setAttribute("x2", String(rect.left - svgRect.left));
hoverConnectorLineEl.setAttribute("y2", String(rect.top - svgRect.top + rect.height / 2));
}
function formatHoverDistance(

View File

@@ -12,6 +12,7 @@ import {
toggleCameraMode,
navigateFromWheel,
} from "./viewerControls";
import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT } from "./viewerConstants";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import type {
CameraMode,
@@ -30,6 +31,7 @@ export interface ViewerInteractionContext {
galaxySelectableTargets: Map<THREE.Object3D, Selectable>;
systemSelectableTargets: Map<THREE.Object3D, Selectable>;
hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement;
marqueeEl: HTMLDivElement;
keyState: Set<string>;
getWorld: () => WorldState | undefined;
@@ -231,8 +233,12 @@ export class ViewerInteractionController {
return;
}
this.context.focusOnSelection(selectedItems[0]);
const selection = selectedItems[0];
this.context.focusOnSelection(selection);
this.context.syncFollowStateFromSelection();
if (selection.kind === "planet") {
this.context.setDesiredDistance(NAV_DISTANCE_PLANET_ORBIT);
}
};
readonly onWheel = (event: WheelEvent) => {
@@ -269,6 +275,7 @@ export class ViewerInteractionController {
updateHoverLabel({
dragMode: this.context.getDragMode(),
hoverLabelEl: this.context.hoverLabelEl,
hoverConnectorLineEl: this.context.hoverConnectorLineEl,
hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY),
activeSystemId: this.context.getActiveSystemId(),
povLevel: this.context.getPovLevel(),

View File

@@ -21,4 +21,8 @@ export class LocalLayer {
this.camera.aspect = aspect;
this.camera.updateProjectionMatrix();
}
render(renderer: THREE.WebGLRenderer) {
renderer.render(this.scene, this.camera);
}
}

View File

@@ -3,6 +3,7 @@ import { MOON_RENDER_SCALE } from "./viewerConstants";
import type {
ShipSnapshot,
PlanetSnapshot,
MoonSnapshot,
Vector3Dto,
WorldSnapshot,
} from "./contracts";
@@ -176,7 +177,7 @@ export function computePlanetLocalPosition(planet: PlanetSnapshot, timeSeconds:
const eccentricAnomaly = meanAnomaly
+ (eccentricity * Math.sin(meanAnomaly))
+ (0.5 * eccentricity * eccentricity * Math.sin(2 * meanAnomaly));
const semiMajorAxis = planet.orbitRadius;
const semiMajorAxis = planet.orbitRadius * KILOMETERS_PER_AU;
const semiMinorAxis = semiMajorAxis * Math.sqrt(Math.max(1 - (eccentricity * eccentricity), 0.05));
const local = new THREE.Vector3(
semiMajorAxis * (Math.cos(eccentricAnomaly) - eccentricity),
@@ -190,47 +191,24 @@ export function computePlanetLocalPosition(planet: PlanetSnapshot, timeSeconds:
return local;
}
export function computeMoonOrbitRadius(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
const spacing = planet.size * 1.4;
const variance = hashUnit(seed, `${planet.label}:${moonIndex}:radius`) * planet.size * 0.9;
return (planet.size * 1.8) + (moonIndex * spacing) + variance;
}
export function computeMoonOrbitSpeed(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
const radius = computeMoonOrbitRadius(planet, moonIndex, seed);
return 0.9 / Math.sqrt(Math.max(radius, 1)) + (moonIndex * 0.003);
}
export function computeMoonLocalPosition(planet: PlanetSnapshot, moonIndex: number, timeSeconds: number, seed: number): THREE.Vector3 {
const orbitRadius = computeMoonOrbitRadius(planet, moonIndex, seed);
const speed = computeMoonOrbitSpeed(planet, moonIndex, seed);
const phase = hashUnit(seed, `${planet.label}:${moonIndex}:phase`) * Math.PI * 2;
const inclination = THREE.MathUtils.degToRad((hashUnit(seed, `${planet.label}:${moonIndex}:inclination`) - 0.5) * 28);
const node = THREE.MathUtils.degToRad(hashUnit(seed, `${planet.label}:${moonIndex}:node`) * 360);
const angle = phase + (timeSeconds * speed);
export function computeMoonLocalPosition(moon: MoonSnapshot, timeSeconds: number): THREE.Vector3 {
const angle = THREE.MathUtils.degToRad(moon.orbitPhaseAtEpoch) + (timeSeconds * moon.orbitSpeed);
const local = new THREE.Vector3(
Math.cos(angle) * orbitRadius,
Math.cos(angle) * moon.orbitRadius,
0,
Math.sin(angle) * orbitRadius,
Math.sin(angle) * moon.orbitRadius,
);
local.applyAxisAngle(new THREE.Vector3(1, 0, 0), inclination);
local.applyAxisAngle(new THREE.Vector3(0, 1, 0), node);
local.applyAxisAngle(new THREE.Vector3(1, 0, 0), THREE.MathUtils.degToRad(moon.orbitInclination));
local.applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(moon.orbitLongitudeOfAscendingNode));
return local;
}
export function computeMoonSize(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
const base = Math.max(2.2, planet.size * 0.11);
const variance = hashUnit(seed, `${planet.label}:${moonIndex}:size`) * Math.max(planet.size * 0.16, 2.5);
return Math.min(base + variance, planet.size * 0.42);
}
export function celestialRenderRadius(size: number, scale: number, minRadius: number, exponent = 1): number {
return Math.max(minRadius, Math.pow(Math.max(size, 0.1), exponent) * scale);
}
export function computeMoonRenderRadius(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
return celestialRenderRadius(computeMoonSize(planet, moonIndex, seed), 0.00011, 0.025, 0.62);
export function computeMoonRenderRadius(moon: MoonSnapshot): number {
return celestialRenderRadius(moon.size, 0.00011, 0.025, 0.62);
}
export function starHaloOpacity(starKind: string): number {
@@ -251,7 +229,6 @@ export function resolveOrbitalAnchorPosition(
systemId: string,
anchor: OrbitalAnchor,
timeSeconds: number,
seed: number,
): THREE.Vector3 {
if (!world || anchor.kind === "star") {
return new THREE.Vector3();
@@ -268,5 +245,6 @@ export function resolveOrbitalAnchorPosition(
return planetPosition;
}
return planetPosition.add(computeMoonLocalPosition(planet, anchor.moonIndex, timeSeconds, seed));
const moon = planet.moons[anchor.moonIndex];
return planetPosition.add(computeMoonLocalPosition(moon, timeSeconds));
}

View File

@@ -351,13 +351,27 @@ export function updateDetailPanel(
detailBodyEl.innerHTML = `
<p>${system.label}</p>
<p>Parent ${parent}</p>
<p>${planet.planetType} · ${planet.shape} · Moons ${planet.moonCount}</p>
<p>${planet.planetType} · ${planet.shape} · Moons ${planet.moons.length}</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;
}
if (selected.kind === "moon") {
const system = world.systems.get(selected.systemId);
const planet = system?.planets[selected.planetIndex];
const moon = planet?.moons[selected.moonIndex];
if (moon) {
detailTitleEl.textContent = moon.label;
detailBodyEl.innerHTML = `
<p>${system?.label ?? selected.systemId} / ${planet?.label ?? `planet ${selected.planetIndex + 1}`}</p>
<p>Orbit ${formatSystemDistance(moon.orbitRadius)}<br>Inc ${moon.orbitInclination.toFixed(1)}°</p>
`;
}
return;
}
const system = world.systems.get(selected.id);
if (!system) {
return;

View File

@@ -2,6 +2,14 @@ import * as THREE from "three";
import { ACTIVE_SYSTEM_DETAIL_SCALE, PROJECTED_GALAXY_RADIUS } from "./viewerConstants";
import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds, scaleLocalVector } from "./viewerMath";
import type { PlanetVisual, ShipVisual, SystemVisual, WorldState } from "./viewerTypes";
import { rawObject } from "./viewerScenePrimitives";
const MIN_ICON_PIXELS = 25;
const MAX_ICON_PIXELS = 50;
export function iconWorldScale(distToCamera: number, camera: THREE.PerspectiveCamera, pixels: number): number {
return pixels * distToCamera * 2 * Math.tan((camera.fov * Math.PI / 180) / 2) / window.innerHeight;
}
export function getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) {
const elapsedMs = now - visual.receivedAtMs;
@@ -26,6 +34,7 @@ export function updatePlanetPresentation(
world: WorldState | undefined,
worldTimeSyncMs: number,
planetVisuals: PlanetVisual[],
systemCamera: THREE.PerspectiveCamera,
) {
const nowSeconds = currentWorldTimeSeconds(world, worldTimeSyncMs);
// In systemScene all positions use scaleLocalVector * ACTIVE_SYSTEM_DETAIL_SCALE.
@@ -34,23 +43,44 @@ export function updatePlanetPresentation(
const position = scaleLocalVector(computePlanetLocalPosition(visual.planet, nowSeconds))
.multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE);
visual.orbit.setScaleScalar(ACTIVE_SYSTEM_DETAIL_SCALE);
visual.orbit.setPosition(new THREE.Vector3(0, 0, 0));
visual.mesh.setPosition(position);
visual.icon.setPosition(position);
const iconWorldPos = visual.icon.getWorldPosition(new THREE.Vector3());
const distToIcon = systemCamera.position.distanceTo(iconWorldPos);
const t = THREE.MathUtils.clamp(distToIcon / 300, 0, 1);
const rawScale = visual.iconBaseScale * t * Math.sqrt(t);
const planetIconScale = THREE.MathUtils.clamp(rawScale, iconWorldScale(distToIcon, systemCamera, MIN_ICON_PIXELS), iconWorldScale(distToIcon, systemCamera, MAX_ICON_PIXELS));
visual.icon.setScaleScalar(planetIconScale);
if (visual.ring) {
visual.ring.setPosition(position);
}
const distToPlanet = systemCamera.position.distanceTo(position);
const moonOrbitOpacity = THREE.MathUtils.clamp(1 - distToPlanet / 500, 0, 1) * 0.18;
const clusterVisible = distToPlanet < 300;
for (const [moonIndex, moon] of visual.moons.entries()) {
moon.orbit.setPosition(position);
moon.orbit.setScaleScalar(ACTIVE_SYSTEM_DETAIL_SCALE);
moon.mesh.setPosition(
position.clone().add(
scaleLocalVector(computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds, world?.seed ?? 1))
.multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE),
),
const moonPos = position.clone().add(
scaleLocalVector(computeMoonLocalPosition(visual.planet.moons[moonIndex], nowSeconds))
.multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE),
);
moon.mesh.setPosition(moonPos);
moon.mesh.setVisible(clusterVisible);
moon.icon.setPosition(moonPos);
moon.icon.setVisible(clusterVisible);
if (clusterVisible) {
const iconWorldPos = moon.icon.getWorldPosition(new THREE.Vector3());
const moonDist = systemCamera.position.distanceTo(iconWorldPos);
const t = THREE.MathUtils.clamp(moonDist / 120, 0, 1);
const rawMoonScale = moon.iconBaseScale * t * Math.sqrt(t);
const moonIconScale = THREE.MathUtils.clamp(rawMoonScale, iconWorldScale(moonDist, systemCamera, MIN_ICON_PIXELS), iconWorldScale(moonDist, systemCamera, MAX_ICON_PIXELS));
moon.icon.setScaleScalar(moonIconScale);
}
moon.orbit.setPosition(position);
const orbitObj = rawObject(moon.orbit);
if (orbitObj instanceof THREE.LineLoop) {
(orbitObj.material as THREE.LineBasicMaterial).opacity = moonOrbitOpacity;
}
}
}
}

View File

@@ -9,8 +9,8 @@ import {
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";
import { createBackdropStars, createMilkyWayBand, createNebulaClouds, createNebulaTexture } from "./viewerSceneFactory";
import type { Selectable } from "./viewerTypes";
export interface ViewerPresentationContext {
renderer: THREE.WebGLRenderer;
@@ -40,7 +40,6 @@ export interface ViewerPresentationContext {
getWorldTimeSyncMs: () => number;
getCurrentDistance: () => number;
planetVisuals: any[];
orbitLines: OrbitLineVisual[];
systemVisuals: Map<any, any>;
createWorldPresentationContext: () => any;
}
@@ -50,30 +49,28 @@ export class ViewerPresentationController {
initializeAmbience() {
this.context.ambienceGroup.renderOrder = -10;
this.context.ambienceGroup.add(createBackdropStars());
this.context.ambienceGroup.add(createBackdropStars(document));
this.context.ambienceGroup.add(...createNebulaClouds(createNebulaTexture(document)));
this.context.ambienceGroup.add(createMilkyWayBand(document));
}
updateAmbience(delta: number) {
updateAmbience(_delta: number) {
const activeCamera = this.context.getPovLevel() === "galaxy"
? this.context.galaxyCamera
: this.context.systemCamera;
this.context.ambienceGroup.position.copy(activeCamera.position);
this.context.ambienceGroup.rotation.y += delta * 0.005;
this.context.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015;
}
applyZoomPresentation() {
const activeSystemId = this.context.getActiveSystemId();
const povLevel = this.context.getPovLevel();
// Orbit lines: only show for active system in system/local zoom
for (const orbitLine of this.context.orbitLines) {
const alpha = this.resolveOrbitLineOpacity(orbitLine, povLevel, activeSystemId);
orbitLine.line.setOpacity(alpha);
}
this.context.galaxyScene.fog = new THREE.FogExp2(0x040912, 0.000035);
const showPlanetIcons = povLevel !== "local";
for (const visual of this.context.planetVisuals) {
visual.icon.setVisible(showPlanetIcons);
}
}
updateNetworkPanel() {
@@ -100,6 +97,7 @@ export class ViewerPresentationController {
world,
this.context.getWorldTimeSyncMs(),
this.context.planetVisuals,
this.context.systemCamera,
);
}
@@ -148,21 +146,4 @@ export class ViewerPresentationController {
return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top);
}
private resolveOrbitLineOpacity(orbitLine: OrbitLineVisual, povLevel: "local" | "system" | "galaxy", activeSystemId?: string) {
if (povLevel === "galaxy" || !activeSystemId || orbitLine.systemId !== activeSystemId) {
return 0;
}
const selected = this.context.getSelectedItems();
const selectedItem = selected.length === 1 ? selected[0] : undefined;
const baseAlpha = povLevel === "local" ? 0.55 : 0.9;
if (selectedItem?.kind === "planet" && selectedItem.systemId === activeSystemId) {
return orbitLine.kind === "moon" && orbitLine.planetIndex === selectedItem.planetIndex
? baseAlpha
: 0;
}
return orbitLine.kind === "planet" ? baseAlpha : 0;
}
}

View File

@@ -1,17 +1,18 @@
import * as THREE from "three";
import { classifyPovLevel } from "./viewerMath";
import type { PovLevel, PerformanceStats } from "./viewerTypes";
import type { PovLevel } from "./viewerTypes";
import type { UniverseLayer } from "./viewerUniverseLayer";
import type { GalaxyLayer } from "./viewerGalaxyLayer";
import type { SystemLayer } from "./viewerSystemLayer";
import type { LocalLayer } from "./viewerLocalLayer";
export interface RenderFrameParams {
clock: THREE.Clock;
renderer: THREE.WebGLRenderer;
universeScene: THREE.Scene;
galaxyScene: THREE.Scene;
galaxyCamera: THREE.PerspectiveCamera;
systemScene: THREE.Scene;
systemCamera: THREE.PerspectiveCamera;
localScene: THREE.Scene;
localCamera: THREE.PerspectiveCamera;
universeLayer: UniverseLayer;
galaxyLayer: GalaxyLayer;
systemLayer: SystemLayer;
localLayer: LocalLayer;
getPovLevel: () => PovLevel;
updateCamera: (delta: number) => void;
updateAmbience: (delta: number) => void;
@@ -25,9 +26,9 @@ export interface RenderFrameParams {
export interface ResizeParams {
renderer: THREE.WebGLRenderer;
galaxyCamera: THREE.PerspectiveCamera;
systemCamera: THREE.PerspectiveCamera;
localCamera: THREE.PerspectiveCamera;
galaxyLayer: GalaxyLayer;
systemLayer: SystemLayer;
localLayer: LocalLayer;
}
export interface CameraStepParams {
@@ -48,22 +49,22 @@ export function renderFrame(params: RenderFrameParams) {
params.applyZoomPresentation();
const povLevel = params.getPovLevel();
const activeCamera = povLevel === "galaxy" ? params.galaxyCamera : params.systemCamera;
const activeCamera = povLevel === "galaxy" ? params.galaxyLayer.camera : params.systemLayer.camera;
params.renderer.autoClear = false;
params.renderer.clear();
// Universe backdrop — always first, rendered with the active camera so it aligns with the foreground
params.renderer.render(params.universeScene, activeCamera);
params.universeLayer.render(params.renderer, activeCamera);
params.renderer.clearDepth();
if (povLevel === "galaxy") {
// Galaxy map on top of universe backdrop
params.renderer.render(params.galaxyScene, params.galaxyCamera);
params.galaxyLayer.render(params.renderer);
} else if (povLevel === "system") {
params.renderer.render(params.systemScene, params.systemCamera);
params.systemLayer.render(params.renderer);
} else {
// local: system as mid-ground backdrop, then local on top
params.renderer.render(params.systemScene, params.systemCamera);
params.systemLayer.render(params.renderer);
params.renderer.clearDepth();
params.renderer.render(params.localScene, params.localCamera);
params.localLayer.render(params.renderer);
}
params.recordPerformanceStats(performance.now() - frameStartedAtMs);
@@ -73,10 +74,9 @@ export function renderFrame(params: RenderFrameParams) {
export function resizeViewer(params: ResizeParams) {
const width = window.innerWidth;
const height = window.innerHeight;
for (const camera of [params.galaxyCamera, params.systemCamera, params.localCamera]) {
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
params.galaxyLayer.onResize(width / height);
params.systemLayer.onResize(width / height);
params.localLayer.onResize(width / height);
params.renderer.setSize(width, height);
}

View File

@@ -42,7 +42,7 @@ import type {
StationSnapshot,
SystemSnapshot,
} from "./contracts";
import type { OrbitLineVisual, OrbitalAnchor, Selectable } from "./viewerTypes";
import type { OrbitalAnchor, Selectable } from "./viewerTypes";
import { rawObject } from "./viewerScenePrimitives";
export interface ViewerSceneDataContext {
@@ -65,7 +65,6 @@ export interface ViewerSceneDataContext {
systemSelectableTargets: Map<THREE.Object3D, Selectable>;
systemVisuals: Map<any, any>;
planetVisuals: any[];
orbitLines: OrbitLineVisual[];
celestialVisuals: Map<any, any>;
nodeVisuals: Map<any, any>;
stationVisuals: Map<any, any>;
@@ -251,7 +250,6 @@ export class ViewerSceneDataController {
systemSelectableTargets: this.context.systemSelectableTargets,
systemVisuals: this.context.systemVisuals,
planetVisuals: this.context.planetVisuals,
orbitLines: this.context.orbitLines,
celestialVisuals: this.context.celestialVisuals,
nodeVisuals: this.context.nodeVisuals,
stationVisuals: this.context.stationVisuals,

View File

@@ -1,5 +1,6 @@
import * as THREE from "three";
import {
ACTIVE_SYSTEM_DETAIL_SCALE,
MOON_RENDER_SCALE,
PLANET_RENDER_SCALE,
STAR_RENDER_SCALE,
@@ -8,6 +9,7 @@ import type {
CelestialSnapshot,
ClaimSnapshot,
ConstructionSiteSnapshot,
MoonSnapshot,
PlanetSnapshot,
ResourceNodeSnapshot,
ShipSnapshot,
@@ -17,10 +19,9 @@ import type {
import type { MoonVisual } from "./viewerTypes";
import {
celestialRenderRadius,
computeMoonOrbitRadius,
computeMoonLocalPosition,
computeMoonRenderRadius,
computePlanetLocalPosition,
scaleLocalScalar,
scaleLocalVector,
starHaloOpacity,
toThreeVector,
@@ -84,45 +85,34 @@ export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): Scen
export function createStarCluster(system: SystemSnapshot): SceneNode {
const root = new THREE.Group();
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)];
for (const [index, offset] of offsets.entries()) {
const sizeScale = index === 0 ? 1 : 0.72;
const star = new THREE.Mesh(
new THREE.SphereGeometry(renderedStarSize * sizeScale, 24, 24),
new THREE.MeshBasicMaterial({ color: system.starColor }),
for (const [index, star] of system.stars.entries()) {
const renderedSize = celestialRenderRadius(star.size, 0.00018, 40, 0.62);
const offset = system.stars.length > 1
? (index === 0
? new THREE.Vector3(-renderedSize * 0.55, 0, 0)
: new THREE.Vector3(renderedSize * 0.75, renderedSize * 0.08, 0))
: new THREE.Vector3(0, 0, 0);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(renderedSize, 24, 24),
new THREE.MeshBasicMaterial({ color: star.color }),
);
const halo = new THREE.Mesh(
new THREE.SphereGeometry(renderedStarSize * sizeScale * 1.45, 20, 20),
new THREE.SphereGeometry(renderedSize * 1.45, 20, 20),
new THREE.MeshBasicMaterial({
color: system.starColor,
color: star.color,
transparent: true,
opacity: starHaloOpacity(system.starKind),
opacity: starHaloOpacity(star.kind),
side: THREE.BackSide,
}),
);
star.position.copy(offset);
mesh.position.copy(offset);
halo.position.copy(offset);
root.add(star, halo);
root.add(mesh, halo);
}
return createSceneNode(root);
}
export function createPlanetOrbit(planet: PlanetSnapshot): SceneNode {
const points = Array.from({ length: 120 }, (_, index) => {
const phaseDegrees = (index / 120) * 360;
return scaleLocalVector(computePlanetLocalPosition(planet, 0, phaseDegrees));
});
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): SceneNode {
const renderedPlanetRadius = celestialRenderRadius(planet.size, 0.00012, 0.03, 0.62);
@@ -140,41 +130,74 @@ export function createPlanetRing(planet: PlanetSnapshot): SceneNode {
return createSceneNode(ring);
}
export function createMoonVisuals(planet: PlanetSnapshot, seed: number): MoonVisual[] {
const moonCount = Math.min(planet.moonCount, 12);
const moons: MoonVisual[] = [];
for (let moonIndex = 0; moonIndex < moonCount; moonIndex += 1) {
const orbitRadius = scaleLocalScalar(computeMoonOrbitRadius(planet, moonIndex, seed));
const orbit = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(
Array.from({ length: 48 }, (_, index) => {
const angle = (index / 48) * Math.PI * 2;
return new THREE.Vector3(
Math.cos(angle) * orbitRadius,
0,
Math.sin(angle) * orbitRadius,
);
}),
),
new THREE.LineBasicMaterial({ color: 0x3b5065, transparent: true, opacity: 0.1 }),
function createMoonOrbit(moon: MoonSnapshot): SceneNode {
const segments = 64;
const period = (2 * Math.PI) / Math.max(Math.abs(moon.orbitSpeed), 1e-6);
const points: THREE.Vector3[] = [];
for (let i = 0; i <= segments; i++) {
points.push(
scaleLocalVector(computeMoonLocalPosition(moon, (i / segments) * period))
.multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE),
);
orbit.rotation.x = THREE.MathUtils.degToRad(planet.orbitInclination * 0.35);
}
return createSceneNode(new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({
color: moon.color,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: false,
}),
));
}
const moonSize = computeMoonRenderRadius(planet, moonIndex, seed);
export function createMoonVisuals(planet: PlanetSnapshot, documentRef: Document): MoonVisual[] {
return planet.moons.map((moon, moonIndex) => {
const moonSize = computeMoonRenderRadius(moon);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(moonSize, 12, 12),
new THREE.MeshStandardMaterial({
color: new THREE.Color(planet.color).lerp(new THREE.Color("#d9dee7"), 0.55),
color: moon.color,
roughness: 0.96,
metalness: 0.02,
}),
);
const baseColor = new THREE.Color(moon.color);
const hsl = { h: 0, s: 0, l: 0 };
baseColor.getHSL(hsl);
const iconColor = new THREE.Color().setHSL(hsl.h, Math.max(hsl.s, 0.4), 0.72).getStyle();
const iconBaseScale = 72;
const icon = createTacticalIcon(documentRef, iconColor, iconBaseScale);
return {
systemId: "",
planetIndex: -1,
moonIndex,
mesh: createSceneNode(mesh),
icon,
iconBaseScale,
orbit: createMoonOrbit(moon),
};
});
}
moons.push({ systemId: "", planetIndex: -1, mesh: createSceneNode(mesh), orbit: createSceneNode(orbit) });
export function createPlanetOrbit(planet: PlanetSnapshot): SceneNode {
const segments = 96;
const points: THREE.Vector3[] = [];
for (let i = 0; i <= segments; i++) {
const phase = (i / segments) * 360;
points.push(scaleLocalVector(computePlanetLocalPosition(planet, 0, phase)).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE));
}
return moons;
return createSceneNode(new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({
color: planet.color,
transparent: true,
opacity: 0.22,
depthWrite: false,
depthTest: false,
}),
));
}
export function createStationMesh(station: StationSnapshot): SceneNode {
@@ -201,32 +224,160 @@ export function createShipMesh(ship: ShipSnapshot, size: number, length: number,
return createSceneNode(mesh);
}
export function createBackdropStars(): THREE.Points {
const starCount = 1800;
const radius = 36000;
function createStarGlowTexture(documentRef: Document): THREE.CanvasTexture {
const canvas = documentRef.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Unable to create star glow texture");
}
const gradient = context.createRadialGradient(64, 64, 0, 64, 64, 64);
gradient.addColorStop(0, "rgba(255,255,255,1)");
gradient.addColorStop(0.14, "rgba(255,255,255,0.95)");
gradient.addColorStop(0.35, "rgba(255,255,255,0.42)");
gradient.addColorStop(0.68, "rgba(180,205,255,0.1)");
gradient.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = gradient;
context.fillRect(0, 0, 128, 128);
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}
function createStarSparkleTexture(documentRef: Document): THREE.CanvasTexture {
const canvas = documentRef.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Unable to create star sparkle texture");
}
context.clearRect(0, 0, 128, 128);
context.translate(64, 64);
context.lineCap = "round";
const bloom = context.createRadialGradient(0, 0, 0, 0, 0, 48);
bloom.addColorStop(0, "rgba(255,255,255,0.95)");
bloom.addColorStop(0.3, "rgba(255,255,255,0.24)");
bloom.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = bloom;
context.beginPath();
context.arc(0, 0, 48, 0, Math.PI * 2);
context.fill();
context.strokeStyle = "rgba(255,255,255,0.75)";
context.lineWidth = 4;
context.beginPath();
context.moveTo(-38, 0);
context.lineTo(38, 0);
context.moveTo(0, -38);
context.lineTo(0, 38);
context.stroke();
context.rotate(Math.PI / 4);
context.strokeStyle = "rgba(255,255,255,0.35)";
context.lineWidth = 2;
context.beginPath();
context.moveTo(-28, 0);
context.lineTo(28, 0);
context.moveTo(0, -28);
context.lineTo(0, 28);
context.stroke();
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}
function createMilkyWayTexture(documentRef: Document): THREE.CanvasTexture {
const canvas = documentRef.createElement("canvas");
canvas.width = 1024;
canvas.height = 256;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Unable to create milky way texture");
}
const background = context.createLinearGradient(0, 0, 1024, 0);
background.addColorStop(0, "rgba(0,0,0,0)");
background.addColorStop(0.1, "rgba(150,110,255,0.08)");
background.addColorStop(0.32, "rgba(120,210,255,0.14)");
background.addColorStop(0.5, "rgba(255,240,220,0.28)");
background.addColorStop(0.68, "rgba(255,165,210,0.16)");
background.addColorStop(0.88, "rgba(115,155,255,0.08)");
background.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = background;
context.fillRect(0, 0, 1024, 256);
for (let index = 0; index < 220; index += 1) {
const x = THREE.MathUtils.randFloat(0, 1024);
const y = 128 + THREE.MathUtils.randFloatSpread(78);
const radiusX = THREE.MathUtils.randFloat(40, 180);
const radiusY = THREE.MathUtils.randFloat(8, 28);
const alpha = THREE.MathUtils.randFloat(0.025, 0.09);
const hue = THREE.MathUtils.randFloat(0.52, 0.76);
const color = new THREE.Color().setHSL(hue, THREE.MathUtils.randFloat(0.25, 0.6), THREE.MathUtils.randFloat(0.72, 0.9));
const puff = context.createRadialGradient(x, y, 0, x, y, radiusX);
puff.addColorStop(0, `rgba(${Math.round(color.r * 255)},${Math.round(color.g * 255)},${Math.round(color.b * 255)},${alpha})`);
puff.addColorStop(0.55, `rgba(${Math.round(color.r * 255)},${Math.round(color.g * 255)},${Math.round(color.b * 255)},${alpha * 0.45})`);
puff.addColorStop(1, "rgba(0,0,0,0)");
context.save();
context.translate(x, y);
context.scale(1, radiusY / radiusX);
context.fillStyle = puff;
context.beginPath();
context.arc(0, 0, radiusX, 0, Math.PI * 2);
context.fill();
context.restore();
}
for (let index = 0; index < 540; index += 1) {
const x = THREE.MathUtils.randFloat(0, 1024);
const y = 128 + THREE.MathUtils.randFloatSpread(54);
const alpha = THREE.MathUtils.randFloat(0.12, 0.65);
const size = THREE.MathUtils.randFloat(0.8, 2.4);
context.fillStyle = `rgba(255,255,255,${alpha})`;
context.fillRect(x, y, size, size);
}
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}
function sampleBackdropStarColor(): THREE.Color {
const roll = Math.random();
if (roll < 0.1) {
return new THREE.Color().setHSL(0.08, THREE.MathUtils.randFloat(0.65, 0.9), THREE.MathUtils.randFloat(0.78, 0.9));
}
if (roll < 0.28) {
return new THREE.Color().setHSL(0.58, THREE.MathUtils.randFloat(0.28, 0.55), THREE.MathUtils.randFloat(0.78, 0.9));
}
if (roll < 0.92) {
return new THREE.Color().setHSL(0.61, THREE.MathUtils.randFloat(0.08, 0.3), THREE.MathUtils.randFloat(0.84, 0.97));
}
return new THREE.Color().setHSL(0.76, THREE.MathUtils.randFloat(0.25, 0.48), THREE.MathUtils.randFloat(0.78, 0.88));
}
function createStarPointLayer(radius: number, starCount: number, size: number, opacity: number): THREE.Points {
const positions = new Float32Array(starCount * 3);
const colors = new Float32Array(starCount * 3);
const color = new THREE.Color();
for (let index = 0; index < starCount; index += 1) {
const direction = new THREE.Vector3(
THREE.MathUtils.randFloatSpread(2),
THREE.MathUtils.randFloatSpread(2),
THREE.MathUtils.randFloatSpread(2),
).normalize().multiplyScalar(radius * THREE.MathUtils.randFloat(0.82, 1));
).normalize().multiplyScalar(radius * THREE.MathUtils.randFloat(0.83, 1));
const color = sampleBackdropStarColor().multiplyScalar(THREE.MathUtils.randFloat(0.55, 1.2));
positions[index * 3] = direction.x;
positions[index * 3 + 1] = direction.y;
positions[index * 3 + 2] = direction.z;
const tint = THREE.MathUtils.randFloat(0, 1);
color.setRGB(
THREE.MathUtils.lerp(0.68, 1, tint),
THREE.MathUtils.lerp(0.76, 0.94, tint),
THREE.MathUtils.lerp(0.9, 1, tint),
);
if (Math.random() < 0.08) {
color.lerp(new THREE.Color(0xffd6a0), 0.45);
}
colors[index * 3] = color.r;
colors[index * 3 + 1] = color.g;
colors[index * 3 + 2] = color.b;
@@ -239,77 +390,244 @@ export function createBackdropStars(): THREE.Points {
return new THREE.Points(
geometry,
new THREE.PointsMaterial({
size: 2.2,
size,
sizeAttenuation: false,
vertexColors: true,
transparent: true,
opacity: 0.9,
opacity,
depthWrite: false,
blending: THREE.AdditiveBlending,
fog: false,
}),
);
}
export function createBackdropStars(documentRef: Document): THREE.Group {
const radius = 36000;
const root = new THREE.Group();
root.add(
createStarPointLayer(radius, 2800, 1.15, 0.5),
createStarPointLayer(radius, 900, 1.9, 0.85),
createStarPointLayer(radius, 240, 3.1, 0.95),
);
const glowTexture = createStarGlowTexture(documentRef);
const sparkleTexture = createStarSparkleTexture(documentRef);
for (let index = 0; index < 72; index += 1) {
const direction = new THREE.Vector3(
THREE.MathUtils.randFloatSpread(2),
THREE.MathUtils.randFloatSpread(2),
THREE.MathUtils.randFloatSpread(2),
).normalize().multiplyScalar(radius * THREE.MathUtils.randFloat(0.84, 0.98));
const color = sampleBackdropStarColor().multiplyScalar(THREE.MathUtils.randFloat(0.9, 1.45));
const glow = new THREE.Sprite(new THREE.SpriteMaterial({
map: glowTexture,
color,
transparent: true,
opacity: THREE.MathUtils.randFloat(0.5, 0.95),
depthWrite: false,
blending: THREE.AdditiveBlending,
fog: false,
}));
const sparkle = new THREE.Sprite(new THREE.SpriteMaterial({
map: sparkleTexture,
color: color.clone().lerp(new THREE.Color(0xffffff), 0.35),
transparent: true,
opacity: THREE.MathUtils.randFloat(0.2, 0.55),
depthWrite: false,
blending: THREE.AdditiveBlending,
fog: false,
}));
const glowScale = THREE.MathUtils.randFloat(120, 260);
glow.position.copy(direction);
glow.scale.set(glowScale, glowScale, 1);
sparkle.position.copy(direction);
sparkle.material.rotation = THREE.MathUtils.randFloat(0, Math.PI);
sparkle.scale.set(glowScale * THREE.MathUtils.randFloat(0.9, 1.4), glowScale * THREE.MathUtils.randFloat(0.9, 1.4), 1);
root.add(glow, sparkle);
}
return root;
}
export function createPlanetTexture(color: string, seed: number, documentRef: Document): THREE.CanvasTexture {
const W = 256, H = 128;
const canvas = documentRef.createElement("canvas");
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Unable to create planet texture");
const imageData = ctx.createImageData(W, H);
const base = new THREE.Color(color);
function hash(x: number, y: number): number {
const n = Math.sin(x * 127.1 + y * 311.7 + seed * 74.3) * 43758.5453;
return n - Math.floor(n);
}
function smoothNoise(x: number, y: number): number {
const ix = Math.floor(x), iy = Math.floor(y);
const fx = x - ix, fy = y - iy;
const ux = fx * fx * (3 - 2 * fx), uy = fy * fy * (3 - 2 * fy);
const a = hash(ix, iy), b = hash(ix + 1, iy);
const c = hash(ix, iy + 1), d = hash(ix + 1, iy + 1);
return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy;
}
function fbm(x: number, y: number): number {
let v = 0, amp = 0.5, freq = 1;
for (let i = 0; i < 5; i++) { v += smoothNoise(x * freq, y * freq) * amp; amp *= 0.5; freq *= 2; }
return v;
}
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const nx = (x / W) * 5, ny = (y / H) * 3;
const turb = fbm(nx + 0.1, ny + 0.1) - 0.5;
const band = Math.sin((y / H * 10 + turb * 3) * Math.PI);
const light = 0.62 + band * 0.38;
const idx = (y * W + x) * 4;
imageData.data[idx] = Math.min(255, base.r * 255 * light);
imageData.data[idx + 1] = Math.min(255, base.g * 255 * light);
imageData.data[idx + 2] = Math.min(255, base.b * 255 * light);
imageData.data[idx + 3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
const texture = new THREE.CanvasTexture(canvas);
texture.wrapS = THREE.RepeatWrapping;
texture.needsUpdate = true;
return texture;
}
export function createNebulaTexture(documentRef: Document): THREE.CanvasTexture {
const canvas = documentRef.createElement("canvas");
canvas.width = 256;
canvas.height = 256;
canvas.width = 512;
canvas.height = 512;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Unable to create nebula texture");
}
const gradient = context.createRadialGradient(128, 128, 18, 128, 128, 118);
gradient.addColorStop(0, "rgba(255,255,255,0.95)");
gradient.addColorStop(0.2, "rgba(255,255,255,0.48)");
gradient.addColorStop(0.55, "rgba(140,180,255,0.14)");
gradient.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = gradient;
context.fillRect(0, 0, 256, 256);
const palettes = [
["rgba(80,220,255,0.24)", "rgba(120,110,255,0.18)", "rgba(255,255,255,0.14)"],
["rgba(255,130,205,0.24)", "rgba(110,170,255,0.16)", "rgba(255,240,255,0.12)"],
["rgba(120,255,205,0.2)", "rgba(100,160,255,0.18)", "rgba(255,255,255,0.1)"],
];
for (let index = 0; index < 10; index += 1) {
const x = THREE.MathUtils.randFloat(30, 226);
const y = THREE.MathUtils.randFloat(30, 226);
const radius = THREE.MathUtils.randFloat(24, 72);
const puff = context.createRadialGradient(x, y, 0, x, y, radius);
puff.addColorStop(0, "rgba(255,255,255,0.16)");
puff.addColorStop(0.45, "rgba(255,255,255,0.08)");
puff.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = puff;
context.clearRect(0, 0, 512, 512);
for (let layer = 0; layer < palettes.length; layer += 1) {
for (let index = 0; index < 18; index += 1) {
const x = THREE.MathUtils.randFloat(40, 472);
const y = THREE.MathUtils.randFloat(40, 472);
const radius = THREE.MathUtils.randFloat(55, 180);
const [core, mid, edge] = palettes[layer];
const puff = context.createRadialGradient(x, y, 0, x, y, radius);
puff.addColorStop(0, core);
puff.addColorStop(0.4, mid);
puff.addColorStop(0.78, edge);
puff.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = puff;
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
}
}
for (let index = 0; index < 36; index += 1) {
const x = THREE.MathUtils.randFloat(50, 462);
const y = THREE.MathUtils.randFloat(50, 462);
const radius = THREE.MathUtils.randFloat(18, 60);
const glow = context.createRadialGradient(x, y, 0, x, y, radius);
glow.addColorStop(0, "rgba(255,255,255,0.12)");
glow.addColorStop(0.4, "rgba(255,255,255,0.05)");
glow.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = glow;
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
}
// Feather the entire texture toward the borders so large sprites do not show a card-like cutoff.
const edgeFade = context.createRadialGradient(256, 256, 86, 256, 256, 256);
edgeFade.addColorStop(0, "rgba(255,255,255,1)");
edgeFade.addColorStop(0.58, "rgba(255,255,255,0.96)");
edgeFade.addColorStop(0.82, "rgba(255,255,255,0.42)");
edgeFade.addColorStop(1, "rgba(255,255,255,0)");
context.globalCompositeOperation = "destination-in";
context.fillStyle = edgeFade;
context.fillRect(0, 0, 512, 512);
context.globalCompositeOperation = "source-over";
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}
export function createNebulaClouds(texture: THREE.Texture): THREE.Sprite[] {
const directions = [
new THREE.Vector3(0.74, 0.34, -0.58),
new THREE.Vector3(-0.62, 0.18, -0.77),
new THREE.Vector3(0.22, -0.44, -0.87),
new THREE.Vector3(-0.38, 0.56, 0.73),
const seeds = [
{ direction: new THREE.Vector3(0.76, 0.28, -0.58), color: "#5bd4ff", scale: 24000, opacity: 0.22, rotation: 0.18 },
{ direction: new THREE.Vector3(0.7, 0.34, -0.54), color: "#93b3ff", scale: 18000, opacity: 0.16, rotation: -0.22 },
{ direction: new THREE.Vector3(-0.58, 0.24, -0.78), color: "#ff8cc6", scale: 22000, opacity: 0.2, rotation: 0.34 },
{ direction: new THREE.Vector3(-0.48, 0.14, -0.86), color: "#8a8dff", scale: 16000, opacity: 0.14, rotation: -0.4 },
{ direction: new THREE.Vector3(0.24, -0.46, -0.85), color: "#79ffd6", scale: 20000, opacity: 0.17, rotation: 0.52 },
{ direction: new THREE.Vector3(-0.34, 0.58, 0.74), color: "#79b7ff", scale: 26000, opacity: 0.16, rotation: -0.12 },
];
return directions.map((direction, index) => {
return seeds.map((seed, index) => {
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: 0.14,
opacity: seed.opacity,
depthWrite: false,
color: ["#6dc7ff", "#ff9ec8", "#8e7dff", "#7ce0c3"][index] ?? "#6dc7ff",
color: seed.color,
blending: THREE.AdditiveBlending,
fog: false,
}));
sprite.position.copy(direction.normalize().multiplyScalar(25000 + index * 2600));
const scale = 15000 + index * 2400;
sprite.scale.set(scale, scale * 0.62, 1);
sprite.position.copy(seed.direction.normalize().multiplyScalar(23000 + index * 1800));
sprite.material.rotation = seed.rotation;
sprite.scale.set(seed.scale, seed.scale * THREE.MathUtils.randFloat(0.52, 0.78), 1);
return sprite;
});
}
export function createMilkyWayBand(documentRef: Document): THREE.Group {
const radius = 33800;
const texture = createMilkyWayTexture(documentRef);
const root = new THREE.Group();
const planeNormal = new THREE.Vector3(0.24, 0.92, -0.3).normalize();
const tangent = new THREE.Vector3().crossVectors(planeNormal, new THREE.Vector3(0, 0, 1));
if (tangent.lengthSq() < 1e-6) {
tangent.set(1, 0, 0);
}
tangent.normalize();
const bitangent = new THREE.Vector3().crossVectors(planeNormal, tangent).normalize();
for (let index = 0; index < 8; index += 1) {
const angle = (index / 8) * Math.PI * 2;
const direction = tangent.clone().multiplyScalar(Math.cos(angle)).add(bitangent.clone().multiplyScalar(Math.sin(angle)));
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: index % 2 === 0 ? 0.22 : 0.15,
depthWrite: false,
blending: THREE.AdditiveBlending,
color: index % 3 === 0 ? "#ffd3f1" : index % 3 === 1 ? "#c8d8ff" : "#ffffff",
fog: false,
}));
sprite.position.copy(direction.multiplyScalar(radius));
sprite.scale.set(16500, 4300 + (index % 3) * 800, 1);
sprite.material.rotation = angle + Math.PI / 2;
root.add(sprite);
}
return root;
}
export function createTacticalIcon(documentRef: Document, color: string, size: number): SceneNode {
const canvas = documentRef.createElement("canvas");
canvas.width = 64;
@@ -325,12 +643,6 @@ export function createTacticalIcon(documentRef: Document, color: string, size: n
context.beginPath();
context.arc(32, 32, 18, 0, Math.PI * 2);
context.stroke();
context.beginPath();
context.moveTo(32, 8);
context.lineTo(32, 56);
context.moveTo(8, 32);
context.lineTo(56, 32);
context.stroke();
const texture = new THREE.CanvasTexture(canvas);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({

View File

@@ -10,7 +10,6 @@ import type {
ClaimVisual,
ConstructionSiteVisual,
NodeVisual,
OrbitLineVisual,
PlanetVisual,
Selectable,
ShipVisual,
@@ -48,6 +47,7 @@ import {
createNodeMesh,
createPlanetOrbit,
createPlanetRing,
createPlanetTexture,
createShellReticle,
createShipMesh,
createCelestialMesh,
@@ -86,7 +86,6 @@ interface SceneSyncContext {
systemSelectableTargets: Map<THREE.Object3D, Selectable>;
systemVisuals: Map<string, SystemVisual>;
planetVisuals: PlanetVisual[];
orbitLines: OrbitLineVisual[];
celestialVisuals: Map<string, CelestialVisual>;
nodeVisuals: Map<string, NodeVisual>;
stationVisuals: Map<string, StructureVisual>;
@@ -121,7 +120,6 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
context.galaxySelectableTargets.clear();
context.systemSelectableTargets.clear();
context.planetVisuals.length = 0;
context.orbitLines.length = 0;
context.systemVisuals.clear();
for (const system of systems) {
@@ -129,7 +127,7 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
const galaxyRoot = createSceneNode(new THREE.Group());
galaxyRoot.setPosition(toDisplayGalaxyVector(system.galaxyPosition));
const systemIcon = createStarDot(context.documentRef, system.starColor);
const systemIcon = createStarDot(context.documentRef, system.stars[0]?.color ?? "#ffffff");
const shellReticle = createShellReticle(context.documentRef, "#ff3b30", 400);
galaxyRoot.add(systemIcon, shellReticle);
@@ -150,50 +148,45 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
);
for (const [planetIndex, planet] of system.planets.entries()) {
const orbit = createPlanetOrbit(planet);
const renderedPlanetRadius = celestialRenderRadius(planet.size, 0.00012, 0.03, 0.62);
const planetTexture = createPlanetTexture(planet.color, planetIndex * 17 + system.id.length * 31, context.documentRef);
const planetMesh = createSceneNode(new THREE.Mesh(
new THREE.SphereGeometry(renderedPlanetRadius, 18, 18),
new THREE.SphereGeometry(renderedPlanetRadius, 24, 24),
new THREE.MeshStandardMaterial({
color: planet.color,
roughness: 0.92,
metalness: 0.08,
emissive: new THREE.Color(planet.color).multiplyScalar(0.04),
map: planetTexture,
roughness: 0.88,
metalness: 0.04,
}),
));
const initialPos = toSystemPos(computePlanetLocalPosition(planet, worldTimeSeconds));
planetMesh.setPosition(initialPos);
const planetIcon = createTacticalIcon(context.documentRef, planet.color, Math.max(24, renderedPlanetRadius * 2));
const iconBaseScale = Math.max(120, renderedPlanetRadius * 10);
const planetHsl = { h: 0, s: 0, l: 0 };
new THREE.Color(planet.color).getHSL(planetHsl);
const planetIconColor = new THREE.Color().setHSL(planetHsl.h, Math.max(planetHsl.s, 0.5), 0.72).getStyle();
const planetIcon = createTacticalIcon(context.documentRef, planetIconColor, iconBaseScale);
planetIcon.setPosition(initialPos);
planetIcon.setVisible(true);
const orbit = createPlanetOrbit(planet);
const ring = planet.hasRing ? createPlanetRing(planet) : undefined;
if (ring) {
ring.setPosition(initialPos);
}
const moons = createMoonVisuals(planet, context.worldSeed);
const moons = createMoonVisuals(planet, context.documentRef);
detailGroup.add(orbit, planetMesh, planetIcon);
if (ring) {
detailGroup.add(ring);
}
for (const moon of moons) {
for (const [moonIdx, moon] of moons.entries()) {
moon.systemId = system.id;
moon.planetIndex = planetIndex;
moon.orbit.setPosition(initialPos);
moon.mesh.setPosition(initialPos);
detailGroup.add(moon.orbit, moon.mesh);
context.orbitLines.push({
line: moon.orbit,
systemId: system.id,
kind: "moon",
planetIndex,
});
moon.icon.setPosition(initialPos);
detailGroup.add(moon.mesh, moon.icon, moon.orbit);
registerSelectableTarget(context.systemSelectableTargets, moon.mesh, { kind: "moon", systemId: system.id, planetIndex, moonIndex: moonIdx });
registerSelectableTarget(context.systemSelectableTargets, moon.icon, { kind: "moon", systemId: system.id, planetIndex, moonIndex: moonIdx });
}
context.orbitLines.push({
line: orbit,
systemId: system.id,
kind: "planet",
planetIndex,
});
context.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, ring, moons });
context.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, iconBaseScale, ring, moons });
registerSelectableTarget(context.systemSelectableTargets, planetMesh, { kind: "planet", systemId: system.id, planetIndex });
registerSelectableTarget(context.systemSelectableTargets, planetIcon, { kind: "planet", systemId: system.id, planetIndex });
}
@@ -225,7 +218,8 @@ export function syncCelestials(context: SceneSyncContext, celestials: CelestialS
}
const mesh = createCelestialMesh(celestial, context.celestialColor);
const icon = createTacticalIcon(context.documentRef, context.celestialColor(celestial.kind), 18);
const celestialIconBaseScale = 90;
const icon = createTacticalIcon(context.documentRef, context.celestialColor(celestial.kind), celestialIconBaseScale);
const orbitalAnchor = toSystemPos(toThreeVector(celestial.orbitalAnchor));
mesh.setPosition(orbitalAnchor);
icon.setPosition(orbitalAnchor);
@@ -237,6 +231,7 @@ export function syncCelestials(context: SceneSyncContext, celestials: CelestialS
systemId: celestial.systemId,
mesh,
icon,
iconBaseScale: celestialIconBaseScale,
kind: celestial.kind,
orbitalAnchor,
});
@@ -252,7 +247,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);
const icon = createTacticalIcon(context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 100);
const localPosition = toThreeVector(node.localPosition);
const displayPos = toSystemPos(localPosition);
mesh.setPosition(displayPos);
@@ -285,7 +280,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);
const icon = createTacticalIcon(context.documentRef, station.color, 130);
const localPosition = toThreeVector(station.localPosition);
const displayPos = toSystemPos(localPosition);
mesh.setPosition(displayPos);
@@ -320,7 +315,7 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[], a
const localPosition = context.resolvePointPosition(claim.systemId, claim.celestialId);
const displayPos = toSystemPos(localPosition);
const mesh = createClaimMesh(claim);
const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 18);
const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 90);
mesh.setPosition(displayPos);
icon.setPosition(displayPos);
const isActive = claim.systemId === activeSystemId;
@@ -348,7 +343,7 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc
const localPosition = context.resolvePointPosition(site.systemId, site.celestialId);
const displayPos = toSystemPos(localPosition);
const mesh = createConstructionSiteMesh(site);
const icon = createTacticalIcon(context.documentRef, "#9df29c", 18);
const icon = createTacticalIcon(context.documentRef, "#9df29c", 90);
mesh.setPosition(displayPos);
icon.setPosition(displayPos);
const isActive = site.systemId === activeSystemId;
@@ -375,7 +370,7 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick
for (const ship of ships) {
const mesh = createShipMesh(ship, context.shipSize(ship), context.shipLength(ship), context.shipPresentationColor(ship));
const shipColor = context.shipPresentationColor(ship);
const icon = createTacticalIcon(context.documentRef, shipColor, 18);
const icon = createTacticalIcon(context.documentRef, shipColor, 90);
const localPosition = toThreeVector(ship.localPosition);
const displayPos = toSystemPos(localPosition);
mesh.setPosition(displayPos);

View File

@@ -33,6 +33,10 @@ export function describeSelectable(world: WorldState | undefined, item: Selectab
if (item.kind === "planet") {
return world.systems.get(item.systemId)?.planets[item.planetIndex]?.label ?? `${item.systemId}:${item.planetIndex}`;
}
if (item.kind === "moon") {
const planet = world.systems.get(item.systemId)?.planets[item.planetIndex];
return planet?.moons[item.moonIndex]?.label ?? `moon ${item.moonIndex + 1}`;
}
return world.systems.get(item.id)?.label ?? item.id;
}
@@ -54,7 +58,7 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab
if (!system) {
return item.id;
}
const starLabel = system.starCount > 1 ? `${system.starCount}× ${system.starKind}` : system.starKind;
const starLabel = system.stars.length > 1 ? `${system.stars.length}× ${system.stars[0]?.kind}` : (system.stars[0]?.kind ?? "unknown");
const planetCount = system.planets.length;
const shipCount = [...world.ships.values()].filter((s) => s.systemId === item.id).length;
const stationCount = [...world.stations.values()].filter((s) => s.systemId === item.id).length;
@@ -81,6 +85,16 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab
return planet ? `${system?.label ?? item.systemId} / ${planet.label}` : `${item.systemId} / planet ${item.planetIndex + 1}`;
}
if (item.kind === "moon") {
const system = world.systems.get(item.systemId);
const planet = system?.planets[item.planetIndex];
const moon = planet?.moons[item.moonIndex];
if (moon) {
return `${system?.label ?? item.systemId} / ${planet?.label ?? `planet ${item.planetIndex + 1}`} / ${moon.label}`;
}
return `${item.systemId} / planet ${item.planetIndex + 1} / moon ${item.moonIndex + 1}`;
}
if (item.kind === "node") {
const node = world.nodes.get(item.id);
if (!node) {
@@ -168,6 +182,9 @@ export function resolveSelectableSystemId(world: WorldState | undefined, selecti
if (selection.kind === "planet") {
return selection.systemId;
}
if (selection.kind === "moon") {
return selection.systemId;
}
return selection.id;
}
@@ -271,7 +288,7 @@ export function renderSystemDetails(
}
}
for (const planet of system.planets) {
moonCount += planet.moonCount;
moonCount += planet.moons.length;
}
const followText = activeContext && cameraMode === "follow" && cameraTargetShipId
@@ -280,7 +297,7 @@ export function renderSystemDetails(
return `
<p>${system.id}${activeContext ? " · active system" : ""}</p>
<p>${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}</p>
<p>${system.stars[0]?.kind ?? "unknown"} · ${system.stars.length} star${system.stars.length > 1 ? "s" : ""}</p>
<p>Planets ${system.planets.length}<br>Moons ${moonCount}<br>Ships ${shipCount}<br>Stations ${stationCount}</p>
<p>Celestials ${celestialCount}<br>Resource nodes ${nodeCount}</p>
<p>Claims ${claimCount}<br>Construction sites ${constructionCount}</p>

View File

@@ -1,5 +1,14 @@
import * as THREE from "three";
import type { Selectable } from "./viewerTypes";
import type {
CelestialVisual,
ClaimVisual,
ConstructionSiteVisual,
NodeVisual,
PlanetVisual,
Selectable,
ShipVisual,
StructureVisual,
} from "./viewerTypes";
/**
* System rendering layer.
@@ -9,7 +18,7 @@ import type { Selectable } from "./viewerTypes";
*/
export class SystemLayer {
readonly scene = new THREE.Scene();
readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 50000);
readonly camera = new THREE.PerspectiveCamera(50, 1, 0.0001, 300000);
readonly celestialGroup = new THREE.Group();
readonly nodeGroup = new THREE.Group();
@@ -20,6 +29,14 @@ export class SystemLayer {
readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
readonly planetVisuals: PlanetVisual[] = [];
readonly shipVisuals = new Map<string, ShipVisual>();
readonly celestialVisuals = new Map<string, CelestialVisual>();
readonly nodeVisuals = new Map<string, NodeVisual>();
readonly stationVisuals = new Map<string, StructureVisual>();
readonly claimVisuals = new Map<string, ClaimVisual>();
readonly constructionSiteVisuals = new Map<string, ConstructionSiteVisual>();
constructor() {
this.scene.add(new THREE.AmbientLight(0x90a6c0, 0.55));
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3);
@@ -44,4 +61,8 @@ export class SystemLayer {
this.camera.aspect = aspect;
this.camera.updateProjectionMatrix();
}
render(renderer: THREE.WebGLRenderer) {
renderer.render(this.scene, this.camera);
}
}

View File

@@ -29,7 +29,8 @@ export type Selectable =
| { kind: "claim"; id: string }
| { kind: "construction-site"; id: string }
| { kind: "system"; id: string }
| { kind: "planet"; systemId: string; planetIndex: number };
| { kind: "planet"; systemId: string; planetIndex: number }
| { kind: "moon"; systemId: string; planetIndex: number; moonIndex: number };
export interface ShipVisual {
systemId: string;
@@ -49,6 +50,7 @@ export interface PlanetVisual {
orbit: SceneNode;
mesh: SceneNode;
icon: SceneNode;
iconBaseScale: number;
ring?: SceneNode;
moons: MoonVisual[];
}
@@ -56,17 +58,13 @@ export interface PlanetVisual {
export interface MoonVisual {
systemId: string;
planetIndex: number;
moonIndex: number;
mesh: SceneNode;
icon: SceneNode;
iconBaseScale: number;
orbit: SceneNode;
}
export interface OrbitLineVisual {
line: SceneNode;
systemId: string;
kind: "planet" | "moon";
planetIndex: number;
}
export type OrbitalAnchor =
| { kind: "star" }
| { kind: "planet"; planetIndex: number }
@@ -89,6 +87,7 @@ export interface CelestialVisual {
systemId: string;
mesh: SceneNode;
icon: SceneNode;
iconBaseScale: number;
kind: string;
orbitalAnchor: THREE.Vector3;
}

View File

@@ -17,9 +17,11 @@ export class UniverseLayer {
this.scene.add(this.ambienceGroup);
}
updateAmbience(activeCamera: THREE.Camera, delta: number) {
updateAmbience(activeCamera: THREE.Camera, _delta: number) {
this.ambienceGroup.position.copy(activeCamera.position);
this.ambienceGroup.rotation.y += delta * 0.005;
this.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015;
}
render(renderer: THREE.WebGLRenderer, camera: THREE.Camera) {
renderer.render(this.scene, camera);
}
}

View File

@@ -4,7 +4,6 @@ import {
DISPLAY_UNITS_PER_LIGHT_YEAR,
KILOMETERS_PER_AU,
computeMoonLocalPosition,
computeMoonSize,
computePlanetLocalPosition,
currentWorldTimeSeconds,
resolveOrbitalAnchorPosition,
@@ -16,6 +15,7 @@ import {
resolveShipHeading,
updateSystemStarPresentation,
getAnimatedShipLocalPosition,
iconWorldScale,
} from "./viewerPresentation";
import { rawObject } from "./viewerScenePrimitives";
import type {
@@ -114,7 +114,15 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
visual.icon.setVisible(visual.systemId === context.activeSystemId);
const iconWorldPos = visual.icon.getWorldPosition(new THREE.Vector3());
const distToIcon = context.camera.position.distanceTo(iconWorldPos);
const isNearPlanetLagrange = /-l[12]$/.test(visual.id);
const inCluster = !isNearPlanetLagrange || distToIcon < 400;
visual.icon.setVisible(visual.systemId === context.activeSystemId && inCluster);
const t = THREE.MathUtils.clamp(distToIcon / 300, 0, 1);
const rawCelestialScale = visual.iconBaseScale * t * Math.sqrt(t);
const celestialIconScale = THREE.MathUtils.clamp(rawCelestialScale, iconWorldScale(distToIcon, context.camera, 15), iconWorldScale(distToIcon, context.camera, 100));
visual.icon.setScaleScalar(celestialIconScale);
}
for (const visual of context.stationVisuals.values()) {
@@ -351,13 +359,12 @@ export function resolveOrbitalAnchor(context: WorldOrbitalContext, systemId: str
bestAnchor = { kind: "planet", planetIndex };
}
const moonCount = Math.min(planet.moonCount, 12);
for (let moonIndex = 0; moonIndex < moonCount; moonIndex += 1) {
for (const [moonIndex, moon] of planet.moons.entries()) {
const moonPosition = planetPosition
.clone()
.add(computeMoonLocalPosition(planet, moonIndex, nowSeconds, context.world.seed));
.add(computeMoonLocalPosition(moon, nowSeconds));
const moonDistance = localPosition.distanceTo(moonPosition);
const moonThreshold = Math.max(computeMoonSize(planet, moonIndex, context.world.seed) * 14, 80);
const moonThreshold = Math.max(moon.size * 14, 80);
if (moonDistance < moonThreshold && moonDistance < bestDistance) {
bestDistance = moonDistance;
bestAnchor = { kind: "moon", planetIndex, moonIndex };
@@ -417,9 +424,15 @@ export function computeCelestialLocalPositionById(
const parentInitialPosition = toThreeVector(parentCelestial.orbitalAnchor);
const relativeOffset = basePosition.clone().sub(parentInitialPosition);
const initialAngle = Math.atan2(parentInitialPosition.z, parentInitialPosition.x);
const currentAngle = Math.atan2(parentCurrentPosition.z, parentCurrentPosition.x);
const rotatedOffset = relativeOffset.applyAxisAngle(new THREE.Vector3(0, 1, 0), currentAngle - initialAngle);
const initialDir = parentInitialPosition.clone().normalize();
const currentDir = parentCurrentPosition.clone().normalize();
let rotatedOffset: THREE.Vector3;
if (initialDir.lengthSq() > 0.0001 && currentDir.lengthSq() > 0.0001) {
const quaternion = new THREE.Quaternion().setFromUnitVectors(initialDir, currentDir);
rotatedOffset = relativeOffset.clone().applyQuaternion(quaternion);
} else {
rotatedOffset = relativeOffset.clone();
}
return parentCurrentPosition.clone().add(rotatedOffset);
}
@@ -486,7 +499,7 @@ function computeStructureLocalPosition(
}
function getOrbitalAnchorPosition(context: WorldOrbitalContext, systemId: string, anchor: OrbitalAnchor, timeSeconds: number) {
return resolveOrbitalAnchorPosition(context.world, systemId, anchor, timeSeconds, context.worldSeed);
return resolveOrbitalAnchorPosition(context.world, systemId, anchor, timeSeconds);
}
function resolveStructureAnimatedLocalPosition(context: WorldOrbitalContext, visual: StructureVisual, timeSeconds: number) {