Refactor simulation and viewer architecture
This commit is contained in:
420
apps/viewer/src/viewerSceneFactory.ts
Normal file
420
apps/viewer/src/viewerSceneFactory.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
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,
|
||||
starHaloOpacity,
|
||||
toThreeVector,
|
||||
} from "./viewerMath";
|
||||
|
||||
export function createNodeMesh(node: ResourceNodeSnapshot): THREE.Mesh {
|
||||
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 mesh;
|
||||
}
|
||||
|
||||
export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColor: (kind: string) => string): THREE.Mesh {
|
||||
const color = spatialNodeColor(node.kind);
|
||||
return 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[],
|
||||
): THREE.LineLoop {
|
||||
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 ring;
|
||||
}
|
||||
|
||||
export function createClaimMesh(claim: ClaimSnapshot): THREE.Mesh {
|
||||
return 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): THREE.Mesh {
|
||||
return 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): THREE.Group {
|
||||
const root = new THREE.Group();
|
||||
const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02);
|
||||
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 root;
|
||||
}
|
||||
|
||||
export function createPlanetOrbit(planet: PlanetSnapshot): THREE.LineLoop {
|
||||
const points = Array.from({ length: 120 }, (_, index) => {
|
||||
const phaseDegrees = (index / 120) * 360;
|
||||
return computePlanetLocalPosition(planet, 0, phaseDegrees);
|
||||
});
|
||||
|
||||
return new THREE.LineLoop(
|
||||
new THREE.BufferGeometry().setFromPoints(points),
|
||||
new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.22 }),
|
||||
);
|
||||
}
|
||||
|
||||
export function createPlanetRing(planet: PlanetSnapshot): THREE.Mesh {
|
||||
const renderedPlanetRadius = celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06);
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.RingGeometry(renderedPlanetRadius * 1.35, renderedPlanetRadius * 2.15, 48),
|
||||
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 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 = 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({ mesh, orbit });
|
||||
}
|
||||
|
||||
return moons;
|
||||
}
|
||||
|
||||
export function createStationMesh(station: StationSnapshot): THREE.Mesh {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(24, 24, 18, 10),
|
||||
new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }),
|
||||
);
|
||||
mesh.rotation.x = Math.PI / 2;
|
||||
mesh.position.copy(toThreeVector(station.localPosition));
|
||||
return mesh;
|
||||
}
|
||||
|
||||
export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): THREE.Mesh {
|
||||
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 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): THREE.Sprite {
|
||||
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 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 = new THREE.Sprite(new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
}));
|
||||
sprite.scale.set(520, 160, 1);
|
||||
sprite.visible = false;
|
||||
return { sprite, texture, anchor };
|
||||
}
|
||||
|
||||
export function createShellReticle(documentRef: Document, color: string, size: number): THREE.Sprite {
|
||||
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 = new THREE.Sprite(material);
|
||||
sprite.scale.setScalar(size);
|
||||
sprite.visible = false;
|
||||
sprite.renderOrder = 1000;
|
||||
return sprite;
|
||||
}
|
||||
Reference in New Issue
Block a user