Files
the-nexus/modules/effects/shockwave.js
Alexander Whitestone 0408ceb5bc
All checks were successful
CI / validate (pull_request) Successful in 6s
CI / auto-merge (pull_request) Successful in 10s
feat: add effects modules — matrix rain, lightning, beam, runes, gravity, shockwave
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>
2026-03-24 14:18:42 -04:00

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;
}
}