Download index.html from One/Space: direct link, hf CLI and curl.
- Browser
- Download file 91.3 kB
-
https://huggingface.co/spaces/One/Space/resolve/main/index.html
- Command line
-
hf download hf://spaces/One/Space/index.html
-
curl -L -o index.html https://huggingface.co/spaces/One/Space/resolve/main/index.html
91.3 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
| <title>Star Drift — Space Flight Explorer</title> | |
| <style> | |
| html, body { | |
| margin: 0; | |
| padding: 0; | |
| width: 100%; | |
| height: 100%; | |
| overflow: hidden; | |
| background: #050510; | |
| cursor: none; /* native cursor hidden; a fading one is drawn on the canvas */ | |
| font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; | |
| -webkit-user-select: none; | |
| user-select: none; | |
| } | |
| #game { | |
| display: block; | |
| width: 100%; | |
| height: 100%; | |
| cursor: none; | |
| touch-action: none; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="game"></canvas> | |
| <script> | |
| (function () { | |
| 'use strict'; | |
| const canvas = document.getElementById('game'); | |
| const ctx = canvas.getContext('2d'); | |
| let W = 0, H = 0, DPR = 1, CX = 0, CY = 0, FOCAL = 0; | |
| // Static layers are baked to bitmaps in resize(): they only change when the | |
| // window size changes, so there is no reason to re-evaluate a gradient over | |
| // every pixel of every frame. | |
| let bgLayer = null, vignetteLayer = null; | |
| function bakeLayer(w, h, paint) { | |
| const cv = document.createElement('canvas'); | |
| cv.width = Math.floor(w * DPR); | |
| cv.height = Math.floor(h * DPR); | |
| const c2 = cv.getContext('2d'); | |
| c2.setTransform(DPR, 0, 0, DPR, 0, 0); | |
| paint(c2); | |
| return cv; | |
| } | |
| function clamp(v, a, b) { | |
| return Math.max(a, Math.min(b, v)); | |
| } | |
| // ------------------------------------------------------------------ | |
| // Perf config — the tunables introduced by the performance pass. | |
| // ------------------------------------------------------------------ | |
| const PERF = { | |
| blobMaxR: 80, // galactic-band blobs are capped at this many screen px | |
| sortAsteroids: false // a rock is convex, so backface-culled faces cannot | |
| // overlap in projection and painter order is dead work. | |
| // Measured over random tumbles: 0% overlap on an | |
| // undamaged rock. Crater displacement breaks convexity: | |
| // 1.75% of drawn face area overlaps, and 0.9% would be | |
| // painted in the wrong order (a crater floor over its | |
| // rim). Set true to restore the exact guarantee. | |
| }; | |
| // Quantised alpha strings. The hot loops used to build one rgba() string per | |
| // particle per frame; now they index a table built once. | |
| const ALPHA_STR = new Array(64); | |
| for (let i = 0; i < 64; i++) ALPHA_STR[i] = (i / 63).toFixed(3); | |
| function alphaStr(a) { | |
| return ALPHA_STR[Math.min(63, (a * 63) | 0)]; | |
| } | |
| // rgb() strings cached per distinct colour. Asteroid faces redraw the same | |
| // handful of shades thousands of times per frame. The cache is bounded: a face | |
| // colour depends on its fixed base tint times a lighting value that varies | |
| // continuously, so over a long session it could drift. Cap it and rebuild. | |
| const RGB_CACHE = new Map(); | |
| const RGB_CACHE_MAX = 4000; | |
| function rgbStr(r, g, b) { | |
| if (RGB_CACHE.size > RGB_CACHE_MAX) RGB_CACHE.clear(); | |
| const key = (r << 16) | (g << 8) | b; | |
| let s = RGB_CACHE.get(key); | |
| if (s === undefined) { | |
| s = 'rgb(' + r + ',' + g + ',' + b + ')'; | |
| RGB_CACHE.set(key, s); | |
| } | |
| return s; | |
| } | |
| // pow(x, 0.8) for asteroid lighting, quantised to 1/256 (a few gray levels, | |
| // not visible against the per-vertex mottling). | |
| const LIT_LUT = new Array(256); | |
| for (let i = 0; i < 256; i++) LIT_LUT[i] = Math.pow(i / 256, 0.8); | |
| // Reused projection targets so the render loop allocates nothing. P_ONE / | |
| // P_TWO / P_CENTER are for a few simultaneous points; P_VERTS, P_RING and | |
| // P_FIL hold batches that must survive across a loop. | |
| function makePool(n) { | |
| const a = new Array(n); | |
| for (let i = 0; i < n; i++) a[i] = { x: 0, y: 0, z: 0, fade: 0 }; | |
| return a; | |
| } | |
| const P_ONE = makePool(1)[0], P_TWO = makePool(1)[0], P_THREE = makePool(1)[0], P_CENTER = makePool(1)[0]; | |
| const P_VERTS = makePool(128); // asteroid rocks are 42 verts, the ship is 19 | |
| const P_RING = makePool(128); // 57 ring points | |
| const P_FIL = makePool(128); // catmullRom output is at most (10-1)*7+1 = 64 | |
| const P_OK = new Uint8Array(128); // 1 = this pooled point is valid | |
| const RING_FRONT = new Uint8Array(128); // 0 = invalid, 1 = behind, 2 = in front | |
| const SHIP_W = [0, 0, 0]; // scratch for shipWorldPoint | |
| const AST_LOCAL = new Float64Array(128 * 3); // rotated local verts (backface test) | |
| // Run-length grouping for filament strokes: consecutive segments that quantise | |
| // to the same alpha are drawn as one path with one stroke. Alpha is monotonic | |
| // in distance, so a run sits at a similar depth and can share one width. This | |
| // is the only change in the file that is not exact: alpha is quantised to | |
| // 1/63 (max error 0.016 on these alphas) and widths are averaged per run. | |
| function flushRun(colorStr, bucket, cnt, wsum) { | |
| if (!cnt) return; | |
| ctx.strokeStyle = 'rgba(' + colorStr + ',' + ALPHA_STR[bucket] + ')'; | |
| ctx.lineWidth = wsum / cnt; | |
| ctx.stroke(); | |
| } | |
| // Reused face records (ship + asteroids). | |
| const FACE_REC = new Array(128); | |
| for (let i = 0; i < FACE_REC.length; i++) FACE_REC[i] = { a: 0, b: 0, c: 0, z: 0, k: '', col: '' }; | |
| const faceList = []; | |
| // Shared far-first painter list (nebulae / galaxies / planets / asteroids). | |
| const ENTRY = new Array(256); | |
| for (let i = 0; i < ENTRY.length; i++) { | |
| ENTRY[i] = { it: null, x: 0, y: 0, z: 0, r: 0, dist: 0, fade: 0, dx: 0, dy: 0, dz: 0 }; | |
| } | |
| const list = []; | |
| function resize() { | |
| DPR = Math.min(window.devicePixelRatio || 1, 2); | |
| W = window.innerWidth; | |
| H = window.innerHeight; | |
| canvas.width = Math.floor(W * DPR); | |
| canvas.height = Math.floor(H * DPR); | |
| canvas.style.width = W + 'px'; | |
| canvas.style.height = H + 'px'; | |
| ctx.setTransform(DPR, 0, 0, DPR, 0, 0); | |
| CX = W / 2; | |
| CY = H / 2; | |
| FOCAL = Math.max(1, H) * 0.9; | |
| bgLayer = bakeLayer(W, H, (c2) => { | |
| const g = c2.createLinearGradient(0, 0, 0, H); | |
| g.addColorStop(0, '#050510'); | |
| g.addColorStop(0.5, '#070718'); | |
| g.addColorStop(1, '#050510'); | |
| c2.fillStyle = g; | |
| c2.fillRect(0, 0, W, H); | |
| }); | |
| // cinematic vignette (not UI — just darkened corners) | |
| vignetteLayer = bakeLayer(W, H, (c2) => { | |
| const g = c2.createRadialGradient(CX, CY, Math.min(W, H) * 0.34, CX, CY, Math.hypot(W, H) * 0.72); | |
| g.addColorStop(0, 'rgba(0,0,0,0)'); | |
| g.addColorStop(0.62, 'rgba(2,3,10,0.16)'); | |
| g.addColorStop(1, 'rgba(2,3,12,0.48)'); | |
| c2.fillStyle = g; | |
| c2.fillRect(0, 0, W, H); | |
| }); | |
| } | |
| window.addEventListener('resize', resize); | |
| resize(); | |
| // ------------------------------------------------------------------ | |
| // 3D math helpers (plain objects, no libraries) | |
| // ------------------------------------------------------------------ | |
| function sub(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; } | |
| function dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } | |
| function cross(a, b) { | |
| return [ | |
| a[1] * b[2] - a[2] * b[1], | |
| a[2] * b[0] - a[0] * b[2], | |
| a[0] * b[1] - a[1] * b[0] | |
| ]; | |
| } | |
| function norm(a) { | |
| const l = Math.hypot(a[0], a[1], a[2]) || 1; | |
| return [a[0] / l, a[1] / l, a[2] / l]; | |
| } | |
| function len(a) { return Math.hypot(a[0], a[1], a[2]); } | |
| const TAU = Math.PI * 2; | |
| // Rodrigues rotation of v about a unit axis — the primitive the steering | |
| // uses to swing the nose along one continuous arc. | |
| function rotateAbout(v, axis, ang) { | |
| const c = Math.cos(ang), s = Math.sin(ang), t = 1 - c; | |
| const k = axis[0] * v[0] + axis[1] * v[1] + axis[2] * v[2]; | |
| const cx = axis[1] * v[2] - axis[2] * v[1]; | |
| const cy = axis[2] * v[0] - axis[0] * v[2]; | |
| const cz = axis[0] * v[1] - axis[1] * v[0]; | |
| return [ | |
| v[0] * c + cx * s + axis[0] * k * t, | |
| v[1] * c + cy * s + axis[1] * k * t, | |
| v[2] * c + cz * s + axis[2] * k * t | |
| ]; | |
| } | |
| function smoothstep(t) { return t * t * (3 - 2 * t); } | |
| function dot3(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } | |
| // Ship state: position, velocity, and the nose as a real 3D unit vector. | |
| // heading / pitch / bank are read back out of that vector for the camera | |
| // and the mesh, so everything stays consistent. | |
| const ship = { | |
| pos: [0, 0, 0], | |
| vel: [0, 0, 0], | |
| fwd: [0, 0, -1], // unit nose direction; heading 0 = facing -Z | |
| omega: [0, 0, 0], // live angular velocity (rad/s) of the nose | |
| heading: 0, | |
| pitch: 0, | |
| bank: 0, | |
| throttle: 0 | |
| }; | |
| // Turning: the nose is rotated about the single axis perpendicular to the | |
| // plane spanned by (current nose, click target) — one continuous great-circle | |
| // arc instead of independent yaw and pitch ramps. The turn rate is | |
| // proportional to the remaining error (so it eases to a stop, never slams), | |
| // capped (so it stays slow and readable) and slew-limited (so it ramps up | |
| // instead of snapping). Result: soft start, gentle peak, soft arrival. | |
| const TURN_MAX = 0.6; // rad/s ceiling on the turn (~34 deg/s) | |
| const TURN_GAIN = 0.8; // proportional ease-out toward the target | |
| const TURN_SLEW = 0.85; // rad/s^2 — how fast the turn can build or relax | |
| const MAX_PITCH = 1.30; // ~74 deg nose authority (keeps the level camera sane) | |
| const PITCH_SIN = Math.sin(MAX_PITCH); | |
| const ACCEL = 88; // thrust strength | |
| const DRAG = 0.95; // exponential drag coefficient | |
| const MAX_SPEED = 118; // hard speed cap | |
| const THROTTLE_RATE = 1.6; // how fast the engines ease in / out | |
| const ENGINE_LOCAL = [[-0.3, 0.12, 1.32], [0.3, 0.12, 1.32]]; | |
| // The turn axis is remembered between frames so the nose cannot jitter as | |
| // it converges (see update()). | |
| const turnAxis = [0, 0, 1]; | |
| // ------------------------------------------------------------------ | |
| // Click-to-fly: click anywhere and the ship turns to face that screen | |
| // direction, then keeps flying that way forever until the next click. | |
| // The click is un-projected through the current camera basis, so the | |
| // ship always aims exactly at the spot you click (WYSIWYG aiming). | |
| // ------------------------------------------------------------------ | |
| let hasTarget = false; | |
| const targetFwd = [0, 0, -1]; // unit world direction the nose steers toward | |
| let lastCam = null; // camera basis from the last rendered frame | |
| function setTargetFromClick(mx, my) { | |
| if (!lastCam) return; | |
| // screen point -> camera-space ray direction (x right, y up, z forward) | |
| let dx = mx - CX, dy = CY - my, dz = FOCAL; | |
| const l = Math.hypot(dx, dy, dz) || 1; | |
| dx /= l; dy /= l; dz /= l; | |
| // camera space -> world space via the camera basis vectors | |
| const wx = dx * lastCam.xAxis[0] + dy * lastCam.yAxis[0] + dz * lastCam.zAxis[0]; | |
| const wy = dx * lastCam.xAxis[1] + dy * lastCam.yAxis[1] + dz * lastCam.zAxis[1]; | |
| const wz = dx * lastCam.xAxis[2] + dy * lastCam.yAxis[2] + dz * lastCam.zAxis[2]; | |
| const h = Math.atan2(wx, -wz); | |
| const p = clamp(Math.asin(clamp(wy, -1, 1)), -MAX_PITCH, MAX_PITCH); | |
| const cp = Math.cos(p); | |
| targetFwd[0] = Math.sin(h) * cp; | |
| targetFwd[1] = Math.sin(p); | |
| targetFwd[2] = -Math.cos(h) * cp; | |
| hasTarget = true; | |
| } | |
| // ------------------------------------------------------------------ | |
| // Cursor: fades out 1 second after the mouse stops moving, and fades | |
| // back in as soon as it moves again. | |
| // ------------------------------------------------------------------ | |
| let mouseX = CX, mouseY = CY; | |
| let lastMove = -1e9; // long ago -> cursor starts hidden | |
| let cursorAlpha = 0; | |
| canvas.addEventListener('pointerdown', (e) => { | |
| setTargetFromClick(e.clientX, e.clientY); | |
| lastMove = performance.now(); | |
| }); | |
| canvas.addEventListener('pointermove', (e) => { | |
| mouseX = e.clientX; | |
| mouseY = e.clientY; | |
| lastMove = performance.now(); | |
| }); | |
| // Fixed: use canvas mouseleave, not document mouseleave. | |
| canvas.addEventListener('mouseleave', () => { | |
| lastMove = -1e9; | |
| }); | |
| window.addEventListener('blur', () => { | |
| lastMove = -1e9; | |
| }); | |
| // ------------------------------------------------------------------ | |
| // World objects | |
| // ------------------------------------------------------------------ | |
| const STAR_COUNT = 15000; | |
| const stars = []; | |
| const starColors = [ | |
| [255, 255, 255], [150, 180, 255], [255, 225, 140], [255, 140, 130] | |
| ]; | |
| const starStyle = starColors.map(c => 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'); | |
| const FIELD = 2600; // respawn half-extent (infinite space wrap) | |
| function makeStar() { | |
| return { | |
| x: (Math.random() * 2 - 1) * FIELD, | |
| y: (Math.random() * 2 - 1) * FIELD, | |
| z: (Math.random() * 2 - 1) * FIELD, | |
| ci: (Math.random() * 4) | 0, | |
| s: Math.random() < 0.18 ? 2 : 1 | |
| }; | |
| } | |
| for (let i = 0; i < STAR_COUNT; i++) stars.push(makeStar()); | |
| // Seeded value noise + fBm (procedural detail for nebulae and asteroids) | |
| function makeNoise(seed) { | |
| const rnd = (h) => { | |
| const s = Math.sin(h * 127.1 + seed * 311.7) * 43758.5453; | |
| return s - Math.floor(s); | |
| }; | |
| const grad = (x, y) => rnd(Math.floor(x) * 157 + Math.floor(y) * 113); | |
| const smooth = (t) => t * t * (3 - 2 * t); | |
| function noise(x, y) { | |
| const xi = Math.floor(x), yi = Math.floor(y); | |
| const xf = x - xi, yf = y - yi; | |
| const u = smooth(xf), v = smooth(yf); | |
| const a = grad(xi, yi), b = grad(xi + 1, yi); | |
| const c = grad(xi, yi + 1), d = grad(xi + 1, yi + 1); | |
| return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; | |
| } | |
| function fbm(x, y, oct = 4) { | |
| let v = 0, amp = 0.5, f = 1, tot = 0; | |
| for (let o = 0; o < oct; o++) { | |
| v += noise(x * f, y * f) * amp; | |
| tot += amp; | |
| amp *= 0.5; | |
| f *= 2; | |
| } | |
| return v / tot; | |
| } | |
| return { noise, fbm }; | |
| } | |
| // Nebulae — wrapped in a box 2x the ship field so they can exist far away | |
| // and fade in from invisible instead of popping into view. | |
| const FIELD_NEB = FIELD * 2; | |
| const NEBULA_COUNT = 16; // 8x the volume of the old box -> same density | |
| const nebulae = []; | |
| const nebPalette = [ | |
| [255, 120, 200], [110, 170, 255], [120, 230, 170], | |
| [255, 170, 90], [180, 120, 255] | |
| ]; | |
| // Pre-render each nebula into a wispy gas-cloud texture: domain-warped fBm | |
| // filaments, dark dust lanes, bright emission cores and embedded stars — | |
| // so nebulae read as real clouds, not flat gradient circles. | |
| function makeNebulaTexture(c, seed) { | |
| const S = 128; | |
| const cv = document.createElement('canvas'); | |
| cv.width = cv.height = S; | |
| const c2 = cv.getContext('2d'); | |
| const nz = makeNoise(seed); | |
| const img = c2.createImageData(S, S); | |
| const data = img.data; | |
| const R = S * 0.5; | |
| for (let y = 0; y < S; y++) { | |
| for (let x = 0; x < S; x++) { | |
| const dx = x - R, dy = y - R; | |
| const d = Math.hypot(dx, dy) / R; | |
| if (d >= 1) continue; | |
| const ang = Math.atan2(dy, dx); | |
| // domain-warped fBm -> swirling gas filaments | |
| const q1 = nz.fbm(dx * 0.05 + 5.2, dy * 0.05 + 1.3, 4); | |
| const q2 = nz.fbm(dx * 0.05 + 9.1, dy * 0.05 + 7.7, 4); | |
| const n = nz.fbm(dx * 0.06 + q1 * 8 + ang * 0.9, dy * 0.06 + q2 * 8, 5); | |
| const fil = Math.pow(n, 2.3); | |
| // dark dust lanes | |
| const dust = nz.fbm(dx * 0.08 + 21.4, dy * 0.08 + 33.9, 3); | |
| const lane = dust < 0.42 ? (0.42 - dust) * 2.2 : 0; | |
| const soft = 1 - d; | |
| const a = Math.max(0, fil * 1.9 - lane) * soft * soft; | |
| const i = (y * S + x) * 4; | |
| const hot = Math.pow(n, 6) * 120; // bright cores along filaments | |
| data[i] = Math.min(255, c[0] + hot); | |
| data[i + 1] = Math.min(255, c[1] + hot * 0.9); | |
| data[i + 2] = Math.min(255, c[2] + hot * 0.8); | |
| data[i + 3] = Math.min(255, a * 255); | |
| } | |
| } | |
| c2.putImageData(img, 0, 0); | |
| // a few faint background stars seen through the gas | |
| for (let k = 0; k < 46; k++) { | |
| const sx = nz.noise(k * 3.1, 7.7) * S; | |
| const sy = nz.noise(2.2, k * 5.7) * S; | |
| const sd = Math.hypot(sx - R, sy - R) / R; | |
| if (sd > 0.85) continue; | |
| c2.fillStyle = 'rgba(255,255,255,' + (0.2 + nz.noise(k, k) * 0.55).toFixed(2) + ')'; | |
| c2.fillRect(sx, sy, 1, 1); | |
| } | |
| return cv; | |
| } | |
| const nebulaTex = nebPalette.map((c, i) => makeNebulaTexture(c, 1234 + i * 77)); | |
| function makeNebula() { | |
| const ci = (Math.random() * nebPalette.length) | 0; | |
| return { | |
| x: (Math.random() * 2 - 1) * FIELD_NEB, | |
| y: (Math.random() * 2 - 1) * FIELD_NEB * 0.6, | |
| z: (Math.random() * 2 - 1) * FIELD_NEB, | |
| r: 700 + Math.random() * 1000, | |
| ci, | |
| rot: Math.random() * Math.PI * 2 | |
| }; | |
| } | |
| for (let i = 0; i < NEBULA_COUNT; i++) nebulae.push(makeNebula()); | |
| // Asteroids — irregular gray polyhedra | |
| // Asteroids wrap in a box 3x the ship field — a huge runway so they can | |
| // fade in from invisible over many seconds instead of popping into view. | |
| const FIELD_AST = FIELD * 3; | |
| const ASTEROID_COUNT = 150; // 150 rocks in a 27x-larger box keeps the same encounter density as before | |
| const asteroids = []; | |
| function makeAsteroid() { | |
| // Icosahedron base, one subdivision -> 42 verts / 80 triangles | |
| const T = (1 + Math.sqrt(5)) / 2; | |
| let verts = [ | |
| [-1, T, 0], [1, T, 0], [-1, -T, 0], [1, -T, 0], | |
| [0, -1, T], [0, 1, T], [0, -1, -T], [0, 1, -T], | |
| [T, 0, -1], [T, 0, 1], [-T, 0, -1], [-T, 0, 1] | |
| ]; | |
| const il = Math.hypot(1, T); | |
| verts = verts.map(p => [p[0] / il, p[1] / il, p[2] / il]); | |
| const F = [ | |
| [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], | |
| [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], | |
| [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], | |
| [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1] | |
| ]; | |
| let faces = F.map(f => f.slice()); | |
| const midCache = {}; | |
| function midpoint(a, b) { | |
| const key = a < b ? a + '_' + b : b + '_' + a; | |
| const hit = midCache[key]; | |
| if (hit !== undefined) return hit; | |
| const pa = verts[a], pb = verts[b]; | |
| let m = [(pa[0] + pb[0]) / 2, (pa[1] + pb[1]) / 2, (pa[2] + pb[2]) / 2]; | |
| const l = Math.hypot(m[0], m[1], m[2]) || 1; | |
| m = [m[0] / l, m[1] / l, m[2] / l]; | |
| verts.push(m); | |
| midCache[key] = verts.length - 1; | |
| return midCache[key]; | |
| } | |
| const nextF = []; | |
| for (const f of faces) { | |
| const ab = midpoint(f[0], f[1]); | |
| const bc = midpoint(f[1], f[2]); | |
| const ca = midpoint(f[2], f[0]); | |
| nextF.push([f[0], ab, ca], [ab, f[1], bc], [ca, bc, f[2]], [ab, bc, ca]); | |
| } | |
| faces = nextF; | |
| // random tumble of the whole shape | |
| const rz = Math.random() * Math.PI * 2, ry = Math.random() * Math.PI * 2; | |
| const rx = (Math.random() - 0.5) * 1.1; | |
| verts = verts.map(p => { | |
| let x = p[0], y = p[1], z = p[2]; | |
| let t = x * Math.cos(rz) - y * Math.sin(rz); | |
| y = x * Math.sin(rz) + y * Math.cos(rz); x = t; | |
| t = y * Math.cos(rx) - z * Math.sin(rx); | |
| z = y * Math.sin(rx) + z * Math.cos(rx); y = t; | |
| t = x * Math.cos(ry) + z * Math.sin(ry); | |
| z = -x * Math.sin(ry) + z * Math.cos(ry); x = t; | |
| return [x, y, z]; | |
| }); | |
| // lumpy regolith displacement | |
| const nz = makeNoise(3000 + ((Math.random() * 9999) | 0)); | |
| verts = verts.map(p => { | |
| const k = 1 + 0.16 * (nz.fbm(p[0] * 2.3 + 5, p[1] * 2.3 + 9) - 0.5) | |
| + 0.10 * (nz.fbm(p[1] * 5.1 + 3, p[2] * 5.1 + 7) - 0.5); | |
| return [p[0] * k, p[1] * k, p[2] * k]; | |
| }); | |
| // mottled per-vertex tint | |
| const seed = Math.random() * 97; | |
| const vcol = verts.map(p => { | |
| const g = 0.50 + (nz.fbm(p[0] * 3.7 + seed, p[2] * 3.7 + seed) - 0.5) * 0.42; | |
| return [g, g, g * 1.05]; | |
| }); | |
| // craters: sink a random vertex, raise its neighbours into a rim | |
| const nv = verts.length; | |
| const craters = 8 + ((Math.random() * 7) | 0); | |
| for (let k = 0; k < craters; k++) { | |
| const vi = (Math.random() * nv) | 0; | |
| const dir = verts[vi]; | |
| const l = Math.hypot(dir[0], dir[1], dir[2]) || 1; | |
| const depth = 0.12 + Math.random() * 0.14; | |
| verts[vi] = [dir[0] / l * (1 - depth), dir[1] / l * (1 - depth), dir[2] / l * (1 - depth)]; | |
| vcol[vi][0] -= 0.10; vcol[vi][1] -= 0.10; vcol[vi][2] -= 0.08; | |
| const seen = {}; | |
| for (const f of faces) { | |
| if (f[0] !== vi && f[1] !== vi && f[2] !== vi) continue; | |
| for (const r of [f[0], f[1], f[2]]) { | |
| if (r === vi || seen[r]) continue; | |
| seen[r] = 1; | |
| const rr = verts[r]; | |
| const rl = Math.hypot(rr[0], rr[1], rr[2]) || 1; | |
| const lift = 1 + depth * 0.8; | |
| verts[r] = [rr[0] / rl * lift, rr[1] / rl * lift, rr[2] / rl * lift]; | |
| vcol[r][0] += 0.14; vcol[r][1] += 0.14; vcol[r][2] += 0.11; | |
| } | |
| } | |
| } | |
| // per-face base colour (crater floors dark, rims bright) | |
| const faceCol = faces.map(f => { | |
| let r = 0, g = 0, b = 0; | |
| for (const vi of f) { r += vcol[vi][0]; g += vcol[vi][1]; b += vcol[vi][2]; } | |
| r /= 3; g /= 3; b /= 3; | |
| const cc = v => Math.max(0, Math.min(1, v)) * 255; | |
| return [cc(r), cc(g), cc(b)]; | |
| }); | |
| // Per-face unit normals, computed once here. drawAsteroids used to rebuild | |
| // the cross product and a normalising sqrt for all 80 faces of every rock | |
| // every frame. Rotating a stored unit normal by the tumble matrix is 9 | |
| // multiplies, and because that matrix is a pure rotation the normal stays | |
| // unit length — so no renormalising is needed at draw time. | |
| const faceNormal = faces.map(f => { | |
| const v0 = verts[f[0]], v1 = verts[f[1]], v2 = verts[f[2]]; | |
| const nx = (v1[1] - v0[1]) * (v2[2] - v0[2]) - (v1[2] - v0[2]) * (v2[1] - v0[1]); | |
| const ny = (v1[2] - v0[2]) * (v2[0] - v0[0]) - (v1[0] - v0[0]) * (v2[2] - v0[2]); | |
| const nz = (v1[0] - v0[0]) * (v2[1] - v0[1]) - (v1[1] - v0[1]) * (v2[0] - v0[0]); | |
| const l = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1; | |
| return [nx / l, ny / l, nz / l]; | |
| }); | |
| const scale = 14 + Math.random() * 22; | |
| return { | |
| x: (Math.random() * 2 - 1) * FIELD_AST, | |
| y: (Math.random() * 2 - 1) * FIELD_AST * 0.7, | |
| z: (Math.random() * 2 - 1) * FIELD_AST, | |
| verts, faces, faceNormal, scale, faceCol, | |
| spin: [Math.random() * 0.8 - 0.4, Math.random() * 0.8 - 0.4, Math.random() * 0.8 - 0.4], | |
| rot: [Math.random() * 6, Math.random() * 6, Math.random() * 6], | |
| shade: 90 + Math.random() * 50 | |
| }; | |
| } | |
| for (let i = 0; i < ASTEROID_COUNT; i++) asteroids.push(makeAsteroid()); | |
| // ------------------------------------------------------------------ | |
| // Deep-space scenery: a galactic band across the whole sky, filamentary | |
| // strands of stars and gas, spiral galaxies, sunlit planets, meteor | |
| // streaks, near-field dust and engine exhaust. All procedural, all | |
| // wrapping or direction-based, so the flight never runs out of sky. | |
| // ------------------------------------------------------------------ | |
| const SUN_DIR = norm([0.42, 0.58, -0.7]); | |
| // Soft glow sprites — far cheaper than a gradient per particle. | |
| function makeSoftDot(rgb, size) { | |
| const cv = document.createElement('canvas'); | |
| cv.width = cv.height = size; | |
| const c2 = cv.getContext('2d'); | |
| const g = c2.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2); | |
| g.addColorStop(0, 'rgba(' + rgb + ',1)'); | |
| g.addColorStop(0.22, 'rgba(' + rgb + ',0.5)'); | |
| g.addColorStop(0.55, 'rgba(' + rgb + ',0.13)'); | |
| g.addColorStop(1, 'rgba(' + rgb + ',0)'); | |
| c2.fillStyle = g; | |
| c2.fillRect(0, 0, size, size); | |
| return cv; | |
| } | |
| const softCool = makeSoftDot('190,230,255', 64); | |
| const softWarm = makeSoftDot('255,226,190', 64); | |
| // Cockpit halo as a bitmap too — the ship is drawn every frame, and building | |
| // a radial gradient per frame is the expensive part, not the fill. | |
| const HALO_R = 52; | |
| const cockpitGlow = (function () { | |
| const cv = document.createElement('canvas'); | |
| cv.width = cv.height = HALO_R; | |
| const c2 = cv.getContext('2d'); | |
| const g = c2.createRadialGradient(HALO_R / 2, HALO_R / 2, 0, HALO_R / 2, HALO_R / 2, HALO_R / 2); | |
| g.addColorStop(0, 'rgba(120,225,255,0.55)'); | |
| g.addColorStop(1, 'rgba(120,225,255,0)'); | |
| c2.fillStyle = g; | |
| c2.fillRect(0, 0, HALO_R, HALO_R); | |
| return cv; | |
| })(); | |
| // ---- galactic band ------------------------------------------------ | |
| // Direction-only: no wrap and no parallax, so it behaves like real | |
| // infinity and can never pop into view. | |
| const SKY_R = 13000; | |
| const GAL_N = norm([0.45, 0.72, 0.53]); // galactic plane normal | |
| const GAL_U = norm(cross(GAL_N, [0, 0, 1])); | |
| const GAL_V = norm(cross(GAL_N, GAL_U)); | |
| const skyNoise = makeNoise(4242); | |
| function bandDir(a, off) { | |
| const ca = Math.cos(a), sa = Math.sin(a); | |
| return norm([ | |
| GAL_U[0] * ca + GAL_V[0] * sa + GAL_N[0] * off, | |
| GAL_U[1] * ca + GAL_V[1] * sa + GAL_N[1] * off, | |
| GAL_U[2] * ca + GAL_V[2] * sa + GAL_N[2] * off | |
| ]); | |
| } | |
| const bandBlobs = []; | |
| const bandStars = []; | |
| { | |
| const N = 420; | |
| for (let i = 0; i < N; i++) { | |
| const a = i / N * TAU, ca = Math.cos(a), sa = Math.sin(a); | |
| const b = skyNoise.fbm(ca * 2.4 + 11, sa * 2.4 + 5, 4); | |
| const dust = skyNoise.fbm(ca * 5.5 + 47, sa * 5.5 + 19, 3); | |
| const off = (skyNoise.noise(ca * 4.3, sa * 4.3) - 0.5) * 0.26; | |
| // fbm brightness minus dark dust lanes where the noise dips | |
| const bright = Math.max(0, b * 1.5 - (dust < 0.44 ? (0.44 - dust) * 2.8 : 0)); | |
| if (bright < 0.04) continue; | |
| bandBlobs.push({ | |
| dir: bandDir(a, off), | |
| r: 1500 + b * 3400, | |
| a: Math.min(0.17, bright * 0.13), | |
| tex: b > 0.58 ? softWarm : softCool | |
| }); | |
| } | |
| for (let i = 0; i < 2600; i++) { | |
| const a = Math.random() * TAU; | |
| const g = (Math.random() + Math.random() + Math.random() - 1.5) * 0.3; // hug the plane | |
| bandStars.push({ | |
| dir: bandDir(a, g), | |
| s: Math.random() < 0.1 ? 2 : 1, | |
| b: (Math.random() * 3) | 0, | |
| c: (Math.random() * 3) | 0 | |
| }); | |
| } | |
| } | |
| // ---- galactic filaments (cosmic web strands) ---------------------- | |
| const FIELD_FIL = FIELD * 4; | |
| const FIL_COUNT = 26; | |
| const filPalette = [ | |
| [120, 170, 255], [160, 200, 255], [140, 235, 220], [255, 200, 160], [200, 160, 255] | |
| ]; | |
| function catmullRom(points, per) { | |
| const out = [], n = points.length; | |
| for (let i = 0; i < n - 1; i++) { | |
| const p0 = points[Math.max(0, i - 1)], p1 = points[i]; | |
| const p2 = points[i + 1], p3 = points[Math.min(n - 1, i + 2)]; | |
| for (let s = 0; s < per; s++) { | |
| const t = s / per, t2 = t * t, t3 = t2 * t; | |
| out.push([ | |
| 0.5 * (2 * p1[0] + (-p0[0] + p2[0]) * t + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3), | |
| 0.5 * (2 * p1[1] + (-p0[1] + p2[1]) * t + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3), | |
| 0.5 * (2 * p1[2] + (-p0[2] + p2[2]) * t + (2 * p0[2] - 5 * p1[2] + 4 * p2[2] - p3[2]) * t2 + (-p0[2] + 3 * p1[2] - 3 * p2[2] + p3[2]) * t3) | |
| ]); | |
| } | |
| } | |
| out.push(points[n - 1].slice()); | |
| return out; | |
| } | |
| function makeFilament() { | |
| const seed = (Math.random() * 9999) | 0; | |
| const nz = makeNoise(seed); | |
| const n = 5 + ((Math.random() * 5) | 0); | |
| let dir = norm([Math.random() * 2 - 1, (Math.random() * 2 - 1) * 0.55, Math.random() * 2 - 1]); | |
| let x = (Math.random() * 2 - 1) * FIELD_FIL * 0.8; | |
| let y = (Math.random() * 2 - 1) * FIELD_FIL * 0.5; | |
| let z = (Math.random() * 2 - 1) * FIELD_FIL * 0.8; | |
| const step = 500 + Math.random() * 600; | |
| const nodes = []; | |
| for (let i = 0; i < n; i++) { | |
| nodes.push([x, y, z]); | |
| const w1 = nz.noise(i * 1.7, seed * 0.01) - 0.5; | |
| const w2 = nz.noise(seed * 0.02, i * 2.3) - 0.5; | |
| dir = norm([dir[0] + w1 * 0.9, dir[1] + w2 * 0.5, dir[2] + w1 * 0.4]); | |
| x += dir[0] * step; y += dir[1] * step; z += dir[2] * step; | |
| } | |
| // faint star clusters gathered at the knots, in local offsets | |
| const knots = []; | |
| for (let i = 0; i < n; i++) { | |
| const k = []; | |
| const m = 9 + ((Math.random() * 10) | 0); | |
| for (let j = 0; j < m; j++) { | |
| k.push([(Math.random() * 2 - 1) * 150, (Math.random() * 2 - 1) * 150, (Math.random() * 2 - 1) * 150]); | |
| } | |
| knots.push(k); | |
| } | |
| const pts = catmullRom(nodes, 7); | |
| // Bounding sphere, so drawFilaments can reject a whole strand with one | |
| // distance test instead of projecting its 35 points. Headroom covers the | |
| // stroke width and the 150-unit knot star offsets. Translation by f.off | |
| // moves the sphere with the strand. | |
| let bx = 0, by = 0, bz = 0; | |
| for (const p of pts) { bx += p[0]; by += p[1]; bz += p[2]; } | |
| bx /= pts.length; by /= pts.length; bz /= pts.length; | |
| let br = 0; | |
| for (const p of pts) { | |
| const l = Math.sqrt((p[0] - bx) * (p[0] - bx) + (p[1] - by) * (p[1] - by) + (p[2] - bz) * (p[2] - bz)); | |
| if (l > br) br = l; | |
| } | |
| return { | |
| nodes, knots, pts, | |
| center: [bx, by, bz], radius: br + 200, | |
| ci: (Math.random() * filPalette.length) | 0, | |
| colorStr: '', | |
| w: 22 + Math.random() * 46, | |
| glow: 0.45 + Math.random() * 0.55, | |
| off: [0, 0, 0] | |
| }; | |
| } | |
| const filaments = []; | |
| for (let i = 0; i < FIL_COUNT; i++) { | |
| const f = makeFilament(); | |
| f.colorStr = filPalette[f.ci][0] + ',' + filPalette[f.ci][1] + ',' + filPalette[f.ci][2]; // once, not per frame | |
| filaments.push(f); | |
| } | |
| // Filaments live far away: they fade out before they reach the camera and | |
| // before a wrap can land them, so they never pop and never straddle you. | |
| function filFade(d) { | |
| if (d < 700 || d > FIELD_FIL) return 0; | |
| return smoothstep(Math.min(1, (d - 700) / 1300)) * smoothstep(Math.min(1, (FIELD_FIL - d) / 2600)); | |
| } | |
| // ---- spiral galaxies ---------------------------------------------- | |
| const FIELD_GAL = FIELD * 2.4; | |
| const GALAXY_COUNT = 12; | |
| const galPalette = [[150, 190, 255], [255, 200, 170], [170, 240, 225], [215, 175, 255]]; | |
| function makeGalaxyTexture(seed, col) { | |
| const S = 160, R = S / 2; | |
| const cv = document.createElement('canvas'); | |
| cv.width = cv.height = S; | |
| const c2 = cv.getContext('2d'); | |
| const nz = makeNoise(seed); | |
| const cs = col[0] + ',' + col[1] + ',' + col[2]; | |
| c2.globalCompositeOperation = 'lighter'; | |
| const g0 = c2.createRadialGradient(R, R, 0, R, R, R); | |
| g0.addColorStop(0, 'rgba(255,248,235,0.7)'); | |
| g0.addColorStop(0.16, 'rgba(' + cs + ',0.32)'); | |
| g0.addColorStop(0.52, 'rgba(' + cs + ',0.1)'); | |
| g0.addColorStop(1, 'rgba(' + cs + ',0)'); | |
| c2.fillStyle = g0; | |
| c2.fillRect(0, 0, S, S); | |
| const arms = 2 + (seed % 2); | |
| const twist = 2.6 + nz.noise(seed, 3) * 1.9; | |
| for (let arm = 0; arm < arms; arm++) { | |
| const base = arm * TAU / arms + nz.noise(arm * 2.1, seed) * 1.2; | |
| const steps = 110; | |
| for (let i = 1; i < steps; i++) { | |
| const t = i / steps; | |
| const rr = 8 + t * R * 0.92; | |
| const th = base + t * twist + (nz.noise(i * 0.19, arm * 3.7) - 0.5) * 0.7; | |
| const jit = (nz.noise(i * 0.63, arm * 1.9) - 0.5) * 14 * t; | |
| const x = R + Math.cos(th) * (rr + jit); | |
| const y = R + Math.sin(th) * (rr + jit) * 0.58; // tilted disc | |
| const rad = 4 + t * 9; | |
| const a = (1 - t * 0.6) * 0.17; | |
| const gg = c2.createRadialGradient(x, y, 0, x, y, rad); | |
| gg.addColorStop(0, 'rgba(' + cs + ',' + a.toFixed(3) + ')'); | |
| gg.addColorStop(1, 'rgba(' + cs + ',0)'); | |
| c2.fillStyle = gg; | |
| c2.beginPath(); c2.arc(x, y, rad, 0, TAU); c2.fill(); | |
| if (nz.noise(i * 1.31, arm * 7.7) > 0.86) { // young blue star knots | |
| c2.fillStyle = 'rgba(200,230,255,' + (0.3 + nz.noise(i, arm) * 0.4).toFixed(2) + ')'; | |
| c2.beginPath(); c2.arc(x, y, 1.3, 0, TAU); c2.fill(); | |
| } | |
| } | |
| } | |
| c2.globalCompositeOperation = 'destination-out'; | |
| for (let k = 0; k < 26; k++) { // dark dust lanes cutting the disc | |
| const t = nz.noise(k * 2.7, seed); | |
| const th = nz.noise(k * 1.1, seed * 0.3) * TAU; | |
| const rr = 12 + t * R * 0.85; | |
| const x = R + Math.cos(th) * rr, y = R + Math.sin(th) * rr * 0.58; | |
| const rad = 9 + t * 12; | |
| const gg = c2.createRadialGradient(x, y, 0, x, y, rad); | |
| gg.addColorStop(0, 'rgba(0,0,0,0.75)'); | |
| gg.addColorStop(1, 'rgba(0,0,0,0)'); | |
| c2.fillStyle = gg; | |
| c2.beginPath(); c2.arc(x, y, rad, 0, TAU); c2.fill(); | |
| } | |
| c2.globalCompositeOperation = 'source-over'; | |
| return cv; | |
| } | |
| const galaxyTex = galPalette.map((c, i) => makeGalaxyTexture(511 + i * 53, c)); | |
| function makeGalaxy() { | |
| return { | |
| x: (Math.random() * 2 - 1) * FIELD_GAL, | |
| y: (Math.random() * 2 - 1) * FIELD_GAL * 0.55, | |
| z: (Math.random() * 2 - 1) * FIELD_GAL, | |
| r: 380 + Math.random() * 720, | |
| ci: (Math.random() * galaxyTex.length) | 0, | |
| rot: Math.random() * TAU, | |
| squash: 0.4 + Math.random() * 0.6 | |
| }; | |
| } | |
| const galaxies = []; | |
| for (let i = 0; i < GALAXY_COUNT; i++) galaxies.push(makeGalaxy()); | |
| // ---- sunlit planets ----------------------------------------------- | |
| const FIELD_PLANET = FIELD * 2.2; | |
| const PLANET_COUNT = 8; | |
| const planetPal = [ | |
| [[36, 64, 118], [58, 104, 146], [214, 228, 238]], // ocean | |
| [[92, 66, 48], [132, 104, 72], [226, 216, 200]], // rocky | |
| [[48, 82, 70], [108, 140, 96], [228, 236, 224]], // verdant | |
| [[124, 86, 58], [176, 126, 84], [242, 222, 192]], // desert | |
| [[40, 52, 82], [92, 112, 152], [222, 232, 246]] // ice | |
| ]; | |
| function makePlanetTexture(seed) { | |
| const S = 128; | |
| const cv = document.createElement('canvas'); | |
| cv.width = cv.height = S; | |
| const c2 = cv.getContext('2d'); | |
| const img = c2.createImageData(S, S), data = img.data; | |
| const nz = makeNoise(seed); | |
| const pal = planetPal[seed % planetPal.length]; | |
| for (let py = 0; py < S; py++) { | |
| for (let px = 0; px < S; px++) { | |
| const dx = (px - S / 2 + 0.5) / (S / 2), dy = (py - S / 2 + 0.5) / (S / 2); | |
| const rr = dx * dx + dy * dy; | |
| const i = (py * S + px) * 4; | |
| if (rr >= 1) { data[i + 3] = 0; continue; } | |
| data[i + 3] = 255; | |
| const z = Math.sqrt(1 - rr); | |
| const lon = Math.atan2(dy, dx) + seed * 0.37; | |
| const lat = Math.asin(clamp(z, -1, 1)) - Math.PI / 2; | |
| const cl = Math.cos(lat); | |
| // seamless-in-longitude continent field | |
| const c = nz.fbm(Math.cos(lon) * 2.1 + 7, Math.sin(lon) * 2.1 + 3 + lat * 2.6, 4); | |
| const land = c > 0.52; | |
| let base = land ? pal[1] : pal[0]; | |
| if (land) { | |
| const h = Math.min(1, (c - 0.52) * 3.2); | |
| base = [base[0] + (pal[2][0] - base[0]) * h * 0.45, | |
| base[1] + (pal[2][1] - base[1]) * h * 0.45, | |
| base[2] + (pal[2][2] - base[2]) * h * 0.45]; | |
| } | |
| // ice caps | |
| const ice = Math.abs(lat) > 1.18 + nz.noise(lon * 3, 9) * 0.3; | |
| if (ice) { | |
| const k = Math.min(1, (Math.abs(lat) - 1.1) * 3); | |
| base = [base[0] + (240 - base[0]) * k, base[1] + (246 - base[1]) * k, base[2] + (252 - base[2]) * k]; | |
| } | |
| // cloud banding | |
| const cloud = nz.fbm(Math.cos(lon) * 3.3 + 21, Math.sin(lon) * 3.3 + 17 + lat * 3.4, 3); | |
| const ca = clamp((cloud - 0.52) * 2.4, 0, 1) * 0.72; | |
| data[i] = base[0] + (250 - base[0]) * ca; | |
| data[i + 1] = base[1] + (252 - base[1]) * ca; | |
| data[i + 2] = base[2] + (255 - base[2]) * ca; | |
| // limb darkening so the sprite reads as a sphere even before shading | |
| const limb = 0.72 + 0.28 * Math.pow(z, 0.45); | |
| data[i] *= limb; data[i + 1] *= limb; data[i + 2] *= limb; | |
| } | |
| } | |
| c2.putImageData(img, 0, 0); | |
| return cv; | |
| } | |
| const planetTex = []; | |
| for (let i = 0; i < 5; i++) planetTex.push(makePlanetTexture(101 + i * 37)); | |
| function makePlanet() { | |
| const nrm = norm([Math.random() * 2 - 1, Math.random() * 1.2 + 0.4, Math.random() * 2 - 1]); | |
| let A = cross(nrm, [0, 1, 0]); | |
| if (len(A) < 1e-4) A = [1, 0, 0]; else A = norm(A); | |
| const B = norm(cross(nrm, A)); | |
| const ringed = Math.random() < 0.4; | |
| return { | |
| x: (Math.random() * 2 - 1) * FIELD_PLANET * 0.9, | |
| y: (Math.random() * 2 - 1) * FIELD_PLANET * 0.5, | |
| z: (Math.random() * 2 - 1) * FIELD_PLANET * 0.9, | |
| r: 240 + Math.random() * 620, | |
| tex: (Math.random() * planetTex.length) | 0, | |
| spin: Math.random() * TAU, | |
| spinRate: (Math.random() - 0.5) * 0.06, | |
| atmo: [[120, 190, 255], [255, 170, 120], [150, 240, 210], [205, 205, 255]][(Math.random() * 4) | 0], | |
| atmoStr: '', | |
| ring: ringed, | |
| ringA: A, ringB: B, | |
| ringR: 1.7 + Math.random() * 0.9, | |
| ringW: 26 + Math.random() * 40 | |
| }; | |
| } | |
| const planets = []; | |
| for (let i = 0; i < PLANET_COUNT; i++) { | |
| const pl = makePlanet(); | |
| pl.atmoStr = pl.atmo[0] + ',' + pl.atmo[1] + ',' + pl.atmo[2]; // built once, not per frame | |
| planets.push(pl); | |
| } | |
| // ---- meteors / shooting stars ------------------------------------- | |
| const meteors = []; | |
| let nextMeteor = 0, exhaustTick = 0; | |
| const exhaust = []; | |
| function spawnMeteors(cam, now) { | |
| if (now < nextMeteor) return; | |
| nextMeteor = now + 500 + Math.random() * 2200; | |
| const burst = Math.random() < 0.22 ? 2 + ((Math.random() * 3) | 0) : 1; | |
| for (let i = 0; i < burst && meteors.length < 16; i++) { | |
| // spawn in the forward hemisphere, off-axis | |
| const d = norm([(Math.random() * 2 - 1) * 1.1, (Math.random() * 2 - 1) * 0.8, 0.45 + Math.random() * 0.55]); | |
| const dist = 500 + Math.random() * 900; | |
| // travel across the view so it reads as a streak | |
| let lat = cross(d, [0, 1, 0]); | |
| if (len(lat) < 1e-4) lat = [1, 0, 0]; else lat = norm(lat); | |
| lat = rotateAbout(lat, d, Math.random() * TAU); | |
| const speed = 700 + Math.random() * 1500; | |
| const fireball = Math.random() < 0.14; | |
| const vx = lat[0] * speed + d[0] * (Math.random() * 220 - 80); | |
| const vy = lat[1] * speed + d[1] * (Math.random() * 220 - 80); | |
| const vz = lat[2] * speed + d[2] * (Math.random() * 220 - 80); | |
| meteors.push({ | |
| x: cam.camPos[0] + d[0] * dist + (Math.random() - 0.5) * 300, | |
| y: cam.camPos[1] + d[1] * dist + (Math.random() - 0.5) * 300, | |
| z: cam.camPos[2] + d[2] * dist + (Math.random() - 0.5) * 300, | |
| vx: vx, vy: vy, vz: vz, | |
| life: 0, max: 0.9 + Math.random() * 1.2, | |
| trail: (fireball ? 0.22 : 0.12) + Math.random() * 0.06, | |
| w: fireball ? 3.2 + Math.random() * 2 : 1.2 + Math.random() * 1.4, | |
| warm: fireball || Math.random() < 0.3, | |
| fireball: fireball | |
| }); | |
| } | |
| } | |
| // ---- near-field dust motes (speed cue) ---------------------------- | |
| const DUST_COUNT = 460, DUST_R = 620; | |
| const dust = []; | |
| for (let i = 0; i < DUST_COUNT; i++) { | |
| dust.push({ | |
| x: (Math.random() * 2 - 1) * DUST_R, | |
| y: (Math.random() * 2 - 1) * DUST_R, | |
| z: (Math.random() * 2 - 1) * DUST_R | |
| }); | |
| } | |
| // ------------------------------------------------------------------ | |
| // Camera: chase cam behind the ship with a FIXED WORLD-UP view. | |
| // The camera's up vector is always (0,1,0), so the horizon stays level | |
| // and the view never rolls or flips no matter how the ship yaws or | |
| // pitches. Position and look-target are exponentially smoothed (no | |
| // overshoot, no drift) so turns feel fluid. | |
| // | |
| // The view direction follows the FLIGHT PATH, not the nose. The nose can | |
| // swing at ~34 deg/s, but the ship's course only changes as its velocity | |
| // is rebuilt along the new nose. So during a turn the sky holds still and | |
| // the ship rotates inside the frame, instead of the whole world spinning | |
| // the other way under the turn. Once you are flying straight, velocity and | |
| // nose agree and the camera behaves exactly as it always did. | |
| // | |
| // CAM_VIEW_BLEND 0 = old behaviour (view snaps to the nose). | |
| // higher = steadier sky, ship swings more in frame. | |
| // CAM_VIEW_FOLLOW how fast the view settles. Lower = steeper lag. | |
| // CAM_MAX_ANGLE how far the view may fall behind the nose, so the | |
| // ship can never swing out of frame. | |
| // ------------------------------------------------------------------ | |
| const CAM_BACK = 110; // distance behind the ship | |
| const CAM_UP = 55; // height above the ship | |
| const CAM_FOLLOW = 7; // smoothing rate (1/s) — softer chase | |
| const CAM_LOOKAHEAD = 26; // camera aims along the view, ship leads it | |
| const CAM_VIEW_BLEND = 0.8; // nose vs. flight path, 0 = pure nose | |
| const CAM_VIEW_FOLLOW = 0.6; // view settling rate (1/s) | |
| const CAM_MAX_ANGLE = 0.8; // rad (~46 deg) soft limit on nose-vs-view lag | |
| const CAM_UNWIND = 0.9; // rad/s — how gently the lag is pulled back | |
| let camState = null; | |
| function cameraBasis(dt) { | |
| const fwd = ship.fwd; | |
| // What the view is allowed to turn toward. Blended with the nose so a | |
| // ship that is barely moving (or decelerating out of the old direction) | |
| // still has a well-defined view. | |
| let want = fwd; | |
| if (CAM_VIEW_BLEND > 0) { | |
| const sp = len(ship.vel); | |
| if (sp > 1) { | |
| const w = CAM_VIEW_BLEND; | |
| const bx = fwd[0] * (1 - w) + (ship.vel[0] / sp) * w; | |
| const by = fwd[1] * (1 - w) + (ship.vel[1] / sp) * w; | |
| const bz = fwd[2] * (1 - w) + (ship.vel[2] / sp) * w; | |
| const bl = Math.hypot(bx, by, bz); | |
| if (bl > 1e-6) want = [bx / bl, by / bl, bz / bl]; | |
| } | |
| } | |
| const first = !camState; | |
| if (first) camState = { pos: [0, 0, 0], tgt: [0, 0, 0], view: want.slice() }; | |
| const kv = 1 - Math.exp(-CAM_VIEW_FOLLOW * dt); | |
| for (let i = 0; i < 3; i++) camState.view[i] += (want[i] - camState.view[i]) * kv; | |
| // Soft limit on how far the view may fall behind the nose. A hard cap | |
| // would snap the view in spikes, which is the very thing being reduced. | |
| const ca = clamp(dot3(camState.view, fwd), -1, 1); | |
| const lag = Math.acos(ca); | |
| if (lag > CAM_MAX_ANGLE) { | |
| const ax = cross(camState.view, fwd); | |
| const al = len(ax); | |
| if (al > 1e-6) { | |
| const n = [ax[0] / al, ax[1] / al, ax[2] / al]; | |
| // positive rotation about cross(view, nose) swings the view back | |
| // toward the nose, but only by what this frame can afford | |
| const r = rotateAbout(camState.view, n, Math.min(lag - CAM_MAX_ANGLE, CAM_UNWIND * dt)); | |
| camState.view[0] = r[0]; camState.view[1] = r[1]; camState.view[2] = r[2]; | |
| } | |
| } | |
| const view = camState.view; | |
| const desiredPos = [ | |
| ship.pos[0] - view[0] * CAM_BACK, | |
| ship.pos[1] - view[1] * CAM_BACK + CAM_UP, | |
| ship.pos[2] - view[2] * CAM_BACK | |
| ]; | |
| const desiredTgt = [ | |
| ship.pos[0] + view[0] * CAM_LOOKAHEAD, | |
| ship.pos[1] + view[1] * CAM_LOOKAHEAD + 8, | |
| ship.pos[2] + view[2] * CAM_LOOKAHEAD | |
| ]; | |
| const k = first ? 1 : 1 - Math.exp(-CAM_FOLLOW * dt); | |
| for (let i = 0; i < 3; i++) { | |
| camState.pos[i] += (desiredPos[i] - camState.pos[i]) * k; | |
| camState.tgt[i] += (desiredTgt[i] - camState.tgt[i]) * k; | |
| } | |
| const forward = norm(sub(camState.tgt, camState.pos)); | |
| let xAxis = cross(forward, [0, 1, 0]); // screen right | |
| if (len(xAxis) < 1e-6) xAxis = [1, 0, 0]; else xAxis = norm(xAxis); | |
| const yAxis = cross(xAxis, forward); // screen up (always world-level) | |
| lastCam = { camPos: camState.pos, xAxis, yAxis, zAxis: forward }; | |
| return lastCam; | |
| } | |
| // Transform a world point into camera space and project it into `out`, a | |
| // pooled object, so the render loop allocates nothing. Returns null if behind. | |
| function project(px, py, pz, cam, out) { | |
| const dx = px - cam.camPos[0]; | |
| const dy = py - cam.camPos[1]; | |
| const dz = pz - cam.camPos[2]; | |
| const cz = dx * cam.zAxis[0] + dy * cam.zAxis[1] + dz * cam.zAxis[2]; | |
| if (cz <= 0.5) return null; // depth first: most rejects stop here | |
| out.x = CX + ((dx * cam.xAxis[0] + dy * cam.xAxis[1] + dz * cam.xAxis[2]) / cz) * FOCAL; | |
| out.y = CY - ((dx * cam.yAxis[0] + dy * cam.yAxis[1] + dz * cam.yAxis[2]) / cz) * FOCAL; | |
| out.z = cz; | |
| return out; | |
| } | |
| // ------------------------------------------------------------------ | |
| // Ship geometry (local space, nose toward -Z, scale factor 15) | |
| // ------------------------------------------------------------------ | |
| const SHIP_SCALE = 15; | |
| const shipVerts = [ | |
| [0, 0, -1.4], // 0 nose tip | |
| [-0.5, 0.15, -0.4], // 1 cockpit ring left | |
| [0.5, 0.15, -0.4], // 2 cockpit ring right | |
| [0, 0.42, -0.4], // 3 cockpit ring top | |
| [-0.62, 0, 0.6], // 4 mid fuselage left | |
| [0.62, 0, 0.6], // 5 mid fuselage right | |
| [0, 0.5, 0.6], // 6 mid fuselage top | |
| [-0.5, 0, 1.3], // 7 rear left | |
| [0.5, 0, 1.3], // 8 rear right | |
| [0, 0.4, 1.3], // 9 rear top | |
| [-2.1, 0.05, 0.9], // 10 left wing tip (swept back) | |
| [2.1, 0.05, 0.9], // 11 right wing tip (swept back) | |
| [-0.62, 0, 1.25], // 12 left wing root rear | |
| [0.62, 0, 1.25], // 13 right wing root rear | |
| [0, 1.15, 1.55], // 14 tail fin tip | |
| [-0.3, 0.35, 1.25], // 15 tail fin base left | |
| [0.3, 0.35, 1.25], // 16 tail fin base right | |
| [-1.0, 0.55, 1.6], // 17 left side fin tip | |
| [1.0, 0.55, 1.6] // 18 right side fin tip | |
| ]; | |
| // [a,b,c] index triples, plus a color key: h=hull, w=wing, g=glass, e=engine | |
| const shipFaces = [ | |
| // nose cone | |
| [0, 1, 2, 'h'], | |
| [0, 2, 3, 'h'], | |
| [0, 3, 1, 'h'], | |
| // glowing cockpit window | |
| [1, 3, 2, 'g'], | |
| // upper fuselage | |
| [1, 3, 6, 'h'], | |
| [1, 6, 4, 'h'], | |
| [2, 5, 6, 'h'], | |
| [2, 6, 3, 'h'], | |
| // lower fuselage | |
| [4, 6, 5, 'h'], | |
| [4, 5, 8, 'h'], | |
| [4, 8, 7, 'h'], | |
| // rear engine block | |
| [7, 8, 9, 'e'], | |
| [7, 9, 5, 'h'], | |
| [8, 5, 9, 'h'], | |
| // left swept wing | |
| [4, 10, 12, 'w'], | |
| [4, 12, 7, 'w'], | |
| [6, 4, 10, 'w'], | |
| // right swept wing | |
| [5, 11, 13, 'w'], | |
| [5, 13, 8, 'w'], | |
| [6, 5, 11, 'w'], | |
| // tail fin | |
| [15, 16, 14, 'h'], | |
| [9, 14, 15, 'h'], | |
| [9, 16, 14, 'h'], | |
| // side tail fins | |
| [7, 17, 12, 'w'], | |
| [8, 13, 18, 'w'] | |
| ]; | |
| const shipColors = { | |
| h: [74, 80, 96], // dark gray hull | |
| w: [104, 112, 132], // lighter wing | |
| g: [130, 226, 255], // glowing blue cockpit | |
| e: [40, 44, 58] // engine block | |
| }; | |
| // ------------------------------------------------------------------ | |
| // Update | |
| // ------------------------------------------------------------------ | |
| let distance = 0; | |
| let last = performance.now(); | |
| function update(dt) { | |
| // --- steering: one smooth arc, eased in and eased out ------------- | |
| let errTrue = 0; | |
| if (hasTarget) { | |
| errTrue = Math.acos(clamp(dot3(ship.fwd, targetFwd), -1, 1)); | |
| // Route: take the direct great circle, unless that arc would pitch the | |
| // nose past its authority — then level out toward the target's bearing | |
| // first and change pitch afterwards. That is what a real ship does, and | |
| // it is what stops a 180-degree reversal from stalling against the | |
| // pitch limit. | |
| let tgt = targetFwd; | |
| const a0 = cross(ship.fwd, targetFwd); | |
| const a0l = len(a0); | |
| if (a0l > 1e-4) { | |
| const nx = a0[0] / a0l, ny = a0[1] / a0l, nz = a0[2] / a0l; | |
| const err0 = Math.acos(clamp(dot3(ship.fwd, targetFwd), -1, 1)); | |
| // Along the arc, nose.y(t) = nose.y*cos t + cy*sin t where | |
| // cy = (axis x nose).y. Its peak inside the swept range is what | |
| // matters — not the peak of the whole great circle. | |
| const cy = nz * ship.fwd[0] - nx * ship.fwd[2]; | |
| const fy = ship.fwd[1]; | |
| let hi = Math.max(fy, fy * Math.cos(err0) + cy * Math.sin(err0)); | |
| let lo = Math.min(fy, fy * Math.cos(err0) + cy * Math.sin(err0)); | |
| const tp = Math.atan2(cy, fy); | |
| if (tp > 0 && tp < err0) hi = Math.max(hi, Math.hypot(fy, cy)); | |
| const tn = tp - Math.PI; | |
| if (tn > 0 && tn < err0) lo = Math.min(lo, -Math.hypot(fy, cy)); | |
| if ((hi > PITCH_SIN || lo < -PITCH_SIN) && Math.hypot(targetFwd[0], targetFwd[2]) > 1e-3) { | |
| const hh = Math.hypot(targetFwd[0], targetFwd[2]); | |
| tgt = [targetFwd[0] / hh, 0, targetFwd[2] / hh]; | |
| } | |
| } | |
| const err = Math.acos(clamp(dot3(ship.fwd, tgt), -1, 1)); | |
| if (err > 1e-4) { | |
| if (err > Math.PI - 0.02) { | |
| // pointing exactly backwards: the cross product is undefined, so | |
| // pick the natural rotation — yaw level about world up | |
| const ax = cross(ship.fwd, [0, 1, 0]); | |
| if (len(ax) > 1e-4) { turnAxis[0] = ax[0] / len(ax); turnAxis[1] = ax[1] / len(ax); turnAxis[2] = ax[2] / len(ax); } | |
| else { turnAxis[0] = 1; turnAxis[1] = 0; turnAxis[2] = 0; } | |
| } else if (err > 0.05) { | |
| // recompute while the arc is well defined; below that the cross | |
| // product of two near-parallel vectors is noise, and re-deriving it | |
| // per frame makes the nose jitter. Remembering the axis instead lets | |
| // the turn settle dead straight — the stored axis is perpendicular to | |
| // both directions, so rotating by the remaining error lands exactly | |
| // on the target. | |
| const ax = cross(ship.fwd, tgt); | |
| const al = len(ax) || 1; | |
| turnAxis[0] = ax[0] / al; turnAxis[1] = ax[1] / al; turnAxis[2] = ax[2] / al; | |
| } | |
| const axis = turnAxis; | |
| const cur = len(ship.omega); | |
| const want = Math.min(TURN_MAX, err * TURN_GAIN); | |
| const rate = Math.max(0, cur + clamp(want - cur, -TURN_SLEW * dt, TURN_SLEW * dt)); | |
| const step = Math.min(rate * dt, err); // never swings past the target | |
| ship.fwd = rotateAbout(ship.fwd, axis, step); | |
| ship.omega[0] = axis[0] * rate; | |
| ship.omega[1] = axis[1] * rate; | |
| ship.omega[2] = axis[2] * rate; | |
| } else { | |
| ship.omega[0] = ship.omega[1] = ship.omega[2] = 0; | |
| } | |
| // safety net: keep the nose off vertical so the level camera stays conditioned | |
| if (Math.abs(ship.fwd[1]) > PITCH_SIN) { | |
| const y = clamp(ship.fwd[1], -PITCH_SIN, PITCH_SIN); | |
| const hh = Math.hypot(ship.fwd[0], ship.fwd[2]) || 1e-6; | |
| const k = Math.sqrt(Math.max(0, 1 - y * y)) / hh; | |
| ship.fwd[0] *= k; ship.fwd[1] = y; ship.fwd[2] *= k; | |
| } | |
| } | |
| // heading / pitch are just the nose vector read back out | |
| ship.pitch = Math.asin(clamp(ship.fwd[1], -1, 1)); | |
| ship.heading = Math.atan2(ship.fwd[0], -ship.fwd[2]); | |
| // Bank into the turn (visual roll only — the camera itself stays level). | |
| // Heading grows when the ship rotates about -Y, so the yaw rate is -omega[1]. | |
| const targetBank = clamp(-ship.omega[1] * 0.95, -0.62, 0.62); | |
| ship.bank += (targetBank - ship.bank) * Math.min(1, dt * 2.6); | |
| // Throttle eases up once the nose is on course and stays low while the | |
| // ship is still swinging through the turn, so the burn matches the aim. | |
| const wantThrottle = hasTarget ? 0.35 + 0.65 * (1 - Math.min(1, errTrue / 1.15)) : 0; | |
| ship.throttle += (wantThrottle - ship.throttle) * Math.min(1, dt * THROTTLE_RATE); | |
| // Thrust along the nose direction | |
| const fwd = ship.fwd; | |
| if (ship.throttle > 0.001) { | |
| const a = ACCEL * ship.throttle * dt; | |
| ship.vel[0] += fwd[0] * a; | |
| ship.vel[1] += fwd[1] * a; | |
| ship.vel[2] += fwd[2] * a; | |
| } | |
| // integrate velocity with drag | |
| const dragF = Math.exp(-DRAG * dt); | |
| ship.vel[0] *= dragF; | |
| ship.vel[1] *= dragF; | |
| ship.vel[2] *= dragF; | |
| // clamp speed | |
| const sp = len(ship.vel); | |
| if (sp > MAX_SPEED) { | |
| const k = MAX_SPEED / sp; | |
| ship.vel[0] *= k; ship.vel[1] *= k; ship.vel[2] *= k; | |
| } | |
| // integrate position | |
| ship.pos[0] += ship.vel[0] * dt; | |
| ship.pos[1] += ship.vel[1] * dt; | |
| ship.pos[2] += ship.vel[2] * dt; | |
| distance += sp * dt; | |
| // Spin asteroids, planets | |
| for (const a of asteroids) { | |
| a.rot[0] += a.spin[0] * dt; | |
| a.rot[1] += a.spin[1] * dt; | |
| a.rot[2] += a.spin[2] * dt; | |
| } | |
| for (const pl of planets) pl.spin += pl.spinRate * dt; | |
| // Engine exhaust: soft particles shed from the nozzles, drifting back | |
| if (ship.throttle > 0.12) { | |
| for (let i = 0; i < 2; i++) { | |
| const e = ENGINE_LOCAL[exhaustTick++ & 1]; | |
| const wp = shipWorldPoint(e, SHIP_W); | |
| const back = 150 + Math.random() * 190; | |
| exhaust.push({ | |
| x: wp[0], y: wp[1], z: wp[2], | |
| vx: ship.vel[0] * 0.4 - fwd[0] * back + (Math.random() - 0.5) * 45, | |
| vy: ship.vel[1] * 0.4 - fwd[1] * back + (Math.random() - 0.5) * 45, | |
| vz: ship.vel[2] * 0.4 - fwd[2] * back + (Math.random() - 0.5) * 45, | |
| life: 0, max: 0.5 + Math.random() * 0.5, r: 0.6 + Math.random() * 1.1 | |
| }); | |
| } | |
| if (exhaust.length > 240) exhaust.splice(0, exhaust.length - 240); | |
| } | |
| for (let i = exhaust.length - 1; i >= 0; i--) { | |
| const p = exhaust[i]; | |
| p.life += dt; | |
| if (p.life >= p.max) { exhaust.splice(i, 1); continue; } | |
| p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt; | |
| p.vx *= 0.985; p.vy *= 0.985; p.vz *= 0.985; | |
| } | |
| // Meteors | |
| for (let i = meteors.length - 1; i >= 0; i--) { | |
| const m = meteors[i]; | |
| m.life += dt; | |
| if (m.life >= m.max) { meteors.splice(i, 1); continue; } | |
| m.x += m.vx * dt; m.y += m.vy * dt; m.z += m.vz * dt; | |
| } | |
| } | |
| // ------------------------------------------------------------------ | |
| // Rendering | |
| // ------------------------------------------------------------------ | |
| function drawBackground() { | |
| if (bgLayer) { | |
| ctx.drawImage(bgLayer, 0, 0, W, H); | |
| } else { | |
| ctx.fillStyle = '#050510'; | |
| ctx.fillRect(0, 0, W, H); | |
| } | |
| } | |
| function drawStars(cam) { | |
| // Batch stars into 4 color paths for fast rendering | |
| const paths = [new Path2D(), new Path2D(), new Path2D(), new Path2D()]; | |
| const sx = ship.pos[0], sy = ship.pos[1], sz = ship.pos[2]; | |
| const xlo = sx - FIELD, xhi = sx + FIELD, ylo = sy - FIELD, yhi = sy + FIELD, zlo = sz - FIELD, zhi = sz + FIELD; | |
| const cp = cam.camPos, xa = cam.xAxis, ya = cam.yAxis, za = cam.zAxis; | |
| for (let i = 0; i < stars.length; i++) { | |
| const st = stars[i]; | |
| // infinite space: wrap around the ship so stars respawn ahead | |
| if (st.x < xlo) st.x += 2 * FIELD; else if (st.x > xhi) st.x -= 2 * FIELD; | |
| if (st.y < ylo) st.y += 2 * FIELD; else if (st.y > yhi) st.y -= 2 * FIELD; | |
| if (st.z < zlo) st.z += 2 * FIELD; else if (st.z > zhi) st.z -= 2 * FIELD; | |
| const dx = st.x - cp[0], dy = st.y - cp[1], dz = st.z - cp[2]; | |
| const cz = dx * za[0] + dy * za[1] + dz * za[2]; | |
| if (cz <= 0.5) continue; | |
| const px = CX + ((dx * xa[0] + dy * xa[1] + dz * xa[2]) / cz) * FOCAL; | |
| if (px < -4 || px > W + 4) continue; | |
| const py = CY - ((dx * ya[0] + dy * ya[1] + dz * ya[2]) / cz) * FOCAL; | |
| if (py < -4 || py > H + 4) continue; | |
| paths[st.ci].rect(px, py, st.s, st.s); | |
| } | |
| for (let i = 0; i < 4; i++) { | |
| ctx.fillStyle = starStyle[i]; | |
| ctx.fill(paths[i]); | |
| } | |
| } | |
| function drawNebulae(cam) { | |
| // Nebulae wrap in a box of +/-FIELD_NEB (2x the ship field) around the | |
| // ship: the furthest one can ever be is the box corner (NEB_MAX, ~9006u). | |
| // A wrap teleports a nebula to somewhere between FIELD_NEB (one axis) and | |
| // NEB_MAX (a corner) away, so alpha is a pure function of distance: | |
| // exactly 0 everywhere a wrap can land, ramping to full at a FIELD in. | |
| const NEB_MAX = FIELD_NEB * Math.sqrt(3); // view/cull distance, 2x the old one | |
| // painter's: far first | |
| list.length = 0; | |
| for (const n of nebulae) { | |
| // infinite space: wrap around the ship | |
| if (n.x < ship.pos[0] - FIELD_NEB) n.x += 2 * FIELD_NEB; else if (n.x > ship.pos[0] + FIELD_NEB) n.x -= 2 * FIELD_NEB; | |
| if (n.y < ship.pos[1] - FIELD_NEB) n.y += 2 * FIELD_NEB; else if (n.y > ship.pos[1] + FIELD_NEB) n.y -= 2 * FIELD_NEB; | |
| if (n.z < ship.pos[2] - FIELD_NEB) n.z += 2 * FIELD_NEB; else if (n.z > ship.pos[2] + FIELD_NEB) n.z -= 2 * FIELD_NEB; | |
| const dz = n.z - cam.camPos[2]; | |
| const dx = n.x - cam.camPos[0]; | |
| const dy = n.y - cam.camPos[1]; | |
| const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); | |
| if (dist > NEB_MAX) continue; | |
| const p = project(n.x, n.y, n.z, cam, P_ONE); | |
| if (!p) continue; | |
| const e = ENTRY[list.length]; | |
| e.it = n; e.x = p.x; e.y = p.y; e.z = p.z; e.dist = dist; e.fade = 0; | |
| list.push(e); | |
| } | |
| list.sort((a, b) => b.dist - a.dist); | |
| ctx.globalCompositeOperation = 'lighter'; | |
| for (const e of list) { | |
| const n = e.it; | |
| const r = (n.r / e.z) * FOCAL; | |
| if (r < 1) continue; | |
| // fully transparent from FIELD_NEB (5200u) out to the horizon, full by | |
| // a FIELD (2600u) in — smoothstep so the ramp itself has no kinks | |
| const t = Math.max(0, Math.min(1, (FIELD_NEB - e.dist) / (FIELD_NEB - FIELD))); | |
| const fade = t * t * (3 - 2 * t); | |
| const alpha = 0.5 * fade; | |
| ctx.save(); | |
| ctx.globalAlpha = alpha; | |
| ctx.translate(e.x, e.y); | |
| ctx.rotate(n.rot); | |
| ctx.drawImage(nebulaTex[n.ci], -r, -r, r * 2, r * 2); | |
| ctx.restore(); | |
| } | |
| ctx.globalCompositeOperation = 'source-over'; | |
| ctx.globalAlpha = 1; | |
| } | |
| // Rotate a local point into world space, then translate & scale. | |
| // Yaw+pitch are one rotation matrix whose columns are the ship's right, | |
| // up and back axes in world space. The local nose (-Z) therefore maps to | |
| // exactly (sin h * cos p, sin p, -cos h * cos p) — the same direction | |
| // update() thrusts along — at every heading and pitch. | |
| function shipWorldPoint(v, out) { | |
| const s = SHIP_SCALE; | |
| const x = v[0] * s, y = v[1] * s, z = v[2] * s; | |
| // bank (visual roll) in the body frame about local forward (local Z) | |
| const cb = Math.cos(ship.bank), sb = Math.sin(ship.bank); | |
| const x1 = x * cb + y * sb; | |
| const y1 = -x * sb + y * cb; | |
| const z1 = z; | |
| const ch = Math.cos(ship.heading), sh = Math.sin(ship.heading); | |
| const cp = Math.cos(ship.pitch), sp2 = Math.sin(ship.pitch); | |
| out[0] = ship.pos[0] + ch * x1 - sh * sp2 * y1 - sh * cp * z1; | |
| out[1] = ship.pos[1] + cp * y1 - sp2 * z1; | |
| out[2] = ship.pos[2] + sh * x1 + ch * sp2 * y1 + ch * cp * z1; | |
| return out; | |
| } | |
| function worldShipPoint(v, cam, out) { | |
| const w = shipWorldPoint(v, SHIP_W); | |
| return project(w[0], w[1], w[2], cam, out); | |
| } | |
| function drawShip(cam, now) { | |
| // Precompute projected verts into the pool (19 verts, far under the pool size) | |
| for (let i = 0; i < shipVerts.length; i++) P_OK[i] = worldShipPoint(shipVerts[i], cam, P_VERTS[i]) ? 1 : 0; | |
| // Build face list with depth + normal (skip if any vert behind) | |
| faceList.length = 0; | |
| for (const f of shipFaces) { | |
| if (!P_OK[f[0]] || !P_OK[f[1]] || !P_OK[f[2]]) continue; | |
| const a = P_VERTS[f[0]], b = P_VERTS[f[1]], c = P_VERTS[f[2]]; | |
| const rec = FACE_REC[faceList.length]; | |
| rec.a = a; rec.b = b; rec.c = c; | |
| rec.z = (a.z + b.z + c.z) / 3; | |
| rec.k = f[3]; | |
| faceList.push(rec); | |
| } | |
| faceList.sort((p, q) => q.z - p.z); | |
| for (const fc of faceList) { | |
| const key = fc.k; | |
| const base = shipColors[key]; | |
| // face normal in camera-ish space via projected verts (approx) | |
| const bx = fc.b.x - fc.a.x, by = fc.b.y - fc.a.y; | |
| const fx = fc.c.x - fc.a.x, fy = fc.c.y - fc.a.y; | |
| const nx = bx * fy - fx * by; | |
| let shade = 0.55 + 0.45 * Math.abs(nx) / (Math.max(1, Math.sqrt(bx * bx + by * by))); | |
| shade = Math.min(1.2, shade); | |
| const col = key === 'g' | |
| ? base | |
| : [Math.min(255, base[0] * shade), Math.min(255, base[1] * shade), Math.min(255, base[2] * shade)]; | |
| ctx.beginPath(); | |
| ctx.moveTo(fc.a.x, fc.a.y); | |
| ctx.lineTo(fc.b.x, fc.b.y); | |
| ctx.lineTo(fc.c.x, fc.c.y); | |
| ctx.closePath(); | |
| ctx.fillStyle = rgbStr(col[0] | 0, col[1] | 0, col[2] | 0); | |
| ctx.fill(); | |
| if (key !== 'g') { | |
| ctx.strokeStyle = 'rgba(0,0,0,0.25)'; | |
| ctx.lineWidth = 0.5; | |
| ctx.stroke(); | |
| } | |
| } | |
| // Cockpit glow halo — baked once as a bitmap, so no gradient is built per frame | |
| const cp = worldShipPoint([0, 0.35, -0.4], cam, P_ONE); | |
| if (cp) { | |
| ctx.globalCompositeOperation = 'lighter'; | |
| ctx.drawImage(cockpitGlow, cp.x - HALO_R / 2, cp.y - HALO_R / 2, HALO_R, HALO_R); | |
| ctx.globalCompositeOperation = 'source-over'; | |
| } | |
| // Thruster flames at the REAR (two engines) | |
| drawThrusters(cam, now); | |
| } | |
| function drawThrusters(cam, now) { | |
| const thr = ship.throttle; | |
| if (thr < 0.04) return; | |
| const t = now * 0.02; | |
| const flick = 0.72 + 0.28 * Math.sin(t) + 0.14 * Math.sin(t * 2.7); | |
| const baseLen = (0.55 + 1.25 * thr) * flick; | |
| const glow = 0.25 + 0.75 * thr; | |
| ctx.globalCompositeOperation = 'lighter'; | |
| for (const e of ENGINE_LOCAL) { | |
| const p0 = worldShipPoint(e, cam, P_ONE); | |
| if (!p0) continue; | |
| // flame extends further back (+Z local) | |
| const p1 = worldShipPoint([e[0], e[1], 1.32 + baseLen * 0.6], cam, P_TWO); | |
| const p2 = worldShipPoint([e[0], e[1], 1.32 + baseLen], cam, P_THREE); | |
| if (!p1 || !p2) continue; | |
| const grad = ctx.createLinearGradient(p0.x, p0.y, p2.x, p2.y); | |
| grad.addColorStop(0, 'rgba(180,235,255,' + alphaStr(0.9 * glow) + ')'); | |
| grad.addColorStop(0.4, 'rgba(70,160,255,' + alphaStr(0.7 * glow) + ')'); | |
| grad.addColorStop(1, 'rgba(40,90,255,0)'); | |
| ctx.strokeStyle = grad; | |
| ctx.lineWidth = 5 * flick * (0.55 + 0.5 * thr); | |
| ctx.lineCap = 'round'; | |
| ctx.beginPath(); | |
| ctx.moveTo(p0.x, p0.y); | |
| ctx.quadraticCurveTo(p1.x, p1.y, p2.x, p2.y); | |
| ctx.stroke(); | |
| // core glow | |
| ctx.fillStyle = 'rgba(200,240,255,' + alphaStr(0.5 * flick * glow) + ')'; | |
| ctx.beginPath(); | |
| ctx.arc(p0.x, p0.y, 3.2, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| ctx.globalCompositeOperation = 'source-over'; | |
| } | |
| const AST_LIGHT = norm([0.42, 0.58, -0.7]); // fixed sun direction (world space) | |
| function drawAsteroids(cam) { | |
| const cp = cam.camPos; | |
| list.length = 0; | |
| for (const a of asteroids) { | |
| // infinite space: wrap around the ship | |
| if (a.x < ship.pos[0] - FIELD_AST) a.x += 2 * FIELD_AST; else if (a.x > ship.pos[0] + FIELD_AST) a.x -= 2 * FIELD_AST; | |
| if (a.y < ship.pos[1] - FIELD_AST) a.y += 2 * FIELD_AST; else if (a.y > ship.pos[1] + FIELD_AST) a.y -= 2 * FIELD_AST; | |
| if (a.z < ship.pos[2] - FIELD_AST) a.z += 2 * FIELD_AST; else if (a.z > ship.pos[2] + FIELD_AST) a.z -= 2 * FIELD_AST; | |
| const dx = a.x - cp[0], dy = a.y - cp[1], dz = a.z - cp[2]; | |
| const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); | |
| if (dist > FIELD_AST * Math.sqrt(3)) continue; // view distance, 3x the old 3640u | |
| // Pop-proof fade in (same idea as the nebulae, with a hard guard). | |
| // Two continuous factors, both pure functions of distance, so a wrap | |
| // (which can only land an asteroid >= ~7560u from the camera) always | |
| // lands it at EXACTLY 0 alpha. The runway is now ~5300u long (about | |
| // 40s of travel at max speed) and concave, so a rock is a faint speck | |
| // far away, becomes clear over many seconds, and eases up to solid: | |
| // 7000u -> ~8% 5500u -> ~46% 4500u -> ~70% 2800u -> ~98% | |
| // guard — hard zero within 300u of the far wrap edge (respawn zone) | |
| // main — smoothstep 7540u -> 2200u, gamma-lifted | |
| const guard = Math.max(0, Math.min(1, (7540 - dist) / 300)); | |
| if (guard <= 0) continue; // guaranteed-invisible respawn zone | |
| const t = Math.max(0, Math.min(1, (7540 - dist) / 5340)); | |
| const alpha = Math.pow(t * t * (3 - 2 * t), 0.7) * guard; | |
| if (alpha < 0.02) continue; // below this it's invisible anyway — skip work | |
| const p = project(a.x, a.y, a.z, cam, P_ONE); | |
| if (!p) continue; | |
| const e = ENTRY[list.length]; | |
| e.it = a; e.x = p.x; e.y = p.y; e.z = p.z; e.dist = dist; e.fade = alpha; | |
| list.push(e); | |
| } | |
| list.sort((x, y) => y.dist - x.dist); | |
| for (const e of list) { | |
| if (e.z < 2) continue; | |
| ctx.globalAlpha = e.fade; | |
| const a = e.it; | |
| const s = a.scale; | |
| const c0 = Math.cos(a.rot[0]), s0 = Math.sin(a.rot[0]); | |
| const c1 = Math.cos(a.rot[1]), s1 = Math.sin(a.rot[1]); | |
| const c2 = Math.cos(a.rot[2]), s2 = Math.sin(a.rot[2]); | |
| // tumble matrix (same orientation as the original three-step rotation), | |
| // so a stored normal can be rotated with 9 multiplies instead of a cross | |
| // product plus a normalising sqrt per face. | |
| const m0 = c1 * c2, m1 = s0 * s1 * c2 - c0 * s2, m2 = c0 * s1 * c2 + s0 * s2; | |
| const m3 = c1 * s2, m4 = s0 * s1 * s2 + c0 * c2, m5 = c0 * s1 * s2 - s0 * c2; | |
| const m6 = -s1, m7 = s0 * c1, m8 = c0 * c1; | |
| for (let i = 0; i < a.verts.length; i++) { | |
| const v = a.verts[i]; | |
| const x = v[0] * s, y = v[1] * s, z = v[2] * s; | |
| const lx = m0 * x + m1 * y + m2 * z; | |
| const ly = m3 * x + m4 * y + m5 * z; | |
| const lz = m6 * x + m7 * y + m8 * z; | |
| const o = i * 3; | |
| AST_LOCAL[o] = lx; AST_LOCAL[o + 1] = ly; AST_LOCAL[o + 2] = lz; | |
| P_OK[i] = project(a.x + lx, a.y + ly, a.z + lz, cam, P_VERTS[i]) ? 1 : 0; | |
| } | |
| faceList.length = 0; | |
| for (let fi = 0; fi < a.faces.length; fi++) { | |
| const f = a.faces[fi]; | |
| if (!P_OK[f[0]] || !P_OK[f[1]] || !P_OK[f[2]]) continue; | |
| const A = P_VERTS[f[0]], B = P_VERTS[f[1]], C = P_VERTS[f[2]]; | |
| const o0 = f[0] * 3, o1 = f[1] * 3, o2 = f[2] * 3; | |
| // stored unit normal, rotated by the tumble matrix | |
| const n = a.faceNormal[fi]; | |
| const nx = m0 * n[0] + m1 * n[1] + m2 * n[2]; | |
| const ny = m3 * n[0] + m4 * n[1] + m5 * n[2]; | |
| const nz = m6 * n[0] + m7 * n[1] + m8 * n[2]; | |
| // backface cull: normal must point toward the camera | |
| const fx = (AST_LOCAL[o0] + AST_LOCAL[o1] + AST_LOCAL[o2]) / 3; | |
| const fy = (AST_LOCAL[o0 + 1] + AST_LOCAL[o1 + 1] + AST_LOCAL[o2 + 1]) / 3; | |
| const fz = (AST_LOCAL[o0 + 2] + AST_LOCAL[o1 + 2] + AST_LOCAL[o2 + 2]) / 3; | |
| if (nx * (cp[0] - a.x - fx) + ny * (cp[1] - a.y - fy) + nz * (cp[2] - a.z - fz) <= 0) continue; | |
| // normal is already unit length, so the dot is cos(theta) directly | |
| const lit = LIT_LUT[Math.max(0, Math.min(255, ((nx * AST_LIGHT[0] + ny * AST_LIGHT[1] + nz * AST_LIGHT[2]) * 256) | 0))]; | |
| const fc = a.faceCol[fi]; | |
| const br = (a.shade / 100) * (0.28 + 0.95 * lit); | |
| const r = Math.min(255, fc[0] * br + 10); | |
| const g = Math.min(255, fc[1] * br + 10); | |
| const b = Math.min(255, fc[2] * br + 16); | |
| const rec = FACE_REC[faceList.length]; | |
| rec.a = A; rec.b = B; rec.c = C; | |
| rec.z = (A.z + B.z + C.z) / 3; | |
| rec.col = rgbStr(r | 0, g | 0, b | 0); | |
| faceList.push(rec); | |
| } | |
| // A convex rock's backface-culled faces cannot overlap in projection, so | |
| // painter order is dead work here. Crater displacement breaks convexity | |
| // slightly: measured over random tumbles, 1.75% of drawn face area | |
| // overlaps and 0.9% would be painted in the wrong order (crater floor over | |
| // rim). Set PERF.sortAsteroids to true if you see that. | |
| if (PERF.sortAsteroids) faceList.sort((m, n) => n.z - m.z); | |
| for (const fc of faceList) { | |
| ctx.fillStyle = fc.col; | |
| ctx.beginPath(); | |
| ctx.moveTo(fc.a.x, fc.a.y); | |
| ctx.lineTo(fc.b.x, fc.b.y); | |
| ctx.lineTo(fc.c.x, fc.c.y); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| } | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| const BAND_ALPHAS = [0.16, 0.3, 0.5]; | |
| const BAND_COLORS = ['rgb(150,180,235)', 'rgb(235,225,215)', 'rgb(175,215,245)']; | |
| function drawSky(cam) { | |
| const cp = cam.camPos, xa = cam.xAxis, ya = cam.yAxis, za = cam.zAxis; | |
| ctx.globalCompositeOperation = 'lighter'; | |
| // soft band glow with dust gaps | |
| for (const b of bandBlobs) { | |
| const cz = b.dir[0] * za[0] + b.dir[1] * za[1] + b.dir[2] * za[2]; | |
| if (cz < 0.05) continue; | |
| const cx = b.dir[0] * xa[0] + b.dir[1] * xa[1] + b.dir[2] * xa[2]; | |
| const cy = b.dir[0] * ya[0] + b.dir[1] * ya[1] + b.dir[2] * ya[2]; | |
| const px = CX + (cx / cz) * FOCAL; | |
| const py = CY - (cy / cz) * FOCAL; | |
| // b.r is a world-space radius; the band lives on a unit-direction shell | |
| // of radius SKY_R, so convert it to an angular size first. Without the | |
| // /SKY_R these blobs are ~1.4M px wide and additive-blend the whole | |
| // screen to white. | |
| const r = Math.min(PERF.blobMaxR, (b.r / SKY_R / cz) * FOCAL); | |
| if (r < 3 || px < -r || px > W + r || py < -r || py > H + r) continue; | |
| ctx.globalAlpha = b.a * Math.min(1, cz * 1.5); | |
| ctx.drawImage(b.tex, px - r, py - r, r * 2, r * 2); | |
| } | |
| ctx.globalAlpha = 1; | |
| // faint deep-field dusting hugging the band | |
| const paths = [new Path2D(), new Path2D(), new Path2D(), new Path2D(), new Path2D(), | |
| new Path2D(), new Path2D(), new Path2D(), new Path2D()]; | |
| for (const st of bandStars) { | |
| const cz = st.dir[0] * za[0] + st.dir[1] * za[1] + st.dir[2] * za[2]; | |
| if (cz <= 0.05) continue; | |
| const cx = st.dir[0] * xa[0] + st.dir[1] * xa[1] + st.dir[2] * xa[2]; | |
| const cy = st.dir[0] * ya[0] + st.dir[1] * ya[1] + st.dir[2] * ya[2]; | |
| const px = CX + (cx / cz) * FOCAL; | |
| if (px < -2 || px > W + 2) continue; | |
| const py = CY - (cy / cz) * FOCAL; | |
| if (py < -2 || py > H + 2) continue; | |
| paths[st.c * 3 + st.b].rect(px, py, st.s, st.s); | |
| } | |
| for (let i = 0; i < 9; i++) { | |
| ctx.globalAlpha = BAND_ALPHAS[i % 3]; | |
| ctx.fillStyle = BAND_COLORS[(i / 3) | 0]; | |
| ctx.fill(paths[i]); | |
| } | |
| ctx.globalAlpha = 1; | |
| ctx.globalCompositeOperation = 'source-over'; | |
| } | |
| function drawFilaments(cam) { | |
| const cp = cam.camPos, xa = cam.xAxis, ya = cam.yAxis, za = cam.zAxis; | |
| ctx.globalCompositeOperation = 'lighter'; | |
| const cluster = new Path2D(); | |
| for (const f of filaments) { | |
| // wrap the whole strand around the ship (node data stays untouched) | |
| const c0 = f.nodes[0]; | |
| for (let i = 0; i < 3; i++) { | |
| const p = c0[i] + f.off[i]; | |
| if (p < ship.pos[i] - FIELD_FIL) f.off[i] += 2 * FIELD_FIL; | |
| else if (p > ship.pos[i] + FIELD_FIL) f.off[i] -= 2 * FIELD_FIL; | |
| } | |
| // Bounding-sphere cull. filFade is exactly zero past FIELD_FIL, so if the | |
| // nearest point of the strand is beyond that the whole strand is invisible | |
| // for sure — one distance test instead of projecting 35 points. The screen | |
| // test uses the same padding as before, so it can never reject a strand | |
| // that had something to draw. | |
| const gx = f.center[0] + f.off[0], gy = f.center[1] + f.off[1], gz = f.center[2] + f.off[2]; | |
| let dx = gx - cp[0], dy = gy - cp[1], dz = gz - cp[2]; | |
| const gd = Math.sqrt(dx * dx + dy * dy + dz * dz); | |
| if (gd - f.radius > FIELD_FIL) continue; | |
| const gcz = dx * za[0] + dy * za[1] + dz * za[2]; | |
| // Only reject if no point of the sphere could pass the per-point depth | |
| // test (cz >= 40). A strand whose centre is close to the camera can still | |
| // have a visible far end. | |
| if (gcz + f.radius < 40) continue; | |
| const gpx = CX + ((dx * xa[0] + dy * xa[1] + dz * xa[2]) / gcz) * FOCAL; | |
| const gpy = CY - ((dx * ya[0] + dy * ya[1] + dz * ya[2]) / gcz) * FOCAL; | |
| // Conservative projected radius: a point at the front of the sphere | |
| // projects further off-centre than one at the centre, so bound it with the | |
| // nearest possible depth. This can only reject strands that had nothing to | |
| // draw, never part of one. | |
| const near = gcz - f.radius; | |
| if (near > 0) { | |
| const sr = f.radius * FOCAL / near; | |
| if (gpx < -300 - sr || gpx > W + 300 + sr || gpy < -300 - sr || gpy > H + 300 + sr) continue; | |
| } | |
| const n = f.pts.length; | |
| for (let i = 0; i < n; i++) P_OK[i] = 0; | |
| // project the smoothed polyline once | |
| for (let i = 0; i < n; i++) { | |
| const nd = f.pts[i]; | |
| const x = nd[0] + f.off[0], y = nd[1] + f.off[1], z = nd[2] + f.off[2]; | |
| const px2 = x - cp[0], py2 = y - cp[1], pz2 = z - cp[2]; | |
| const cz = px2 * za[0] + py2 * za[1] + pz2 * za[2]; | |
| const fade = filFade(Math.sqrt(px2 * px2 + py2 * py2 + pz2 * pz2)); | |
| if (cz < 40 || fade <= 0.01) continue; | |
| const sx = CX + ((px2 * xa[0] + py2 * xa[1] + pz2 * xa[2]) / cz) * FOCAL; | |
| const sy = CY - ((px2 * ya[0] + py2 * ya[1] + pz2 * ya[2]) / cz) * FOCAL; | |
| if (sx < -300 || sx > W + 300 || sy < -300 || sy > H + 300) continue; | |
| const p = P_FIL[i]; | |
| p.x = sx; p.y = sy; p.z = cz; p.fade = fade; | |
| P_OK[i] = 1; | |
| } | |
| // One stroke per run of segments sharing a quantised alpha, instead of one | |
| // stroke per segment. Measured over random views: ~300 strokes/frame | |
| // become ~39. A gap in the polyline ends the run and starts a new path. | |
| for (let pass = 0; pass < 2; pass++) { | |
| const mult = pass === 0 ? 0.1 : 0.3; | |
| const minW = pass === 0 ? 1.5 : 0.6; | |
| const maxW = pass === 0 ? 14 : 6; | |
| const scale = pass === 0 ? 2.2 : 0.45; | |
| let bucket = -1, cnt = 0, wsum = 0; | |
| for (let i = 0; i + 1 < n; i++) { | |
| if (!P_OK[i] || !P_OK[i + 1]) { flushRun(f.colorStr, bucket, cnt, wsum); cnt = 0; wsum = 0; bucket = -1; continue; } | |
| const A = P_FIL[i], B = P_FIL[i + 1]; | |
| const a = (A.fade + B.fade) * 0.5 * f.glow * mult; | |
| const wpx = f.w * FOCAL / ((A.z + B.z) * 0.5); | |
| const w = Math.min(maxW, Math.max(minW, wpx * scale)); | |
| const b = Math.min(63, (a * 63) | 0); | |
| if (b !== bucket) { | |
| flushRun(f.colorStr, bucket, cnt, wsum); | |
| bucket = b; cnt = 0; wsum = 0; | |
| ctx.beginPath(); | |
| } | |
| ctx.moveTo(A.x, A.y); | |
| ctx.lineTo(B.x, B.y); | |
| cnt++; wsum += w; | |
| } | |
| flushRun(f.colorStr, bucket, cnt, wsum); | |
| } | |
| // galaxy clusters gathered at the knots | |
| for (let i = 0; i < f.nodes.length; i++) { | |
| const nd = f.nodes[i]; | |
| const x = nd[0] + f.off[0], y = nd[1] + f.off[1], z = nd[2] + f.off[2]; | |
| const dxk = x - cp[0], dyk = y - cp[1], dzk = z - cp[2]; | |
| const cz = dxk * za[0] + dyk * za[1] + dzk * za[2]; | |
| const fade = filFade(Math.sqrt(dxk * dxk + dyk * dyk + dzk * dzk)); | |
| if (cz < 40 || fade <= 0.05) continue; | |
| const px = CX + ((dxk * xa[0] + dyk * xa[1] + dzk * xa[2]) / cz) * FOCAL; | |
| const py = CY - ((dxk * ya[0] + dyk * ya[1] + dzk * ya[2]) / cz) * FOCAL; | |
| if (px < -80 || px > W + 80 || py < -80 || py > H + 80) continue; | |
| const hs = Math.min(120, 120 * FOCAL / cz); | |
| ctx.globalAlpha = fade * 0.3 * f.glow; | |
| ctx.drawImage(softCool, px - hs / 2, py - hs / 2, hs, hs); | |
| ctx.globalAlpha = 1; | |
| const knots = f.knots[i]; | |
| for (let k = 0; k < knots.length; k++) { | |
| const kx = x + knots[k][0] - cp[0], ky = y + knots[k][1] - cp[1], kz = z + knots[k][2] - cp[2]; | |
| const kcz = kx * za[0] + ky * za[1] + kz * za[2]; | |
| if (kcz < 40) continue; | |
| const qx = CX + ((kx * xa[0] + ky * xa[1] + kz * xa[2]) / kcz) * FOCAL; | |
| const qy = CY - ((kx * ya[0] + ky * ya[1] + kz * ya[2]) / kcz) * FOCAL; | |
| if (qx < -2 || qx > W + 2 || qy < -2 || qy > H + 2) continue; | |
| cluster.rect(qx, qy, 1, 1); | |
| } | |
| } | |
| } | |
| ctx.globalAlpha = 0.45; | |
| ctx.fillStyle = 'rgb(215,230,255)'; | |
| ctx.fill(cluster); | |
| ctx.globalAlpha = 1; | |
| ctx.globalCompositeOperation = 'source-over'; | |
| } | |
| function drawGalaxies(cam) { | |
| const G_MAX = FIELD_GAL * Math.sqrt(3); | |
| list.length = 0; | |
| for (const g of galaxies) { | |
| if (g.x < ship.pos[0] - FIELD_GAL) g.x += 2 * FIELD_GAL; | |
| else if (g.x > ship.pos[0] + FIELD_GAL) g.x -= 2 * FIELD_GAL; | |
| if (g.y < ship.pos[1] - FIELD_GAL) g.y += 2 * FIELD_GAL; | |
| else if (g.y > ship.pos[1] + FIELD_GAL) g.y -= 2 * FIELD_GAL; | |
| if (g.z < ship.pos[2] - FIELD_GAL) g.z += 2 * FIELD_GAL; | |
| else if (g.z > ship.pos[2] + FIELD_GAL) g.z -= 2 * FIELD_GAL; | |
| const dx = g.x - cam.camPos[0], dy = g.y - cam.camPos[1], dz = g.z - cam.camPos[2]; | |
| const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); | |
| if (dist > G_MAX) continue; | |
| const t = Math.max(0, Math.min(1, (FIELD_GAL - dist) / (FIELD_GAL - FIELD))); | |
| const fade = smoothstep(t); | |
| if (fade < 0.01) continue; | |
| const p = project(g.x, g.y, g.z, cam, P_ONE); | |
| if (!p) continue; | |
| const e = ENTRY[list.length]; | |
| e.it = g; e.x = p.x; e.y = p.y; e.z = p.z; e.dist = dist; e.fade = fade; | |
| list.push(e); | |
| } | |
| list.sort((a, b) => b.dist - a.dist); | |
| ctx.globalCompositeOperation = 'lighter'; | |
| for (const e of list) { | |
| const g = e.it; | |
| const r = (g.r / e.z) * FOCAL; | |
| if (r < 1.5) continue; | |
| ctx.save(); | |
| ctx.globalAlpha = 0.62 * e.fade; | |
| ctx.translate(e.x, e.y); | |
| ctx.rotate(g.rot); | |
| ctx.scale(1, g.squash); | |
| ctx.drawImage(galaxyTex[g.ci], -r, -r, r * 2, r * 2); | |
| ctx.restore(); | |
| } | |
| ctx.globalAlpha = 1; | |
| ctx.globalCompositeOperation = 'source-over'; | |
| } | |
| function drawPlanets(cam) { | |
| const P_MAX = FIELD_PLANET * Math.sqrt(3); | |
| list.length = 0; | |
| for (const pl of planets) { | |
| for (let i = 0; i < 3; i++) { | |
| if (pl.x < ship.pos[0] - FIELD_PLANET) pl.x += 2 * FIELD_PLANET; | |
| else if (pl.x > ship.pos[0] + FIELD_PLANET) pl.x -= 2 * FIELD_PLANET; | |
| if (pl.y < ship.pos[1] - FIELD_PLANET) pl.y += 2 * FIELD_PLANET; | |
| else if (pl.y > ship.pos[1] + FIELD_PLANET) pl.y -= 2 * FIELD_PLANET; | |
| if (pl.z < ship.pos[2] - FIELD_PLANET) pl.z += 2 * FIELD_PLANET; | |
| else if (pl.z > ship.pos[2] + FIELD_PLANET) pl.z -= 2 * FIELD_PLANET; | |
| } | |
| const dx = pl.x - cam.camPos[0], dy = pl.y - cam.camPos[1], dz = pl.z - cam.camPos[2]; | |
| const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); | |
| if (dist > P_MAX) continue; | |
| const t = Math.max(0, Math.min(1, (FIELD_PLANET - dist) / (FIELD_PLANET - FIELD))); | |
| const fade = smoothstep(t); | |
| if (fade < 0.01) continue; | |
| const p = project(pl.x, pl.y, pl.z, cam, P_ONE); | |
| if (!p) continue; | |
| const r = (pl.r / p.z) * FOCAL; | |
| if (r < 1.5) continue; | |
| const e = ENTRY[list.length]; | |
| e.it = pl; e.x = p.x; e.y = p.y; e.z = p.z; e.r = r; e.dist = dist; e.fade = fade; | |
| e.dx = dx; e.dy = dy; e.dz = dz; | |
| list.push(e); | |
| } | |
| list.sort((a, b) => b.dist - a.dist); | |
| // Sun direction in camera space: once per frame, not once per planet. | |
| const lx = SUN_DIR[0] * cam.xAxis[0] + SUN_DIR[1] * cam.xAxis[1] + SUN_DIR[2] * cam.xAxis[2]; | |
| const ly = SUN_DIR[0] * cam.yAxis[0] + SUN_DIR[1] * cam.yAxis[1] + SUN_DIR[2] * cam.yAxis[2]; | |
| for (const e of list) { | |
| const pl = e.it, px = e.x, py = e.y, r = e.r, fade = e.fade; | |
| const dl = Math.sqrt(e.dx * e.dx + e.dy * e.dy + e.dz * e.dz) || 1; | |
| const f = (SUN_DIR[0] * e.dx + SUN_DIR[1] * e.dy + SUN_DIR[2] * e.dz) / dl; // +1 = night side to us | |
| const phase = (1 - f) * 0.5; // 1 = full, 0 = new | |
| let ux = lx, uy = -ly; | |
| const ul = Math.sqrt(ux * ux + uy * uy); | |
| if (ul < 1e-4) { ux = 1; uy = 0; } else { ux /= ul; uy /= ul; } | |
| const ringN = pl.ring ? ringProjection(pl, cam) : 0; | |
| if (ringN) strokeRing(pl, ringN, 1, fade); | |
| // surface | |
| ctx.save(); | |
| ctx.beginPath(); | |
| ctx.arc(px, py, r, 0, TAU); | |
| ctx.clip(); | |
| ctx.globalAlpha = fade; | |
| ctx.save(); | |
| ctx.translate(px, py); | |
| ctx.rotate(pl.spin); | |
| ctx.drawImage(planetTex[pl.tex], -r, -r, r * 2, r * 2); | |
| ctx.restore(); | |
| // day/night terminator multiplied over the surface only | |
| const soft = 0.24; | |
| const grad = ctx.createLinearGradient(px - ux * r, py - uy * r, px + ux * r, py + uy * r); | |
| const dark = 'rgb(11,13,22)', light = 'rgb(255,255,255)'; | |
| grad.addColorStop(0, dark); | |
| grad.addColorStop(Math.min(1, phase), dark); | |
| grad.addColorStop(Math.max(0, Math.min(1, phase + soft)), light); | |
| grad.addColorStop(1, light); | |
| ctx.globalCompositeOperation = 'multiply'; | |
| ctx.fillStyle = grad; | |
| ctx.beginPath(); | |
| ctx.arc(px, py, r, 0, TAU); | |
| ctx.fill(); | |
| ctx.restore(); | |
| // atmosphere rim | |
| const ac = pl.atmoStr; | |
| ctx.globalCompositeOperation = 'lighter'; | |
| const ag = ctx.createRadialGradient(px, py, r * 0.82, px, py, r * 1.3); | |
| ag.addColorStop(0, 'rgba(' + ac + ',0)'); | |
| ag.addColorStop(0.45, 'rgba(' + ac + ',' + alphaStr(0.2 * fade) + ')'); | |
| ag.addColorStop(1, 'rgba(' + ac + ',0)'); | |
| ctx.fillStyle = ag; | |
| ctx.beginPath(); | |
| ctx.arc(px, py, r * 1.3, 0, TAU); | |
| ctx.fill(); | |
| ctx.globalCompositeOperation = 'source-over'; | |
| if (ringN) strokeRing(pl, ringN, 2, fade); | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| // Ring points are classified by depth against the planet centre, so the | |
| // far half is drawn behind the body and the near half over it. Points go | |
| // into the P_RING pool and the front/behind flags into RING_FRONT; the | |
| // function returns the count (0 = nothing to draw). | |
| function ringProjection(pl, cam) { | |
| const centre = project(pl.x, pl.y, pl.z, cam, P_CENTER); | |
| if (!centre) return 0; | |
| const N = 56; | |
| for (let i = 0; i <= N; i++) { | |
| const a = i / N * TAU; | |
| const ca = Math.cos(a), sa = Math.sin(a); | |
| const R = pl.r * pl.ringR; | |
| const q = project(pl.x + (pl.ringA[0] * ca + pl.ringB[0] * sa) * R, | |
| pl.y + (pl.ringA[1] * ca + pl.ringB[1] * sa) * R, | |
| pl.z + (pl.ringA[2] * ca + pl.ringB[2] * sa) * R, cam, P_RING[i]); | |
| RING_FRONT[i] = q ? (q.z < centre.z ? 2 : 1) : 0; | |
| } | |
| return N + 1; | |
| } | |
| function strokeRing(pl, n, front, fade) { | |
| ctx.lineCap = 'round'; | |
| const style = 'rgba(' + pl.atmoStr + ',' + alphaStr(0.26 * fade) + ')'; // built once, not per segment | |
| for (let i = 0; i + 1 < n; i++) { | |
| if (RING_FRONT[i] !== front || RING_FRONT[i + 1] !== front) continue; | |
| const A = P_RING[i], B = P_RING[i + 1]; | |
| const z = (A.z + B.z) * 0.5; | |
| ctx.strokeStyle = style; | |
| ctx.lineWidth = Math.max(0.7, pl.ringW * FOCAL / z); // width still varies with depth | |
| ctx.beginPath(); | |
| ctx.moveTo(A.x, A.y); | |
| ctx.lineTo(B.x, B.y); | |
| ctx.stroke(); | |
| } | |
| } | |
| function drawMeteors(cam) { | |
| if (!meteors.length) return; | |
| ctx.globalCompositeOperation = 'lighter'; | |
| ctx.lineCap = 'round'; | |
| for (const m of meteors) { | |
| const head = project(m.x, m.y, m.z, cam, P_ONE); | |
| if (!head) continue; | |
| const sp = Math.sqrt(m.vx * m.vx + m.vy * m.vy + m.vz * m.vz) || 1; | |
| const tl = sp * m.trail; | |
| const tail = project(m.x - m.vx / sp * tl, m.y - m.vy / sp * tl, m.z - m.vz / sp * tl, cam, P_TWO); | |
| if (!tail) continue; | |
| const t = m.life / m.max; | |
| let env = 1; | |
| if (t < 0.18) env = t / 0.18; | |
| else if (t > 0.6) env = Math.max(0, (1 - t) / 0.4); | |
| if (env <= 0.02 || !tail) continue; | |
| const cs = m.warm ? '255,226,190' : '205,235,255'; | |
| const g = ctx.createLinearGradient(head.x, head.y, tail.x, tail.y); | |
| g.addColorStop(0, 'rgba(' + cs + ',' + alphaStr(0.95 * env) + ')'); | |
| g.addColorStop(0.35, 'rgba(' + cs + ',' + alphaStr(0.4 * env) + ')'); | |
| g.addColorStop(1, 'rgba(' + cs + ',0)'); | |
| ctx.strokeStyle = g; | |
| ctx.lineWidth = m.w; | |
| ctx.beginPath(); | |
| ctx.moveTo(head.x, head.y); | |
| ctx.lineTo(tail.x, tail.y); | |
| ctx.stroke(); | |
| const hs = (m.fireball ? 52 : 24) * env; | |
| ctx.globalAlpha = 0.8 * env; | |
| ctx.drawImage(m.warm ? softWarm : softCool, head.x - hs / 2, head.y - hs / 2, hs, hs); | |
| ctx.globalAlpha = 1; | |
| } | |
| ctx.globalCompositeOperation = 'source-over'; | |
| } | |
| function drawExhaust(cam) { | |
| if (!exhaust.length) return; | |
| ctx.globalCompositeOperation = 'lighter'; | |
| for (const p of exhaust) { | |
| const q = project(p.x, p.y, p.z, cam, P_ONE); | |
| if (!q) continue; | |
| const t = p.life / p.max; | |
| const a = (1 - t) * (1 - t) * 0.55; | |
| let hs = p.r * (1 + t * 2.4) * 2 * FOCAL / q.z; | |
| if (hs < 1) continue; | |
| if (hs > 34) hs = 34; | |
| ctx.globalAlpha = a; | |
| ctx.drawImage(softCool, q.x - hs / 2, q.y - hs / 2, hs, hs); | |
| } | |
| ctx.globalAlpha = 1; | |
| ctx.globalCompositeOperation = 'source-over'; | |
| } | |
| const DUST_ALPHAS = [0.1, 0.22, 0.36]; | |
| function drawDust(cam) { | |
| const cp = cam.camPos; | |
| const paths = [new Path2D(), new Path2D(), new Path2D()]; | |
| const TRAIL = 0.05; // motion-blur trail time — the speed cue | |
| const vx = ship.vel[0] * TRAIL, vy = ship.vel[1] * TRAIL, vz = ship.vel[2] * TRAIL; | |
| for (const d of dust) { | |
| if (d.x < cp[0] - DUST_R) d.x += 2 * DUST_R; | |
| else if (d.x > cp[0] + DUST_R) d.x -= 2 * DUST_R; | |
| if (d.y < cp[1] - DUST_R) d.y += 2 * DUST_R; | |
| else if (d.y > cp[1] + DUST_R) d.y -= 2 * DUST_R; | |
| if (d.z < cp[2] - DUST_R) d.z += 2 * DUST_R; | |
| else if (d.z > cp[2] + DUST_R) d.z -= 2 * DUST_R; | |
| const dx = d.x - cp[0], dy = d.y - cp[1], dz = d.z - cp[2]; | |
| const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); | |
| if (dist > DUST_R || dist < 12) continue; | |
| const cz = dx * cam.zAxis[0] + dy * cam.zAxis[1] + dz * cam.zAxis[2]; | |
| if (cz < 20) continue; | |
| const cx = dx * cam.xAxis[0] + dy * cam.xAxis[1] + dz * cam.xAxis[2]; | |
| const cy = dx * cam.yAxis[0] + dy * cam.yAxis[1] + dz * cam.yAxis[2]; | |
| const px = CX + (cx / cz) * FOCAL; | |
| if (px < -20 || px > W + 20) continue; | |
| const py = CY - (cy / cz) * FOCAL; | |
| if (py < -20 || py > H + 20) continue; | |
| // the mote appeared at here a moment ago: that's the streak | |
| const cz2 = cz + (vx * cam.zAxis[0] + vy * cam.zAxis[1] + vz * cam.zAxis[2]); | |
| if (cz2 < 20) continue; | |
| const cx2 = cx + vx * cam.xAxis[0] + vy * cam.xAxis[1] + vz * cam.xAxis[2]; | |
| const cy2 = cy + vx * cam.yAxis[0] + vy * cam.yAxis[1] + vz * cam.yAxis[2]; | |
| const qx = CX + (cx2 / cz2) * FOCAL; | |
| const qy = CY - (cy2 / cz2) * FOCAL; | |
| const bucket = Math.min(2, ((1 - dist / DUST_R) * 3) | 0); | |
| const path = paths[bucket]; | |
| path.moveTo(px, py); | |
| path.lineTo(qx, qy); | |
| } | |
| ctx.globalCompositeOperation = 'lighter'; | |
| ctx.strokeStyle = 'rgb(200,225,255)'; | |
| ctx.lineWidth = 1; | |
| for (let i = 0; i < 3; i++) { | |
| ctx.globalAlpha = DUST_ALPHAS[i]; | |
| ctx.stroke(paths[i]); | |
| } | |
| ctx.globalAlpha = 1; | |
| ctx.globalCompositeOperation = 'source-over'; | |
| } | |
| function drawCursor() { | |
| if (cursorAlpha <= 0.01) return; | |
| ctx.save(); | |
| ctx.globalAlpha = cursorAlpha; | |
| ctx.strokeStyle = '#cfeaff'; | |
| ctx.fillStyle = '#cfeaff'; | |
| ctx.lineWidth = 1.5; | |
| ctx.lineCap = 'round'; | |
| ctx.beginPath(); | |
| ctx.moveTo(mouseX - 13, mouseY); ctx.lineTo(mouseX - 5, mouseY); | |
| ctx.moveTo(mouseX + 5, mouseY); ctx.lineTo(mouseX + 13, mouseY); | |
| ctx.moveTo(mouseX, mouseY - 13); ctx.lineTo(mouseX, mouseY - 5); | |
| ctx.moveTo(mouseX, mouseY + 5); ctx.lineTo(mouseX, mouseY + 13); | |
| ctx.stroke(); | |
| ctx.beginPath(); | |
| ctx.arc(mouseX, mouseY, 1.6, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.restore(); | |
| } | |
| // ------------------------------------------------------------------ | |
| // Main loop | |
| // ------------------------------------------------------------------ | |
| function frame(now) { | |
| let dt = (now - last) / 1000; | |
| last = now; | |
| // Fixed: guard against zero or negative dt to avoid NaN in yawRate. | |
| if (dt <= 0) { | |
| requestAnimationFrame(frame); | |
| return; | |
| } | |
| if (dt > 0.05) dt = 0.05; // clamp big gaps | |
| update(dt); | |
| const cam = cameraBasis(dt); | |
| spawnMeteors(cam, now); | |
| drawBackground(); | |
| drawSky(cam); | |
| drawNebulae(cam); | |
| drawGalaxies(cam); | |
| drawFilaments(cam); | |
| drawStars(cam); | |
| drawPlanets(cam); | |
| drawAsteroids(cam); | |
| drawExhaust(cam); | |
| drawMeteors(cam); | |
| drawDust(cam); | |
| drawShip(cam, now); | |
| if (vignetteLayer) { | |
| ctx.drawImage(vignetteLayer, 0, 0, W, H); | |
| } | |
| // Cursor fade: visible while the mouse moved within the last second, | |
| // eases out after that, eases back in on movement. | |
| { | |
| const idle = now - lastMove; | |
| const target = idle < 1000 ? 1 : 0; | |
| const speed = target === 1 ? 12 : 4; | |
| cursorAlpha += (target - cursorAlpha) * Math.min(1, speed * dt); | |
| if (target === 0 && cursorAlpha < 0.01) cursorAlpha = 0; | |
| else if (target === 1 && cursorAlpha > 0.99) cursorAlpha = 1; | |
| } | |
| drawCursor(); | |
| requestAnimationFrame(frame); | |
| } | |
| // Fixed: ensure a valid camera basis exists before the first click. | |
| cameraBasis(0.016); | |
| requestAnimationFrame(frame); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |