From 557bedcf1a5a0d7e018f7781ccffa06abbc1a57f Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:39:36 -0400 Subject: [PATCH] feat: add meteor shower effect triggering randomly every 2-5 min MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a meteor shower system in the Three.js scene: - Each shower spawns 8-17 meteors with staggered delays (0.18–0.46s apart) - Meteors streak as bright cyan-white lines (tail + head) across the sky - Per-shower direction keeps streaks coherent; slight per-meteor variation - Meteors fade out over the last 35% of their ~1.3-1.9s lifetime - First shower fires 15-30s after page load; subsequent showers every 2-5 min - Geometry/material disposed on completion to avoid memory leaks Fixes #113 Co-Authored-By: Claude Sonnet 4.6 --- app.js | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/app.js b/app.js index 23cc7d3..40b33ce 100644 --- a/app.js +++ b/app.js @@ -575,6 +575,92 @@ async function loadSovereigntyStatus() { loadSovereigntyStatus(); +// === METEOR SHOWER === +const METEOR_COLOR = 0xaaddff; +const METEOR_SPEED_BASE = 55; // units/sec +const METEOR_TAIL_BASE = 7; // world units +const METEOR_DURATION_BASE = 1.3; // seconds + +/** @type {Array<{line: THREE.Line, startPos: THREE.Vector3, dir: THREE.Vector3, spawnTime: number, duration: number, speed: number, tailLength: number}>} */ +const activeMeteors = []; + +/** @type {Array<{triggerTime: number, delay: number, showerDir: THREE.Vector3}>} */ +const scheduledMeteors = []; + +// First shower triggers 15–30 s after load so it's visible on first visit; +// subsequent showers every 2–5 minutes. +let nextMeteorShowerAt = 15 + Math.random() * 15; + +/** + * Spawns a single meteor line in world space. + * @param {number} elapsed + * @param {THREE.Vector3} showerDir + */ +function spawnMeteor(elapsed, showerDir) { + const r = 140 + Math.random() * 60; + const phi = Math.random() * Math.PI * 2; + const elevAngle = (Math.random() * 0.4 + 0.1); // 0.1–0.5 rad above equator + const startPos = new THREE.Vector3( + r * Math.cos(elevAngle) * Math.cos(phi), + r * Math.sin(elevAngle) + 20, + r * Math.cos(elevAngle) * Math.sin(phi) + ); + + const dir = showerDir.clone().add( + new THREE.Vector3( + (Math.random() - 0.5) * 0.25, + (Math.random() - 0.5) * 0.25, + (Math.random() - 0.5) * 0.25 + ) + ).normalize(); + + const positions = new Float32Array(6); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + + const mat = new THREE.LineBasicMaterial({ + color: METEOR_COLOR, + transparent: true, + opacity: 1.0, + depthWrite: false, + }); + + const line = new THREE.Line(geo, mat); + scene.add(line); + + activeMeteors.push({ + line, + startPos, + dir, + spawnTime: elapsed, + duration: METEOR_DURATION_BASE + Math.random() * 0.6, + speed: METEOR_SPEED_BASE + Math.random() * 25, + tailLength: METEOR_TAIL_BASE + Math.random() * 5, + }); +} + +/** + * Schedules a burst of meteors with staggered spawn delays. + * @param {number} elapsed + */ +function triggerMeteorShower(elapsed) { + const angle = Math.random() * Math.PI * 2; + const showerDir = new THREE.Vector3( + Math.cos(angle) * 0.65, + -0.55 - Math.random() * 0.25, + Math.sin(angle) * 0.65 + ).normalize(); + + const count = 8 + Math.floor(Math.random() * 10); + for (let i = 0; i < count; i++) { + scheduledMeteors.push({ + triggerTime: elapsed, + delay: i * (0.18 + Math.random() * 0.28), + showerDir, + }); + } +} + // === ANIMATION LOOP === const clock = new THREE.Clock(); @@ -656,6 +742,45 @@ function animate() { sprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.22; } + // Trigger meteor shower on schedule + if (elapsed >= nextMeteorShowerAt) { + triggerMeteorShower(elapsed); + nextMeteorShowerAt = elapsed + 120 + Math.random() * 180; // 2–5 min + } + + // Spawn any scheduled meteors whose delay has elapsed + for (let i = scheduledMeteors.length - 1; i >= 0; i--) { + const sm = scheduledMeteors[i]; + if (elapsed >= sm.triggerTime + sm.delay) { + spawnMeteor(elapsed, sm.showerDir); + scheduledMeteors.splice(i, 1); + } + } + + // Animate active meteors + for (let i = activeMeteors.length - 1; i >= 0; i--) { + const m = activeMeteors[i]; + const age = elapsed - m.spawnTime; + if (age >= m.duration) { + scene.remove(m.line); + m.line.geometry.dispose(); + m.line.material.dispose(); + activeMeteors.splice(i, 1); + continue; + } + const headDist = age * m.speed; + const tailDist = Math.max(0, headDist - m.tailLength); + const head = m.startPos.clone().addScaledVector(m.dir, headDist); + const tail = m.startPos.clone().addScaledVector(m.dir, tailDist); + const pos = m.line.geometry.attributes.position.array; + pos[0] = tail.x; pos[1] = tail.y; pos[2] = tail.z; + pos[3] = head.x; pos[4] = head.y; pos[5] = head.z; + m.line.geometry.attributes.position.needsUpdate = true; + // Fade out in last 35% of lifetime + const t = age / m.duration; + m.line.material.opacity = t > 0.65 ? (1 - t) / 0.35 : 1.0; + } + composer.render(); } -- 2.43.0