Files
space-game/apps/viewer/src/viewerSceneFactory.ts
Jonathan Bourdon 5df5111463 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>
2026-03-16 14:21:20 -04:00

425 lines
14 KiB
TypeScript

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