feat: firework celebration effect on milestone-complete (#241)
Some checks failed
CI / validate (pull_request) Failing after 17s
CI / auto-merge (pull_request) Has been skipped

Adds a `triggerFireworks()` function that launches 7 coloured firework
trails upward from near the island, each exploding into 120 additive-
blended particles that fan out with gravity and fade over ~2.8 s.
Triggered by the `milestone-complete` window event (dispatched by hermes)
and also exposed as `window.triggerFireworks()` for console/testing.

Fixes #241

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-24 01:10:16 -04:00
parent 459b3eb38f
commit 911004c86b

155
app.js
View File

@@ -1357,6 +1357,52 @@ function animate() {
ring.mat.opacity = (1 - t) * 0.9;
}
// Animate firework launches (trails rising upward)
for (let i = fireworkLaunches.length - 1; i >= 0; i--) {
const launch = fireworkLaunches[i];
const age = elapsed - launch.startTime - launch.delay;
if (age < 0) continue;
const t = Math.min(age / FIREWORK_RISE_DURATION, 1);
if (t >= 1) {
scene.remove(launch.mesh);
launch.mesh.geometry.dispose();
launch.mesh.material.dispose();
spawnFireworkBurst(launch.target, launch.color, elapsed);
fireworkLaunches.splice(i, 1);
continue;
}
const pos = new THREE.Vector3().lerpVectors(launch.origin, launch.target, t);
launch.mesh.position.copy(pos);
launch.mesh.material.opacity = 0.6 + Math.sin(t * Math.PI) * 0.4;
}
// Animate firework burst particles
for (let i = fireworkBursts.length - 1; i >= 0; i--) {
const burst = fireworkBursts[i];
const age = elapsed - burst.startTime;
if (age < 0) continue;
const t = Math.min(age / FIREWORK_BURST_DURATION, 1);
if (t >= 1) {
scene.remove(burst.points);
burst.geo.dispose();
burst.mat.dispose();
fireworkBursts.splice(i, 1);
continue;
}
const pos = burst.geo.attributes.position.array;
const vel = burst.velocities;
for (let j = 0; j < FIREWORK_PARTICLE_COUNT; j++) {
pos[j * 3] += vel[j * 3];
pos[j * 3 + 1] += vel[j * 3 + 1];
pos[j * 3 + 2] += vel[j * 3 + 2];
vel[j * 3 + 1] -= FIREWORK_GRAVITY; // gravity
}
burst.geo.attributes.position.needsUpdate = true;
// Fade out: bright in middle, dim at end
burst.mat.opacity = Math.sin(t * Math.PI) * 0.9 + 0.05;
burst.mat.size = 0.28 * (1 - t * 0.5);
}
// Animate rune ring — orbit and vertical float
for (const rune of runeSprites) {
const angle = rune.baseAngle + elapsed * RUNE_ORBIT_SPEED;
@@ -1761,6 +1807,11 @@ window.addEventListener('pr-notification', (/** @type {CustomEvent} */ event) =>
}
});
window.addEventListener('milestone-complete', (/** @type {CustomEvent} */ event) => {
console.log('[hermes] Milestone complete:', event.detail);
triggerFireworks();
});
// === SOVEREIGNTY EASTER EGG ===
const SOVEREIGNTY_WORD = 'sovereignty';
let sovereigntyBuffer = '';
@@ -1866,6 +1917,110 @@ function triggerShockwave() {
}
}
// === FIREWORKS CELEBRATION ===
const FIREWORK_PARTICLE_COUNT = 120;
const FIREWORK_BURST_COUNT = 7;
const FIREWORK_GRAVITY = 0.0035;
const FIREWORK_RISE_DURATION = 1.2; // seconds for launch trail to rise
const FIREWORK_BURST_DURATION = 2.8; // seconds for burst particles to fade
const FIREWORK_COLORS = [0xff4455, 0xffaa00, 0xffff44, 0x44ff88, 0x4488ff, 0xff44ff, 0x00ffff, 0xffd700, 0xffffff];
/**
* Active launch trails rising toward their burst point.
* @type {Array<{mesh: THREE.Mesh, origin: THREE.Vector3, target: THREE.Vector3, color: number, startTime: number, delay: number}>}
*/
const fireworkLaunches = [];
/**
* Active burst particle systems.
* @type {Array<{points: THREE.Points, geo: THREE.BufferGeometry, mat: THREE.PointsMaterial, velocities: Float32Array, startTime: number}>}
*/
const fireworkBursts = [];
/**
* Spawns a burst of colored particles at the given position.
* @param {THREE.Vector3} origin
* @param {number} color
* @param {number} startTime
*/
function spawnFireworkBurst(origin, color, startTime) {
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(FIREWORK_PARTICLE_COUNT * 3);
const velocities = new Float32Array(FIREWORK_PARTICLE_COUNT * 3);
for (let i = 0; i < FIREWORK_PARTICLE_COUNT; i++) {
positions[i * 3] = origin.x;
positions[i * 3 + 1] = origin.y;
positions[i * 3 + 2] = origin.z;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const speed = 0.04 + Math.random() * 0.09;
velocities[i * 3] = Math.sin(phi) * Math.cos(theta) * speed;
velocities[i * 3 + 1] = Math.cos(phi) * speed * 0.8 + 0.015;
velocities[i * 3 + 2] = Math.sin(phi) * Math.sin(theta) * speed;
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color,
size: 0.28,
sizeAttenuation: true,
transparent: true,
opacity: 1.0,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const points = new THREE.Points(geo, mat);
scene.add(points);
fireworkBursts.push({ points, geo, mat, velocities, startTime });
}
/**
* Launches a firework trail that rises to a peak then explodes.
* Call to celebrate a milestone completion.
*/
function triggerFireworks() {
const now = clock.getElapsedTime();
for (let i = 0; i < FIREWORK_BURST_COUNT; i++) {
const x = (Math.random() - 0.5) * 14;
const y = 10 + Math.random() * 8;
const z = (Math.random() - 0.5) * 14;
const color = FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)];
const launchOrigin = new THREE.Vector3(x * 0.3, -1, z * 0.3);
const target = new THREE.Vector3(x, y, z);
// Launch trail: a small bright sphere
const trailGeo = new THREE.SphereGeometry(0.12, 6, 6);
const trailMat = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.9,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const trailMesh = new THREE.Mesh(trailGeo, trailMat);
trailMesh.position.copy(launchOrigin);
scene.add(trailMesh);
fireworkLaunches.push({
mesh: trailMesh,
origin: launchOrigin,
target,
color,
startTime: now,
delay: i * 0.45,
});
}
}
// Expose for external triggers (e.g. hermes events, console testing)
window.triggerFireworks = triggerFireworks;
/**
* Triggers a visual flash effect for merge events: stars pulse bright, lines glow.
*/