Spaces:
Runtime error
Runtime error
| import * as THREE from "three"; | |
| import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js"; | |
| import { RenderPass } from "three/addons/postprocessing/RenderPass.js"; | |
| import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js"; | |
| // ---------------------------------------------------------------- DOM | |
| const canvas = document.querySelector("#universe"); | |
| const hud = { | |
| sector: document.querySelector("#sector"), | |
| status: document.querySelector("#status"), | |
| authored: document.querySelector("#authored"), | |
| discovered: document.querySelector("#discovered"), | |
| card: document.querySelector("#hud"), | |
| phenomenon: document.querySelector("#phenomenon"), | |
| distance: document.querySelector("#distance"), | |
| name: document.querySelector("#body-name"), | |
| science: document.querySelector("#science"), | |
| transWrap: document.querySelector("#transmission-wrap"), | |
| civ: document.querySelector("#civ"), | |
| transmission: document.querySelector("#transmission"), | |
| log: document.querySelector("#log"), | |
| logList: document.querySelector("#log-list"), | |
| toast: document.querySelector("#event-toast"), | |
| }; | |
| const overlay = document.querySelector("#overlay"); | |
| const engage = document.querySelector("#engage"); | |
| const introEl = document.querySelector("#intro"); | |
| // ---------------------------------------------------------------- renderer | |
| 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; | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 1.05; | |
| const scene = new THREE.Scene(); | |
| scene.fog = new THREE.FogExp2(0x01030a, 0.000045); | |
| const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 60000); | |
| const yaw = new THREE.Object3D(); | |
| const pitch = new THREE.Object3D(); | |
| yaw.add(pitch); | |
| pitch.add(camera); | |
| scene.add(yaw); | |
| yaw.position.set(0, 0, 1200); | |
| const composer = new EffectComposer(renderer); | |
| composer.addPass(new RenderPass(scene, camera)); | |
| const bloom = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.9, 0.55, 0.0); | |
| composer.addPass(bloom); | |
| scene.add(new THREE.AmbientLight(0x4a5e88, 0.5)); | |
| // ---------------------------------------------------------------- state | |
| const clock = new THREE.Clock(); | |
| const keys = new Set(); | |
| const velocity = new THREE.Vector3(); | |
| const move = new THREE.Vector3(); | |
| let flying = false; | |
| const bodies = []; // { data, group, anchor, mesh, glow, effects, payload, requested, revealed, animate } | |
| const cache = new Map(); | |
| let discoveredCount = 0; | |
| let nearest = null; | |
| let lastEvent = 0; | |
| // ---------------------------------------------------------------- textures | |
| function radialTexture(stops) { | |
| const c = document.createElement("canvas"); | |
| c.width = c.height = 128; | |
| const ctx = c.getContext("2d"); | |
| const g = ctx.createRadialGradient(64, 64, 0, 64, 64, 64); | |
| for (const [o, col] of stops) g.addColorStop(o, col); | |
| ctx.fillStyle = g; | |
| ctx.fillRect(0, 0, 128, 128); | |
| return new THREE.CanvasTexture(c); | |
| } | |
| const glowTex = radialTexture([ | |
| [0, "rgba(255,255,255,1)"], [0.25, "rgba(255,255,255,0.8)"], | |
| [0.5, "rgba(255,255,255,0.28)"], [1, "rgba(255,255,255,0)"], | |
| ]); | |
| const softTex = radialTexture([ | |
| [0, "rgba(255,255,255,0.7)"], [0.4, "rgba(255,255,255,0.25)"], [1, "rgba(255,255,255,0)"], | |
| ]); | |
| function glowSprite(color, scale) { | |
| const s = new THREE.Sprite(new THREE.SpriteMaterial({ | |
| map: glowTex, color: new THREE.Color(color), transparent: true, | |
| blending: THREE.AdditiveBlending, depthWrite: false, opacity: 0.95, | |
| })); | |
| s.scale.setScalar(scale); | |
| return s; | |
| } | |
| // ---------------------------------------------------------------- shaders | |
| const NOISE = ` | |
| 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), f=fract(p); f=f*f*(3.0-2.0*f); | |
| return 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); | |
| } | |
| float fbm(vec3 p){ float v=0.0,a=0.5; for(int i=0;i<5;i++){ v+=a*noise(p); p*=2.03; a*=0.5; } return v; } | |
| `; | |
| function starMaterial(shader) { | |
| return new THREE.ShaderMaterial({ | |
| uniforms: { | |
| time: { value: 0 }, primary: { value: new THREE.Color(shader.primary) }, | |
| secondary: { value: new THREE.Color(shader.secondary) }, emissive: { value: shader.emissive }, | |
| noiseScale: { value: shader.noise_scale }, | |
| }, | |
| vertexShader: `varying vec3 vp; void main(){ vp=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`, | |
| fragmentShader: ` | |
| uniform float time; uniform vec3 primary; uniform vec3 secondary; uniform float emissive; uniform float noiseScale; | |
| varying vec3 vp; ${NOISE} | |
| void main(){ | |
| vec3 p = normalize(vp)*noiseScale; | |
| float n = fbm(p + vec3(0.0, time*0.15, time*0.05)); | |
| float gran = fbm(p*3.5 - time*0.2); | |
| float h = pow(n*0.7 + gran*0.4, 1.4); | |
| vec3 col = mix(primary, secondary, smoothstep(0.25,0.95,h)); | |
| col += secondary * pow(gran, 3.0) * 0.6; | |
| gl_FragColor = vec4(col*(0.7+emissive*0.7), 1.0); | |
| }`, | |
| }); | |
| } | |
| function planetMaterial(shader) { | |
| return new THREE.ShaderMaterial({ | |
| uniforms: { | |
| time: { value: 0 }, primary: { value: new THREE.Color(shader.primary) }, | |
| secondary: { value: new THREE.Color(shader.secondary) }, noiseScale: { value: shader.noise_scale }, | |
| lightDir: { value: new THREE.Vector3(0.6, 0.4, 0.7).normalize() }, | |
| }, | |
| vertexShader: `varying vec3 vp; varying vec3 vn; | |
| void main(){ vp=position; vn=normalize(normalMatrix*normal); gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`, | |
| fragmentShader: ` | |
| uniform float time; uniform vec3 primary; uniform vec3 secondary; uniform float noiseScale; uniform vec3 lightDir; | |
| varying vec3 vp; varying vec3 vn; ${NOISE} | |
| void main(){ | |
| vec3 p = normalize(vp); | |
| float bands = fbm(vec3(p.x*1.2, p.y*noiseScale*1.4, p.z*1.2) + vec3(time*0.04,0.0,0.0)); | |
| float swirl = fbm(p*4.0 + vec3(time*0.08, 0.0, 0.0)); | |
| float t = smoothstep(0.2, 0.85, bands*0.7 + swirl*0.35); | |
| vec3 col = mix(primary, secondary, t); | |
| float light = clamp(dot(vn, lightDir), 0.0, 1.0)*0.9 + 0.12; | |
| float rim = pow(1.0 - max(dot(vn, vec3(0.0,0.0,1.0)),0.0), 3.0); | |
| gl_FragColor = vec4(col*light + secondary*rim*0.6, 1.0); | |
| }`, | |
| }); | |
| } | |
| // ---------------------------------------------------------------- body builders | |
| function buildBody(data) { | |
| const group = new THREE.Group(); | |
| const p = data.position; | |
| group.position.set(p.x, p.y, p.z); | |
| scene.add(group); | |
| const shader = { | |
| primary: data.visual.primary_color, secondary: data.visual.secondary_color, | |
| emissive: data.visual.emissive, noise_scale: 2.4, atmosphere: 0.4, | |
| }; | |
| const r = data.visual.radius; | |
| const render = data.visual.render; | |
| const entry = { data, group, animate: [], glow: null, far: glowSprite(data.visual.primary_color, r * 7) }; | |
| group.add(entry.far); | |
| if (render === "star" || render === "pulsar") { | |
| const star = new THREE.Mesh(new THREE.IcosahedronGeometry(r, 5), starMaterial(shader)); | |
| group.add(star); | |
| entry.animate.push((t) => { star.material.uniforms.time.value = t; star.rotation.y = t * 0.04; }); | |
| group.add(glowSprite(data.visual.primary_color, r * 5.2)); | |
| if (data.visual.corona) { | |
| const corona = glowSprite(data.visual.secondary_color, r * 8.5); | |
| corona.material.opacity = 0.4; | |
| group.add(corona); | |
| entry.animate.push((t) => corona.scale.setScalar(r * (8.0 + Math.sin(t * 1.3) * 0.6))); | |
| } | |
| if (render === "pulsar" || data.visual.beam) addBeams(group, entry, r, data.visual.primary_color); | |
| } else if (render === "planet") { | |
| const planet = new THREE.Mesh(new THREE.SphereGeometry(r, 96, 64), planetMaterial(shader)); | |
| group.add(planet); | |
| entry.animate.push((t) => { planet.material.uniforms.time.value = t; planet.rotation.y = t * 0.06; }); | |
| const atmo = new THREE.Mesh(new THREE.SphereGeometry(r * 1.06, 48, 32), new THREE.MeshBasicMaterial({ | |
| color: new THREE.Color(data.visual.secondary_color), transparent: true, opacity: 0.18, | |
| blending: THREE.AdditiveBlending, side: THREE.BackSide, depthWrite: false, | |
| })); | |
| group.add(atmo); | |
| if (data.visual.rings) addRings(group, r, data.visual.particles, 1.4, 2.3, false); | |
| } else if (render === "blackhole") { | |
| const core = new THREE.Mesh(new THREE.SphereGeometry(r, 48, 32), new THREE.MeshBasicMaterial({ color: 0x000000 })); | |
| group.add(core); | |
| addRings(group, r, data.visual.primary_color, 1.5, 3.4, true, data.visual.secondary_color); | |
| const photon = glowSprite(data.visual.secondary_color, r * 4.0); | |
| photon.material.opacity = 0.5; | |
| group.add(photon); | |
| if (data.visual.jets) addBeams(group, entry, r, data.visual.secondary_color, true); | |
| entry.animate.push((t) => { group.children.forEach((c) => { if (c.userData.disk) c.rotation.z = t * 0.5; }); }); | |
| } else if (render === "nebula") { | |
| addNebula(group, r, data.visual.primary_color, data.visual.secondary_color, entry); | |
| } else if (render === "remnant") { | |
| addRemnant(group, r, data.visual.primary_color, data.visual.secondary_color, entry); | |
| } | |
| return entry; | |
| } | |
| function addBeams(group, entry, r, color, vertical = false) { | |
| const mat = new THREE.MeshBasicMaterial({ | |
| color: new THREE.Color(color), transparent: true, opacity: 0.32, | |
| blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, | |
| }); | |
| const len = r * (vertical ? 30 : 22); | |
| const geo = new THREE.CylinderGeometry(r * 0.05, r * 0.5, len, 24, 1, true); | |
| const a = new THREE.Mesh(geo, mat); | |
| const b = new THREE.Mesh(geo, mat.clone()); | |
| a.position.y = len / 2; b.position.y = -len / 2; b.rotation.z = Math.PI; | |
| const pivot = new THREE.Group(); | |
| pivot.add(a, b); | |
| if (!vertical) pivot.rotation.z = Math.PI / 2.6; | |
| group.add(pivot); | |
| entry.animate.push((t) => { if (!vertical) pivot.rotation.y = t * 1.4; mat.opacity = 0.22 + Math.abs(Math.sin(t * 2.0)) * 0.28; }); | |
| } | |
| function addRings(group, r, color, inner, outer, hot, color2) { | |
| const geo = new THREE.RingGeometry(r * inner, r * outer, 128); | |
| const mat = new THREE.ShaderMaterial({ | |
| uniforms: { time: { value: 0 }, c1: { value: new THREE.Color(color) }, c2: { value: new THREE.Color(color2 || color) } }, | |
| transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, | |
| vertexShader: `varying vec2 vu; void main(){ vu=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);} `, | |
| fragmentShader: `uniform float time; uniform vec3 c1; uniform vec3 c2; varying vec2 vu; | |
| void main(){ float a=atan(vu.y-0.5, vu.x-0.5); float r=length(vu-0.5); | |
| float band=0.5+0.5*sin(a*${hot ? "18.0" : "40.0"}+time*${hot ? "3.0" : "0.0"}); | |
| float glow=smoothstep(0.5,0.18,abs(r-0.34)); | |
| vec3 col=mix(c2,c1,band); gl_FragColor=vec4(col, glow*${hot ? "0.9" : "0.6"}); }`, | |
| }); | |
| const ring = new THREE.Mesh(geo, mat); | |
| ring.rotation.x = Math.PI / 2 + (hot ? 0.32 : 0.5); | |
| ring.userData.disk = hot; | |
| group.add(ring); | |
| group.userData.ringMat = mat; | |
| } | |
| function addNebula(group, r, c1, c2, entry) { | |
| const colors = [c1, c2, c1]; | |
| for (let i = 0; i < 18; i++) { | |
| const col = colors[i % 3]; | |
| const s = new THREE.Sprite(new THREE.SpriteMaterial({ | |
| map: softTex, color: new THREE.Color(col), transparent: true, opacity: 0.16, | |
| blending: THREE.AdditiveBlending, depthWrite: false, | |
| })); | |
| s.position.set((Math.random() - 0.5) * r * 1.6, (Math.random() - 0.5) * r * 1.1, (Math.random() - 0.5) * r * 1.6); | |
| s.scale.setScalar(r * (0.7 + Math.random() * 0.9)); | |
| group.add(s); | |
| } | |
| group.add(glowSprite(c2, r * 0.8)); | |
| entry.animate.push((t) => { group.rotation.y = t * 0.012; }); | |
| } | |
| function addRemnant(group, r, c1, c2, entry) { | |
| const count = 2200; | |
| const pos = new Float32Array(count * 3); | |
| const col = new Float32Array(count * 3); | |
| const a = new THREE.Color(c1), b = new THREE.Color(c2); | |
| for (let i = 0; i < count; i++) { | |
| const rad = r * (0.85 + Math.random() * 0.2); | |
| const th = Math.random() * Math.PI * 2, ph = Math.acos(Math.random() * 2 - 1); | |
| pos[i * 3] = rad * Math.sin(ph) * Math.cos(th); | |
| pos[i * 3 + 1] = rad * Math.cos(ph); | |
| pos[i * 3 + 2] = rad * Math.sin(ph) * Math.sin(th); | |
| const c = Math.random() < 0.5 ? a : b; | |
| col[i * 3] = c.r; col[i * 3 + 1] = c.g; col[i * 3 + 2] = c.b; | |
| } | |
| const geo = new THREE.BufferGeometry(); | |
| geo.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); | |
| geo.setAttribute("color", new THREE.Float32BufferAttribute(col, 3)); | |
| const pts = new THREE.Points(geo, new THREE.PointsMaterial({ | |
| size: r * 0.03, vertexColors: true, transparent: true, opacity: 0.8, | |
| blending: THREE.AdditiveBlending, depthWrite: false, map: softTex, | |
| })); | |
| group.add(pts); | |
| group.add(glowSprite(c1, r * 0.6)); | |
| entry.animate.push((t) => { const s = 1 + Math.sin(t * 0.25) * 0.04; group.scale.setScalar(s); group.rotation.y = t * 0.02; }); | |
| } | |
| // ---------------------------------------------------------------- starfield + nebulae backdrop | |
| function backgroundStars(positions, colors, size) { | |
| const geo = new THREE.BufferGeometry(); | |
| geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); | |
| geo.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3)); | |
| const pts = new THREE.Points(geo, new THREE.PointsMaterial({ | |
| size, sizeAttenuation: false, vertexColors: true, transparent: true, | |
| opacity: 0.95, depthWrite: false, map: glowTex, alphaTest: 0.02, | |
| })); | |
| scene.add(pts); | |
| } | |
| async function loadHygStars() { | |
| const fallback = () => { | |
| const pos = [], col = []; | |
| for (let i = 0; i < 2600; i++) { | |
| const d = 30000, th = Math.random() * Math.PI * 2, ph = Math.acos(Math.random() * 2 - 1); | |
| pos.push(d * Math.sin(ph) * Math.cos(th), d * Math.cos(ph), d * Math.sin(ph) * Math.sin(th)); | |
| const c = new THREE.Color().setHSL(0.55 + Math.random() * 0.12, 0.4, 0.7 + Math.random() * 0.25); | |
| col.push(c.r, c.g, c.b); | |
| } | |
| backgroundStars(pos, col, 1.6); | |
| }; | |
| try { | |
| const res = await fetch("/data/hyg_sample.csv"); | |
| if (!res.ok) return fallback(); | |
| const rows = (await res.text()).trim().split(/\r?\n/).slice(1); | |
| const pos = [], col = []; | |
| for (const row of rows) { | |
| const [, , ra, dec, dist, mag, spect] = row.split(","); | |
| const R = 28000; | |
| const a = (Number(ra) / 24) * Math.PI * 2, d = THREE.MathUtils.degToRad(Number(dec)); | |
| pos.push(R * Math.cos(d) * Math.cos(a), R * Math.sin(d), R * Math.cos(d) * Math.sin(a)); | |
| const c = spectralColor(spect, Number(mag)); | |
| col.push(c.r, c.g, c.b); | |
| } | |
| if (pos.length) backgroundStars(pos, col, 2.2); | |
| fallback(); // add a faint deep field behind the real catalog | |
| } catch { fallback(); } | |
| } | |
| function spectralColor(spect = "G", mag = 1) { | |
| const palette = { O: 0x9bb0ff, B: 0xaabfff, A: 0xdce6ff, F: 0xfff4ea, G: 0xfff2cc, K: 0xffd2a1, M: 0xffb38a }; | |
| const c = new THREE.Color(palette[(spect.trim()[0] || "G").toUpperCase()] || 0xffffff); | |
| return c.multiplyScalar(THREE.MathUtils.clamp(1.5 - (mag + 1.5) * 0.12, 0.5, 1.5)); | |
| } | |
| function backdropNebulae() { | |
| const spots = [ | |
| [-9000, 2000, -12000, 5200, 0x3a2f6e], [11000, -3000, 9000, 6000, 0x123b4a], | |
| [-4000, -6000, 14000, 5000, 0x4a1d3a], [8000, 5000, -16000, 7000, 0x1c3a2e], | |
| ]; | |
| for (const [x, y, z, s, c] of spots) { | |
| const sp = new THREE.Sprite(new THREE.SpriteMaterial({ | |
| map: softTex, color: new THREE.Color(c), transparent: true, opacity: 0.5, | |
| blending: THREE.AdditiveBlending, depthWrite: false, | |
| })); | |
| sp.position.set(x, y, z); sp.scale.setScalar(s); scene.add(sp); | |
| } | |
| } | |
| // ---------------------------------------------------------------- universe load | |
| async function loadUniverse() { | |
| const manifest = await fetch("/api/universe").then((r) => r.json()); | |
| introEl.textContent = manifest.intro; | |
| for (const data of manifest.bodies) bodies.push(buildBody(data)); | |
| hud.discovered.textContent = `relics 0 / ${bodies.length}`; | |
| hud.status.textContent = "STANDBY"; | |
| } | |
| // ---------------------------------------------------------------- sector fetch + reveal | |
| async function requestSector(entry) { | |
| if (entry.requested) return; | |
| entry.requested = true; | |
| if (cache.has(entry.data.id)) { entry.payload = cache.get(entry.data.id); return; } | |
| try { | |
| const r = await fetch(`/api/sector/${entry.data.id}`); | |
| const payload = await r.json(); | |
| entry.payload = payload; | |
| cache.set(entry.data.id, payload); | |
| } catch { | |
| entry.requested = false; // allow retry | |
| } | |
| } | |
| function showCard(entry) { | |
| const p = entry.payload; | |
| hud.card.classList.remove("hidden"); | |
| hud.sector.textContent = `SECTOR ${entry.data.id.toUpperCase()}`; | |
| hud.phenomenon.textContent = (p ? p.body.phenomenon : entry.data.name).toUpperCase(); | |
| hud.name.textContent = p ? p.body.name : entry.data.name; | |
| hud.authored.textContent = `author: ${p ? p.authored_by : "…"}`; | |
| if (p) { | |
| hud.science.textContent = p.fact_layer.explanation; | |
| } else { | |
| hud.science.textContent = "Authoring sector… approaching telemetry."; | |
| } | |
| hud.transWrap.classList.add("hidden"); | |
| } | |
| function revealTransmission(entry) { | |
| const p = entry.payload; | |
| if (!p || entry.revealed) return; | |
| entry.revealed = true; | |
| hud.civ.textContent = p.fiction_layer.civilization; | |
| hud.transmission.textContent = `“${p.fiction_layer.transmission}”`; | |
| hud.transWrap.classList.remove("hidden"); | |
| hud.status.textContent = "TRANSMISSION LOCK"; | |
| discoveredCount += 1; | |
| hud.discovered.textContent = `relics ${discoveredCount} / ${bodies.length}`; | |
| addLog(p); | |
| } | |
| function addLog(p) { | |
| hud.log.classList.remove("hidden"); | |
| const li = document.createElement("li"); | |
| li.innerHTML = `<b>${p.body.name}</b> — ${p.fiction_layer.transmission}`; | |
| hud.logList.prepend(li); | |
| while (hud.logList.children.length > 8) hud.logList.lastChild.remove(); | |
| } | |
| // ---------------------------------------------------------------- cosmic events | |
| function maybeEvent(t) { | |
| if (t - lastEvent < 22 || Math.random() > 0.004) return; | |
| lastEvent = t; | |
| Math.random() < 0.5 ? supernova() : comet(); | |
| } | |
| function supernova() { | |
| const far = bodies.filter((b) => b.group.position.distanceTo(yaw.position) > 1500); | |
| if (!far.length) return; | |
| const target = far[Math.floor(Math.random() * far.length)]; | |
| const flash = glowSprite(0xfff0d0, 40); | |
| flash.position.copy(target.group.position); | |
| scene.add(flash); | |
| toast("◎ SUPERNOVA DETECTED — a star is seeding the void with heavy elements"); | |
| let s = 0; | |
| const grow = () => { | |
| s += 0.04; | |
| flash.scale.setScalar(40 + s * 2600); | |
| flash.material.opacity = Math.max(0, 1 - s); | |
| if (s < 1) requestAnimationFrame(grow); else scene.remove(flash); | |
| }; | |
| grow(); | |
| } | |
| function comet() { | |
| const start = new THREE.Vector3().copy(yaw.position).add(new THREE.Vector3((Math.random() - 0.5) * 3000, 800, -2500)); | |
| const head = glowSprite(0xbfe9ff, 60); | |
| head.position.copy(start); | |
| scene.add(head); | |
| const vel = new THREE.Vector3((Math.random() - 0.5) * 60, -8, 120); | |
| toast("☄ COMET passing — primordial ice from the outer dark"); | |
| let life = 0; | |
| const fly = () => { | |
| life += 1; head.position.add(vel); head.material.opacity = Math.max(0, 1 - life / 120); | |
| if (life < 120) requestAnimationFrame(fly); else scene.remove(head); | |
| }; | |
| fly(); | |
| } | |
| let toastTimer = 0; | |
| function toast(msg) { | |
| hud.toast.textContent = msg; | |
| hud.toast.classList.remove("hidden"); | |
| clearTimeout(toastTimer); | |
| toastTimer = setTimeout(() => hud.toast.classList.add("hidden"), 6000); | |
| } | |
| // ---------------------------------------------------------------- controls | |
| engage.addEventListener("click", () => { | |
| overlay.style.opacity = "0"; | |
| setTimeout(() => overlay.classList.add("hidden"), 600); | |
| canvas.requestPointerLock?.(); | |
| flying = true; | |
| hud.status.textContent = "FLIGHT ONLINE"; | |
| }); | |
| document.addEventListener("pointerlockchange", () => { | |
| if (document.pointerLockElement !== canvas && flying) hud.status.textContent = "CURSOR FREE · CLICK TO RESUME"; | |
| }); | |
| canvas.addEventListener("click", () => { if (flying) canvas.requestPointerLock?.(); }); | |
| document.addEventListener("mousemove", (e) => { | |
| if (document.pointerLockElement !== canvas) return; | |
| yaw.rotation.y -= e.movementX * 0.0021; | |
| pitch.rotation.x = THREE.MathUtils.clamp(pitch.rotation.x - e.movementY * 0.0021, -1.45, 1.45); | |
| }); | |
| document.addEventListener("keydown", (e) => keys.add(e.code)); | |
| document.addEventListener("keyup", (e) => keys.delete(e.code)); | |
| window.addEventListener("resize", () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| composer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| function updateFlight(dt) { | |
| move.set(0, 0, 0); | |
| if (keys.has("KeyW") || keys.has("ArrowUp")) move.z -= 1; | |
| if (keys.has("KeyS") || keys.has("ArrowDown")) move.z += 1; | |
| if (keys.has("KeyA") || keys.has("ArrowLeft")) move.x -= 1; | |
| if (keys.has("KeyD") || keys.has("ArrowRight")) move.x += 1; | |
| if (keys.has("Space")) move.y += 1; | |
| if (keys.has("ShiftLeft") && keys.has("KeyW")) move.z -= 2.2; // boost | |
| if (keys.has("ControlLeft") || keys.has("KeyC")) move.y -= 1; | |
| const boost = keys.has("ShiftLeft") ? 2.4 : 1; | |
| const speed = 420 * boost; | |
| const q = new THREE.Quaternion(); | |
| camera.getWorldQuaternion(q); | |
| move.applyQuaternion(q); | |
| velocity.lerp(move.multiplyScalar(speed), 0.06); | |
| yaw.position.addScaledVector(velocity, dt); | |
| } | |
| // ---------------------------------------------------------------- main loop | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const dt = Math.min(clock.getDelta(), 0.05); | |
| const t = clock.elapsedTime; | |
| if (flying) updateFlight(dt); | |
| // nearest body + prefetch + reveal | |
| let best = null, bestD = Infinity; | |
| for (const entry of bodies) { | |
| const d = entry.group.position.distanceTo(yaw.position); | |
| for (const fn of entry.animate) fn(t); | |
| if (entry.group.userData.ringMat) entry.group.userData.ringMat.uniforms.time.value = t; | |
| entry.far.material.opacity = THREE.MathUtils.clamp((d - 600) / 4000, 0.0, 0.95); | |
| const trigger = entry.data.visual.radius * 26 + 900; | |
| if (d < trigger) requestSector(entry); | |
| if (d < bestD) { bestD = d; best = entry; } | |
| } | |
| if (best) { | |
| const approach = best.data.visual.radius * 14 + 600; | |
| if (bestD < approach) { | |
| if (nearest !== best) { nearest = best; best.revealed = false; showCard(best); } | |
| if (!best.payload && best.requested) requestSector(best); | |
| if (best.payload && hud.science.textContent.startsWith("Authoring")) showCard(best); | |
| hud.distance.textContent = `${Math.round(bestD)} u`; | |
| hud.status.textContent = bestD < best.data.visual.radius * 5 + 200 ? "ARRIVAL" : "APPROACH"; | |
| if (bestD < best.data.visual.radius * 6 + 260) revealTransmission(best); | |
| } else if (nearest && bestD > approach * 1.4) { | |
| nearest = null; | |
| hud.card.classList.add("hidden"); | |
| if (flying) hud.status.textContent = "CRUISING"; | |
| } | |
| } | |
| if (flying) maybeEvent(t); | |
| composer.render(); | |
| } | |
| // ---------------------------------------------------------------- boot | |
| loadHygStars(); | |
| backdropNebulae(); | |
| loadUniverse(); | |
| animate(); | |