From a5f67b8706b94aee299dc2e29900a1fb1f630655 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 23:07:52 -0400 Subject: [PATCH] feat: re-implement shockwave and fireworks on PR merge (#479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds shockwave ring ripples and multi-burst fireworks directly into app.js (single-file architecture), triggered by: - `pr-notification` window event with action=merged → triggerMergeFlash() - `milestone-complete` window event → triggerFireworks() triggerMergeFlash fires both a cyan shockwave floor ripple (3 rings, 2.5s) and 6 staggered firework bursts (gravity-affected particles, 2.2s). Animation updated each frame inside the existing gameLoop. Fixes #479 Co-Authored-By: Claude Sonnet 4.6 --- app.js | 130 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/app.js b/app.js index 9a03e40..410bdf8 100644 --- a/app.js +++ b/app.js @@ -45,6 +45,17 @@ let chatOpen = true; let loadProgress = 0; let performanceTier = 'high'; +// ═══ CELEBRATIONS STATE ═══ +const shockwaveRings = []; +const fireworkBursts = []; +const SHOCKWAVE_DURATION = 2.5; +const SHOCKWAVE_MAX_RADIUS = 14; +const SHOCKWAVE_RING_COUNT = 3; +const FIREWORK_BURST_PARTICLES = 80; +const FIREWORK_BURST_DURATION = 2.2; +const FIREWORK_GRAVITY = -5.0; +const FIREWORK_COLORS = [0xff4466, 0xffaa00, 0x00ffaa, 0x4488ff, 0xff44ff, 0xffff44, 0x00ffff]; + // ═══ NAVIGATION SYSTEM ═══ const NAV_MODES = ['walk', 'orbit', 'fly']; let navModeIdx = 0; @@ -141,6 +152,17 @@ async function init() { window.addEventListener('resize', onResize); debugOverlay = document.getElementById('debug-overlay'); + window.addEventListener('pr-notification', (event) => { + console.log('[nexus] PR notification:', event.detail); + if (event.detail && event.detail.action === 'merged') { + triggerMergeFlash(); + } + }); + + window.addEventListener('milestone-complete', () => { + triggerFireworks(); + }); + updateLoad(100); setTimeout(() => { @@ -1480,6 +1502,47 @@ function gameLoop() { core.material.emissiveIntensity = 1.5 + Math.sin(elapsed * 2) * 0.5; } + // Update shockwave rings + for (let i = shockwaveRings.length - 1; i >= 0; i--) { + const ring = shockwaveRings[i]; + const age = elapsed - ring.startTime - ring.delay; + if (age < 0) continue; + const t = Math.min(age / SHOCKWAVE_DURATION, 1); + if (t >= 1) { + scene.remove(ring.mesh); + ring.mat.dispose(); + ring.mesh.geometry.dispose(); + shockwaveRings.splice(i, 1); + continue; + } + const radius = t * SHOCKWAVE_MAX_RADIUS; + ring.mesh.scale.setScalar(radius); + ring.mat.opacity = (1 - t) * 0.8; + } + + // Update firework bursts + for (let i = fireworkBursts.length - 1; i >= 0; i--) { + const burst = fireworkBursts[i]; + const age = elapsed - burst.startTime; + const t = Math.min(age / FIREWORK_BURST_DURATION, 1); + if (t >= 1) { + scene.remove(burst.points); + burst.mat.dispose(); + burst.geo.dispose(); + fireworkBursts.splice(i, 1); + continue; + } + burst.mat.opacity = 1 - t * t; + const halfGAge2 = 0.5 * FIREWORK_GRAVITY * age * age; + const posArr = burst.geo.attributes.position.array; + for (let j = 0; j < FIREWORK_BURST_PARTICLES; j++) { + posArr[j * 3] = burst.origins[j * 3] + burst.velocities[j * 3] * age; + posArr[j * 3 + 1] = burst.origins[j * 3 + 1] + burst.velocities[j * 3 + 1] * age + halfGAge2; + posArr[j * 3 + 2] = burst.origins[j * 3 + 2] + burst.velocities[j * 3 + 2] * age; + } + burst.geo.attributes.position.needsUpdate = true; + } + composer.render(); frameCount++; @@ -1679,6 +1742,73 @@ function addAgentLog(agentId, text) { } } +// ═══ CELEBRATIONS ═══ +function triggerShockwave() { + const now = clock.getElapsedTime(); + for (let i = 0; i < SHOCKWAVE_RING_COUNT; i++) { + const mat = new THREE.MeshBasicMaterial({ + color: 0x00ffff, transparent: true, opacity: 0, + side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending, + }); + const geo = new THREE.RingGeometry(0.9, 1.0, 64); + const mesh = new THREE.Mesh(geo, mat); + mesh.rotation.x = -Math.PI / 2; + mesh.position.y = 0.02; + scene.add(mesh); + shockwaveRings.push({ mesh, mat, startTime: now, delay: i * 0.35 }); + } +} + +function spawnFireworkBurst(origin, color) { + const now = clock.getElapsedTime(); + const count = FIREWORK_BURST_PARTICLES; + const positions = new Float32Array(count * 3); + const origins = new Float32Array(count * 3); + const velocities = new Float32Array(count * 3); + for (let i = 0; i < count; i++) { + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(2 * Math.random() - 1); + const speed = 2.5 + Math.random() * 3.5; + velocities[i * 3] = Math.sin(phi) * Math.cos(theta) * speed; + velocities[i * 3 + 1] = Math.sin(phi) * Math.sin(theta) * speed; + velocities[i * 3 + 2] = Math.cos(phi) * speed; + origins[i * 3] = origin.x; + origins[i * 3 + 1] = origin.y; + origins[i * 3 + 2] = origin.z; + positions[i * 3] = origin.x; + positions[i * 3 + 1] = origin.y; + positions[i * 3 + 2] = origin.z; + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const mat = new THREE.PointsMaterial({ + color, size: 0.35, 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, origins, velocities, startTime: now }); +} + +function triggerFireworks() { + for (let i = 0; i < 6; i++) { + const delay = i * 0.35; + setTimeout(() => { + const x = (Math.random() - 0.5) * 12; + const y = 8 + Math.random() * 6; + const z = (Math.random() - 0.5) * 12; + const color = FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)]; + spawnFireworkBurst(new THREE.Vector3(x, y, z), color); + }, delay * 1000); + } +} + +function triggerMergeFlash() { + triggerShockwave(); + triggerFireworks(); +} + function triggerHarnessPulse() { if (!harnessPulseMesh) return; harnessPulseMesh.scale.setScalar(0.1); -- 2.43.0