diff --git a/app.js b/app.js index 7765897..b166575 100644 --- a/app.js +++ b/app.js @@ -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. */