import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.165.0/build/three.module.js"; const canvas = document.querySelector("#universe"); const hud = { sector: document.querySelector("#sector"), status: document.querySelector("#status"), name: document.querySelector("#body-name"), phenomenon: document.querySelector("#phenomenon"), science: document.querySelector("#science"), transmission: document.querySelector("#transmission"), }; const engage = document.querySelector("#engage"); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: "high-performance" }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); renderer.outputColorSpace = THREE.SRGBColorSpace; const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x020408, 0.00035); const camera = new THREE.PerspectiveCamera(72, window.innerWidth / window.innerHeight, 0.1, 6000); camera.position.set(0, 12, 90); const clock = new THREE.Clock(); const keys = new Set(); const velocity = new THREE.Vector3(); const direction = new THREE.Vector3(); const yaw = new THREE.Object3D(); const pitch = new THREE.Object3D(); yaw.add(pitch); pitch.add(camera); scene.add(yaw); yaw.position.copy(camera.position); camera.position.set(0, 0, 0); let sectorPayload = null; let payloadRequested = false; let nearBodyShown = false; const targetPosition = new THREE.Vector3(0, 20, -450); const sectorKey = "0:0:-1"; const positionKey = `${targetPosition.x},${targetPosition.y},${targetPosition.z}`; scene.add(new THREE.AmbientLight(0x8fb6ff, 0.28)); const keyLight = new THREE.PointLight(0xb7f4ff, 850, 1200); keyLight.position.set(90, 120, 150); scene.add(keyLight); const bodyGroup = new THREE.Group(); bodyGroup.position.copy(targetPosition); scene.add(bodyGroup); const starMaterial = new THREE.PointsMaterial({ size: 2.4, sizeAttenuation: true, transparent: true, opacity: 0.92, vertexColors: true, depthWrite: false, }); createFallbackStarfield(); loadHygStars(); createBody({ primary: "#8fd7ff", secondary: "#f7fbff", emissive: 1.3, beam: true, seed: 240519, noise_scale: 2.7, atmosphere: 0.45, }); createNebulae(); requestSectorAhead(); engage.addEventListener("click", () => { canvas.requestPointerLock?.(); hud.status.textContent = "FLIGHT ONLINE"; engage.classList.add("hidden"); }); document.addEventListener("pointerlockchange", () => { if (document.pointerLockElement !== canvas) engage.classList.remove("hidden"); }); document.addEventListener("mousemove", (event) => { if (document.pointerLockElement !== canvas) return; yaw.rotation.y -= event.movementX * 0.0022; pitch.rotation.x -= event.movementY * 0.0022; pitch.rotation.x = THREE.MathUtils.clamp(pitch.rotation.x, -1.4, 1.4); }); document.addEventListener("keydown", (event) => keys.add(event.code)); document.addEventListener("keyup", (event) => keys.delete(event.code)); window.addEventListener("resize", onResize); animate(); async function requestSectorAhead() { if (payloadRequested) return; payloadRequested = true; hud.status.textContent = "AUTHORING SECTOR"; try { const response = await fetch(`/api/sector/${encodeURIComponent(sectorKey)}?position=${encodeURIComponent(positionKey)}`); if (!response.ok) throw new Error(`Sector request failed: ${response.status}`); sectorPayload = await response.json(); } catch (error) { hud.status.textContent = "CACHE ERROR"; hud.science.textContent = "The sector author did not respond. Reload the page and try again."; return; } applySectorPayload(sectorPayload); hud.status.textContent = "CACHE READY"; } function applySectorPayload(payload) { hud.sector.textContent = `SECTOR ${payload.sector_key}`; hud.name.textContent = payload.body.name; hud.phenomenon.textContent = payload.fact_layer.title; hud.science.textContent = ""; hud.transmission.textContent = ""; createBody(payload.shader); } function revealPayload() { if (!sectorPayload || nearBodyShown) return; nearBodyShown = true; hud.science.textContent = sectorPayload.fact_layer.explanation; hud.transmission.textContent = `“${sectorPayload.fiction_layer.transmission}”`; hud.status.textContent = "TRANSMISSION LOCK"; } function createBody(shader) { bodyGroup.clear(); const rng = seeded(shader.seed); const geometry = new THREE.SphereGeometry(34, 96, 64); const uniforms = { time: { value: 0 }, primary: { value: new THREE.Color(shader.primary) }, secondary: { value: new THREE.Color(shader.secondary) }, noiseScale: { value: shader.noise_scale }, emissive: { value: shader.emissive }, }; const material = new THREE.ShaderMaterial({ uniforms, vertexShader: ` varying vec3 vNormal; varying vec3 vPosition; void main() { vNormal = normalize(normalMatrix * normal); vPosition = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` uniform float time; uniform vec3 primary; uniform vec3 secondary; uniform float noiseScale; uniform float emissive; varying vec3 vNormal; varying vec3 vPosition; float hash(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); } float noise(vec3 p) { vec3 i = floor(p); vec3 f = fract(p); f = f * f * (3.0 - 2.0 * f); float n = mix( mix(mix(hash(i), hash(i + vec3(1,0,0)), f.x), mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y), mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x), mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y), f.z ); return n; } void main() { vec3 p = normalize(vPosition) * noiseScale + vec3(time * 0.06, 0.0, time * 0.03); float bands = smoothstep(0.25, 0.9, noise(p) + 0.25 * sin(p.y * 7.0 + time)); float fresnel = pow(1.0 - max(dot(vNormal, vec3(0.0, 0.0, 1.0)), 0.0), 2.5); vec3 color = mix(primary, secondary, bands) * (0.75 + emissive * 0.3) + fresnel * secondary; gl_FragColor = vec4(color, 1.0); } `, }); const sphere = new THREE.Mesh(geometry, material); sphere.userData.uniforms = uniforms; bodyGroup.add(sphere); const atmosphere = new THREE.Mesh( new THREE.SphereGeometry(38 + shader.atmosphere * 12, 64, 48), new THREE.MeshBasicMaterial({ color: new THREE.Color(shader.secondary), transparent: true, opacity: 0.16 + shader.atmosphere * 0.12, blending: THREE.AdditiveBlending, depthWrite: false, }), ); bodyGroup.add(atmosphere); if (shader.beam) { const beamMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(shader.primary), transparent: true, opacity: 0.34, blending: THREE.AdditiveBlending, depthWrite: false, }); const beamGeo = new THREE.CylinderGeometry(2.2, 10, 360, 32, 1, true); const beamA = new THREE.Mesh(beamGeo, beamMat); beamA.rotation.z = Math.PI / 2; const beamB = beamA.clone(); beamB.rotation.z = -Math.PI / 2; bodyGroup.add(beamA, beamB); } bodyGroup.rotation.set(rng() * Math.PI, rng() * Math.PI, 0); } function createFallbackStarfield() { const positions = []; const colors = []; const rng = seeded(1107); for (let i = 0; i < 1600; i++) { const radius = 1500 + rng() * 3800; const theta = rng() * Math.PI * 2; const phi = Math.acos(rng() * 2 - 1); positions.push( radius * Math.sin(phi) * Math.cos(theta), radius * Math.cos(phi), radius * Math.sin(phi) * Math.sin(theta), ); const c = new THREE.Color().setHSL(0.56 + rng() * 0.12, 0.45, 0.72 + rng() * 0.22); colors.push(c.r, c.g, c.b); } setStarfield(positions, colors); } async function loadHygStars() { const response = await fetch("/data/hyg_sample.csv").catch(() => null); if (!response?.ok) return; const text = await response.text(); const rows = text.trim().split(/\r?\n/).slice(1); const positions = []; const colors = []; for (const row of rows) { const [id, proper, ra, dec, dist, mag, spect] = row.split(","); const pos = raDecToVector(Number(ra), Number(dec), Math.min(5200, Number(dist) * 18 + 900)); positions.push(pos.x, pos.y, pos.z); const color = spectralColor(spect, Number(mag)); colors.push(color.r, color.g, color.b); } if (positions.length) setStarfield(positions, colors, 9.0); } function setStarfield(positions, colors, size = 2.4) { const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3)); starMaterial.size = size; const points = new THREE.Points(geometry, starMaterial); const old = scene.getObjectByName("starfield"); if (old) scene.remove(old); points.name = "starfield"; scene.add(points); } function createNebulae() { const texture = makeNebulaTexture(); const material = new THREE.SpriteMaterial({ map: texture, color: 0x78ffe0, transparent: true, opacity: 0.22, blending: THREE.AdditiveBlending, depthWrite: false, }); const positions = [ [-420, 150, -900, 520], [560, -120, -1250, 720], [-100, 420, -1700, 900], ]; for (const [x, y, z, scale] of positions) { const sprite = new THREE.Sprite(material.clone()); sprite.position.set(x, y, z); sprite.scale.set(scale, scale * 0.58, 1); scene.add(sprite); } } function makeNebulaTexture() { const nebulaCanvas = document.createElement("canvas"); nebulaCanvas.width = 256; nebulaCanvas.height = 256; const ctx = nebulaCanvas.getContext("2d"); const gradient = ctx.createRadialGradient(128, 128, 12, 128, 128, 128); gradient.addColorStop(0, "rgba(255, 255, 255, 0.55)"); gradient.addColorStop(0.35, "rgba(98, 240, 212, 0.22)"); gradient.addColorStop(0.7, "rgba(255, 79, 139, 0.13)"); gradient.addColorStop(1, "rgba(0, 0, 0, 0)"); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 256, 256); return new THREE.CanvasTexture(nebulaCanvas); } function animate() { const dt = Math.min(clock.getDelta(), 0.05); updateFlight(dt); const t = clock.elapsedTime; bodyGroup.rotation.y += dt * 0.22; for (const child of bodyGroup.children) { if (child.userData.uniforms) child.userData.uniforms.time.value = t; } const dist = yaw.position.distanceTo(targetPosition); if (dist < 650) requestSectorAhead(); if (dist < 175) revealPayload(); if (!nearBodyShown && sectorPayload) hud.status.textContent = `${Math.round(dist)} KM`; renderer.render(scene, camera); requestAnimationFrame(animate); } function updateFlight(dt) { direction.set(0, 0, 0); if (keys.has("KeyW") || keys.has("ArrowUp")) direction.z -= 1; if (keys.has("KeyS") || keys.has("ArrowDown")) direction.z += 1; if (keys.has("KeyA") || keys.has("ArrowLeft")) direction.x -= 1; if (keys.has("KeyD") || keys.has("ArrowRight")) direction.x += 1; if (keys.has("Space")) direction.y += 1; if (keys.has("ShiftLeft") || keys.has("ShiftRight")) direction.y -= 1; direction.normalize(); const speed = keys.has("ControlLeft") || keys.has("ControlRight") ? 260 : 145; velocity.lerp(direction.multiplyScalar(speed), 0.12); yaw.translateX(velocity.x * dt); yaw.translateY(velocity.y * dt); yaw.translateZ(velocity.z * dt); } function raDecToVector(raHours, decDeg, radius) { const ra = (raHours / 24) * Math.PI * 2; const dec = THREE.MathUtils.degToRad(decDeg); return new THREE.Vector3( radius * Math.cos(dec) * Math.cos(ra), radius * Math.sin(dec), radius * Math.cos(dec) * Math.sin(ra), ); } function spectralColor(spect = "G", mag = 1) { const letter = spect.trim()[0]?.toUpperCase(); const palette = { O: 0x9bbcff, B: 0xaabfff, A: 0xd8e7ff, F: 0xfff4df, G: 0xffe7b5, K: 0xffbd6f, M: 0xff8866, }; const color = new THREE.Color(palette[letter] || 0xffffff); const boost = THREE.MathUtils.clamp(1.4 - (mag + 1.5) * 0.12, 0.45, 1.4); return color.multiplyScalar(boost); } function seeded(seed) { let state = Number(seed) >>> 0; return () => { state += 0x6d2b79f5; let t = state; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function onResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }