Phase 4 of app.js modularization. Extracts all visual effects into self-contained ES modules under modules/effects/ following the init(scene,state,theme)/update(elapsed,delta) contract defined in CLAUDE.md. Modules created: - matrix-rain.js — commit-density-driven 2D canvas rain (DATA-TETHERED AESTHETIC) - lightning.js — floating crystals + lightning arcs (DATA-TETHERED AESTHETIC) - energy-beam.js — Batcave terminal beam (DATA-TETHERED AESTHETIC) - rune-ring.js — portal-tethered orbiting rune sprites (DATA-TETHERED AESTHETIC) - gravity-zones.js — portal-position rising particle zones (DATA-TETHERED AESTHETIC) - shockwave.js — shockwave ripple, fireworks, merge flash (DATA-TETHERED AESTHETIC) All modules read data tethers from the state bus (state.zoneIntensity, state.portals, state.activeAgentCount, state.commitHashes). No mocked data. app.js unchanged — final wiring happens in Phase 5 slim-down. Refs #423 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
184 lines
6.3 KiB
JavaScript
184 lines
6.3 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|
|
}
|