Spaces:
Sleeping
Sleeping
| 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); | |
| } | |