/** * shockwave.js — Shockwave ripple, fireworks, and merge flash * * Category: DATA-TETHERED AESTHETIC * Data source: PR merge events (WebSocket/event dispatch) * * Triggered externally on merge events: * - triggerShockwave() — expanding concentric ring waves from scene centre * - triggerFireworks() — multi-burst particle fireworks above the platform * - triggerMergeFlash() — both of the above + star/constellation color flash * * The merge flash accepts optional callbacks so terrain/stars.js can own * its own state while shockwave.js coordinates the event. */ import * as THREE from 'three'; const SHOCKWAVE_RING_COUNT = 3; const SHOCKWAVE_MAX_RADIUS = 14; const SHOCKWAVE_DURATION = 2.5; // seconds const FIREWORK_COLORS = [0xff4466, 0xffaa00, 0x00ffaa, 0x4488ff, 0xff44ff, 0xffff44, 0x00ffff]; const FIREWORK_BURST_PARTICLES = 80; const FIREWORK_BURST_DURATION = 2.2; // seconds const FIREWORK_GRAVITY = -5.0; let _scene = null; let _clock = null; /** * @typedef {{mesh: THREE.Mesh, mat: THREE.MeshBasicMaterial, startTime: number, delay: number}} ShockwaveRing * @typedef {{points: THREE.Points, geo: THREE.BufferGeometry, mat: THREE.PointsMaterial, origins: Float32Array, velocities: Float32Array, startTime: number}} FireworkBurst */ /** @type {ShockwaveRing[]} */ const shockwaveRings = []; /** @type {FireworkBurst[]} */ const fireworkBursts = []; /** * Optional callbacks injected via init() for the merge flash star/constellation effect. * terrain/stars.js can register its own handler when it is initialized. * @type {Array<() => void>} */ const _mergeFlashCallbacks = []; /** * @param {THREE.Scene} scene * @param {object} _state (unused — triggered by events, not state polling) * @param {object} _theme * @param {{ clock: THREE.Clock }} options Pass the shared clock in. */ export function init(scene, _state, _theme, options = {}) { _scene = scene; _clock = options.clock ?? new THREE.Clock(); } /** * Register an external callback to be called during triggerMergeFlash(). * Use this to let other modules (stars, constellation lines) animate their own flash. * @param {() => void} fn */ export function onMergeFlash(fn) { _mergeFlashCallbacks.push(fn); } export function triggerShockwave() { if (!_scene || !_clock) return; 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) { if (!_scene || !_clock) return; 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 }); } export 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); } } export function triggerMergeFlash() { triggerShockwave(); // Notify registered handlers (e.g. terrain/stars.js) for (const fn of _mergeFlashCallbacks) fn(); } export function update(elapsed, _delta) { // Animate 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.mesh.geometry.dispose(); ring.mat.dispose(); shockwaveRings.splice(i, 1); continue; } const eased = 1 - Math.pow(1 - t, 2); ring.mesh.scale.setScalar(eased * SHOCKWAVE_MAX_RADIUS + 0.1); ring.mat.opacity = (1 - t) * 0.9; } // Animate 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.geo.dispose(); burst.mat.dispose(); fireworkBursts.splice(i, 1); continue; } burst.mat.opacity = t < 0.6 ? 1.0 : (1.0 - t) / 0.4; const pos = burst.geo.attributes.position.array; const vel = burst.velocities; const org = burst.origins; const halfGAge2 = 0.5 * FIREWORK_GRAVITY * age * age; for (let j = 0; j < FIREWORK_BURST_PARTICLES; j++) { pos[j * 3] = org[j * 3] + vel[j * 3] * age; pos[j * 3 + 1] = org[j * 3 + 1] + vel[j * 3 + 1] * age + halfGAge2; pos[j * 3 + 2] = org[j * 3 + 2] + vel[j * 3 + 2] * age; } burst.geo.attributes.position.needsUpdate = true; } }