feat: add meteor shower effect triggering randomly every 2-5 min
Some checks failed
CI / validate (pull_request) Failing after 11s
CI / auto-merge (pull_request) Has been skipped

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 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-24 00:39:36 -04:00
parent eadc104842
commit 557bedcf1a

125
app.js
View File

@@ -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 1530 s after load so it's visible on first visit;
// subsequent showers every 25 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.10.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; // 25 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();
}