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; }