diff --git a/modules/effects/energy-beam.js b/modules/effects/energy-beam.js new file mode 100644 index 0000000..3a061d4 --- /dev/null +++ b/modules/effects/energy-beam.js @@ -0,0 +1,56 @@ +/** + * energy-beam.js — Vertical energy beam above the Batcave terminal + * + * Category: DATA-TETHERED AESTHETIC + * Data source: state.activeAgentCount (0 = faint, 3+ = full intensity) + * + * A glowing cyan cylinder rising from the Batcave area. + * Intensity and pulse amplitude are driven by the number of active agents. + */ + +import * as THREE from 'three'; + +const BEAM_RADIUS = 0.2; +const BEAM_HEIGHT = 50; +const BEAM_X = -10; +const BEAM_Y = 0; +const BEAM_Z = -10; + +let _state = null; +let _beamMaterial = null; +let _pulse = 0; + +/** + * @param {THREE.Scene} scene + * @param {object} state Shared state bus (reads state.activeAgentCount) + * @param {object} theme Theme bus (reads theme.colors.accent) + */ +export function init(scene, state, theme) { + _state = state; + + const accentColor = theme?.colors?.accent ?? 0x4488ff; + + const geo = new THREE.CylinderGeometry(BEAM_RADIUS, BEAM_RADIUS * 2.5, BEAM_HEIGHT, 32, 16, true); + _beamMaterial = new THREE.MeshBasicMaterial({ + color: accentColor, + transparent: true, + opacity: 0.6, + blending: THREE.AdditiveBlending, + side: THREE.DoubleSide, + depthWrite: false, + }); + const beam = new THREE.Mesh(geo, _beamMaterial); + beam.position.set(BEAM_X, BEAM_Y + BEAM_HEIGHT / 2, BEAM_Z); + scene.add(beam); +} + +export function update(_elapsed, _delta) { + if (!_beamMaterial) return; + + _pulse += 0.02; + + const agentCount = _state?.activeAgentCount ?? 0; + const agentIntensity = agentCount === 0 ? 0.1 : Math.min(0.1 + agentCount * 0.3, 1.0); + const pulseEffect = Math.sin(_pulse) * 0.15 * agentIntensity; + _beamMaterial.opacity = agentIntensity * 0.6 + pulseEffect; +} diff --git a/modules/effects/gravity-zones.js b/modules/effects/gravity-zones.js new file mode 100644 index 0000000..23c33b1 --- /dev/null +++ b/modules/effects/gravity-zones.js @@ -0,0 +1,176 @@ +/** + * gravity-zones.js — Rising particle gravity anomaly zones + * + * Category: DATA-TETHERED AESTHETIC + * Data source: state.portals (positions and online status) + * + * Each gravity zone is a glowing floor ring with rising particle streams. + * Zones are initially placed at hardcoded positions, then realigned to portal + * positions when portal data loads. Online portals have brighter/faster anomalies; + * offline portals have dim, slow anomalies. + */ + +import * as THREE from 'three'; + +const ANOMALY_FLOOR = 0.2; +const ANOMALY_CEIL = 16.0; + +const DEFAULT_ZONES = [ + { x: -8, z: -6, radius: 3.5, color: 0x00ffcc, particleCount: 180 }, + { x: 10, z: 4, radius: 3.0, color: 0xaa44ff, particleCount: 160 }, + { x: -3, z: 9, radius: 2.5, color: 0xff8844, particleCount: 140 }, +]; + +let _state = null; +let _scene = null; +let _portalsApplied = false; + +/** + * @typedef {{ + * zone: object, + * ring: THREE.Mesh, ringMat: THREE.MeshBasicMaterial, + * disc: THREE.Mesh, discMat: THREE.MeshBasicMaterial, + * points: THREE.Points, geo: THREE.BufferGeometry, + * driftPhases: Float32Array, velocities: Float32Array + * }} GravityZoneObject + */ + +/** @type {GravityZoneObject[]} */ +const gravityZoneObjects = []; + +function _buildZone(zone) { + const ringGeo = new THREE.RingGeometry(zone.radius - 0.15, zone.radius + 0.15, 64); + const ringMat = new THREE.MeshBasicMaterial({ + color: zone.color, transparent: true, opacity: 0.4, + side: THREE.DoubleSide, depthWrite: false, + }); + const ring = new THREE.Mesh(ringGeo, ringMat); + ring.rotation.x = -Math.PI / 2; + ring.position.set(zone.x, ANOMALY_FLOOR + 0.05, zone.z); + _scene.add(ring); + + const discGeo = new THREE.CircleGeometry(zone.radius - 0.15, 64); + const discMat = new THREE.MeshBasicMaterial({ + color: zone.color, transparent: true, opacity: 0.04, + side: THREE.DoubleSide, depthWrite: false, + }); + const disc = new THREE.Mesh(discGeo, discMat); + disc.rotation.x = -Math.PI / 2; + disc.position.set(zone.x, ANOMALY_FLOOR + 0.04, zone.z); + _scene.add(disc); + + const count = zone.particleCount; + const positions = new Float32Array(count * 3); + const driftPhases = new Float32Array(count); + const velocities = new Float32Array(count); + + for (let i = 0; i < count; i++) { + const angle = Math.random() * Math.PI * 2; + const r = Math.sqrt(Math.random()) * zone.radius; + positions[i * 3] = zone.x + Math.cos(angle) * r; + positions[i * 3 + 1] = ANOMALY_FLOOR + Math.random() * (ANOMALY_CEIL - ANOMALY_FLOOR); + positions[i * 3 + 2] = zone.z + Math.sin(angle) * r; + driftPhases[i] = Math.random() * Math.PI * 2; + velocities[i] = 0.03 + Math.random() * 0.04; + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const mat = new THREE.PointsMaterial({ + color: zone.color, size: 0.10, sizeAttenuation: true, + transparent: true, opacity: 0.7, depthWrite: false, + }); + const points = new THREE.Points(geo, mat); + _scene.add(points); + + return { zone: { ...zone }, ring, ringMat, disc, discMat, points, geo, driftPhases, velocities }; +} + +/** + * @param {THREE.Scene} scene + * @param {object} state Shared state bus (reads state.portals) + * @param {object} _theme + */ +export function init(scene, state, _theme) { + _scene = scene; + _state = state; + + for (const zone of DEFAULT_ZONES) { + gravityZoneObjects.push(_buildZone(zone)); + } +} + +function _applyPortals(portals) { + _portalsApplied = true; + for (let i = 0; i < Math.min(portals.length, gravityZoneObjects.length); i++) { + const portal = portals[i]; + const gz = gravityZoneObjects[i]; + const isOnline = portal.status === 'online'; + const c = new THREE.Color(portal.color); + + gz.ring.position.set(portal.position.x, ANOMALY_FLOOR + 0.05, portal.position.z); + gz.disc.position.set(portal.position.x, ANOMALY_FLOOR + 0.04, portal.position.z); + gz.zone.x = portal.position.x; + gz.zone.z = portal.position.z; + gz.zone.color = c.getHex(); + + gz.ringMat.color.copy(c); + gz.discMat.color.copy(c); + gz.points.material.color.copy(c); + + gz.ringMat.opacity = isOnline ? 0.4 : 0.08; + gz.discMat.opacity = isOnline ? 0.04 : 0.01; + gz.points.material.opacity = isOnline ? 0.7 : 0.15; + + // Reposition particles around portal + const pos = gz.geo.attributes.position.array; + for (let j = 0; j < gz.zone.particleCount; j++) { + const angle = Math.random() * Math.PI * 2; + const r = Math.sqrt(Math.random()) * gz.zone.radius; + pos[j * 3] = gz.zone.x + Math.cos(angle) * r; + pos[j * 3 + 2] = gz.zone.z + Math.sin(angle) * r; + } + gz.geo.attributes.position.needsUpdate = true; + } +} + +export function update(elapsed, _delta) { + // Align to portal data once it loads + if (!_portalsApplied) { + const portals = _state?.portals ?? []; + if (portals.length > 0) _applyPortals(portals); + } + + for (const gz of gravityZoneObjects) { + const pos = gz.geo.attributes.position.array; + const count = gz.zone.particleCount; + + for (let i = 0; i < count; i++) { + pos[i * 3 + 1] += gz.velocities[i]; + pos[i * 3] += Math.sin(elapsed * 0.5 + gz.driftPhases[i]) * 0.003; + pos[i * 3 + 2] += Math.cos(elapsed * 0.5 + gz.driftPhases[i]) * 0.003; + + if (pos[i * 3 + 1] > ANOMALY_CEIL) { + const angle = Math.random() * Math.PI * 2; + const r = Math.sqrt(Math.random()) * gz.zone.radius; + pos[i * 3] = gz.zone.x + Math.cos(angle) * r; + pos[i * 3 + 1] = ANOMALY_FLOOR + Math.random() * 2.0; + pos[i * 3 + 2] = gz.zone.z + Math.sin(angle) * r; + } + } + gz.geo.attributes.position.needsUpdate = true; + + // Breathing glow pulse on ring/disc + gz.ringMat.opacity = 0.3 + Math.sin(elapsed * 1.5 + gz.zone.x) * 0.15; + gz.discMat.opacity = 0.02 + Math.sin(elapsed * 1.5 + gz.zone.x) * 0.02; + } +} + +/** + * Re-align zones to current portal data. + * Call after portal health check updates portal statuses. + */ +export function rebuildFromPortals() { + const portals = _state?.portals ?? []; + if (portals.length > 0) _applyPortals(portals); +} diff --git a/modules/effects/lightning.js b/modules/effects/lightning.js new file mode 100644 index 0000000..e864548 --- /dev/null +++ b/modules/effects/lightning.js @@ -0,0 +1,196 @@ +/** + * lightning.js — Floating crystals and lightning arcs between them + * + * Category: DATA-TETHERED AESTHETIC + * Data source: state.zoneIntensity (commit activity drives arc count + intensity) + * + * Five octahedral crystals float above the platform. Lightning arcs jump + * between them when zone activity is high. Crystal count and colors are + * aligned to the five agent zones. + */ + +import * as THREE from 'three'; + +const CRYSTAL_COUNT = 5; +const CRYSTAL_BASE_POSITIONS = [ + new THREE.Vector3(-4.5, 3.2, -3.8), + new THREE.Vector3( 4.8, 2.8, -4.0), + new THREE.Vector3(-5.5, 4.0, 1.5), + new THREE.Vector3( 5.2, 3.5, 2.0), + new THREE.Vector3( 0.0, 5.0, -5.5), +]; +// Zone colors: Claude, Timmy, Kimi, Perplexity, center +const CRYSTAL_COLORS = [0xff6440, 0x40a0ff, 0x40ff8c, 0xc840ff, 0xffd700]; + +const LIGHTNING_POOL_SIZE = 6; +const LIGHTNING_SEGMENTS = 8; +const LIGHTNING_REFRESH_MS = 130; + +let _state = null; + +/** @type {THREE.Scene|null} */ +let _scene = null; + +/** @type {Array<{mesh: THREE.Mesh, light: THREE.PointLight, basePos: THREE.Vector3, floatPhase: number, flashStartTime: number}>} */ +const crystals = []; + +/** @type {THREE.Line[]} */ +const lightningArcs = []; + +/** @type {Array<{active: boolean, baseOpacity: number, srcIdx: number, dstIdx: number}>} */ +const lightningArcMeta = []; + +let _lastLightningRefreshTime = 0; + +function _totalActivity() { + if (!_state) return 0; + if (typeof _state.totalActivity === 'function') return _state.totalActivity(); + const zi = _state.zoneIntensity; + if (!zi) return 0; + const vals = Object.values(zi); + return vals.reduce((s, v) => s + v, 0) / Math.max(vals.length, 1); +} + +function _lerpColor(colorA, colorB, t) { + const ar = (colorA >> 16) & 0xff, ag = (colorA >> 8) & 0xff, ab = colorA & 0xff; + const br = (colorB >> 16) & 0xff, bg = (colorB >> 8) & 0xff, bb = colorB & 0xff; + return (Math.round(ar + (br - ar) * t) << 16) | + (Math.round(ag + (bg - ag) * t) << 8) | + Math.round(ab + (bb - ab) * t); +} + +function _buildLightningPath(start, end, jagAmount) { + const out = new Float32Array((LIGHTNING_SEGMENTS + 1) * 3); + for (let s = 0; s <= LIGHTNING_SEGMENTS; s++) { + const t = s / LIGHTNING_SEGMENTS; + const jag = s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0; + out[s * 3] = start.x + (end.x - start.x) * t + jag; + out[s * 3 + 1] = start.y + (end.y - start.y) * t + jag; + out[s * 3 + 2] = start.z + (end.z - start.z) * t + jag; + } + return out; +} + +/** + * @param {THREE.Scene} scene + * @param {object} state Shared state bus (reads state.zoneIntensity) + * @param {object} _theme + */ +export function init(scene, state, _theme) { + _scene = scene; + _state = state; + + const crystalGroup = new THREE.Group(); + scene.add(crystalGroup); + + for (let i = 0; i < CRYSTAL_COUNT; i++) { + const geo = new THREE.OctahedronGeometry(0.35, 0); + const color = CRYSTAL_COLORS[i]; + const mat = new THREE.MeshStandardMaterial({ + color, + emissive: new THREE.Color(color).multiplyScalar(0.6), + roughness: 0.05, + metalness: 0.3, + transparent: true, + opacity: 0.88, + }); + const mesh = new THREE.Mesh(geo, mat); + const basePos = CRYSTAL_BASE_POSITIONS[i].clone(); + mesh.position.copy(basePos); + mesh.userData.zoomLabel = 'Crystal'; + crystalGroup.add(mesh); + + const light = new THREE.PointLight(color, 0.3, 6); + light.position.copy(basePos); + crystalGroup.add(light); + + crystals.push({ mesh, light, basePos, floatPhase: (i / CRYSTAL_COUNT) * Math.PI * 2, flashStartTime: -999 }); + } + + // Pre-allocate lightning arc pool + for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) { + const positions = new Float32Array((LIGHTNING_SEGMENTS + 1) * 3); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const mat = new THREE.LineBasicMaterial({ + color: 0x88ccff, + transparent: true, + opacity: 0.0, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const arc = new THREE.Line(geo, mat); + scene.add(arc); + lightningArcs.push(arc); + lightningArcMeta.push({ active: false, baseOpacity: 0, srcIdx: 0, dstIdx: 0 }); + } +} + +function _refreshLightningArcs(elapsed) { + const activity = _totalActivity(); + const activeCount = Math.round(activity * LIGHTNING_POOL_SIZE); + + for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) { + const arc = lightningArcs[i]; + const meta = lightningArcMeta[i]; + if (i >= activeCount) { + arc.material.opacity = 0; + meta.active = false; + continue; + } + + const a = Math.floor(Math.random() * CRYSTAL_COUNT); + let b = Math.floor(Math.random() * (CRYSTAL_COUNT - 1)); + if (b >= a) b++; + + const jagAmount = 0.45 + activity * 0.85; + const path = _buildLightningPath(crystals[a].mesh.position, crystals[b].mesh.position, jagAmount); + const attr = arc.geometry.attributes.position; + attr.array.set(path); + attr.needsUpdate = true; + + arc.material.color.setHex(_lerpColor(CRYSTAL_COLORS[a], CRYSTAL_COLORS[b], 0.5)); + const base = (0.35 + Math.random() * 0.55) * Math.min(activity * 1.5, 1.0); + arc.material.opacity = base; + meta.active = true; + meta.baseOpacity = base; + meta.srcIdx = a; + meta.dstIdx = b; + + crystals[a].flashStartTime = elapsed; + crystals[b].flashStartTime = elapsed; + } +} + +export function update(elapsed, _delta) { + const activity = _totalActivity(); + + // Float crystals + for (let i = 0; i < crystals.length; i++) { + const c = crystals[i]; + c.mesh.position.y = c.basePos.y + Math.sin(elapsed * 0.7 + c.floatPhase) * 0.3; + c.light.position.y = c.mesh.position.y; + + // Brief emissive flash on lightning strike + const flashAge = elapsed - c.flashStartTime; + const flashIntensity = flashAge < 0.15 ? (1.0 - flashAge / 0.15) : 0; + c.mesh.material.emissiveIntensity = 0.6 + flashIntensity * 1.2; + c.light.intensity = 0.3 + flashIntensity * 1.5; + + // Color intensity tethered to total activity + c.mesh.material.opacity = 0.7 + activity * 0.18; + } + + // Flicker active arcs + for (let i = 0; i < lightningArcMeta.length; i++) { + const meta = lightningArcMeta[i]; + if (!meta.active) continue; + lightningArcs[i].material.opacity = meta.baseOpacity * (0.7 + Math.random() * 0.3); + } + + // Periodically rebuild arcs + if (elapsed * 1000 - _lastLightningRefreshTime > LIGHTNING_REFRESH_MS) { + _lastLightningRefreshTime = elapsed * 1000; + _refreshLightningArcs(elapsed); + } +} diff --git a/modules/effects/matrix-rain.js b/modules/effects/matrix-rain.js new file mode 100644 index 0000000..85cc7f0 --- /dev/null +++ b/modules/effects/matrix-rain.js @@ -0,0 +1,106 @@ +/** + * matrix-rain.js — Commit-density-driven 2D canvas matrix rain + * + * Category: DATA-TETHERED AESTHETIC + * Data source: state.zoneIntensity (commit activity) + state.commitHashes + * + * Renders a Katakana/hex character rain behind the Three.js canvas. + * Density and speed are tethered to commit zone activity. + * Real commit hashes are occasionally injected as characters. + */ + +const MATRIX_CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEF'; +const MATRIX_FONT_SIZE = 14; + +let _state = null; +let _canvas = null; +let _ctx = null; +let _drops = []; + +/** + * Computes mean activity [0..1] across all agent zones via state. + * @returns {number} + */ +function _totalActivity() { + if (!_state) return 0; + if (typeof _state.totalActivity === 'function') return _state.totalActivity(); + const zi = _state.zoneIntensity; + if (!zi) return 0; + const vals = Object.values(zi); + return vals.reduce((s, v) => s + v, 0) / Math.max(vals.length, 1); +} + +function _draw() { + if (!_canvas || !_ctx) return; + const activity = _totalActivity(); + const commitHashes = _state?.commitHashes ?? []; + + // Fade previous frame — creates the trailing glow + _ctx.fillStyle = 'rgba(0, 0, 8, 0.05)'; + _ctx.fillRect(0, 0, _canvas.width, _canvas.height); + + _ctx.font = `${MATRIX_FONT_SIZE}px monospace`; + + const density = 0.1 + activity * 0.9; + const activeColCount = Math.max(1, Math.floor(_drops.length * density)); + + for (let i = 0; i < _drops.length; i++) { + if (i >= activeColCount) { + if (_drops[i] * MATRIX_FONT_SIZE > _canvas.height) continue; + } + + let char; + if (commitHashes.length > 0 && Math.random() < 0.02) { + const hash = commitHashes[Math.floor(Math.random() * commitHashes.length)]; + char = hash[Math.floor(Math.random() * hash.length)]; + } else { + char = MATRIX_CHARS[Math.floor(Math.random() * MATRIX_CHARS.length)]; + } + + _ctx.fillStyle = '#aaffaa'; + _ctx.fillText(char, i * MATRIX_FONT_SIZE, _drops[i] * MATRIX_FONT_SIZE); + + const resetThreshold = 0.975 - activity * 0.015; + if (_drops[i] * MATRIX_FONT_SIZE > _canvas.height && Math.random() > resetThreshold) { + _drops[i] = 0; + } + _drops[i]++; + } +} + +function _resetDrops() { + const colCount = Math.floor(window.innerWidth / MATRIX_FONT_SIZE); + _drops = new Array(colCount).fill(1); +} + +/** + * @param {THREE.Scene} _scene (unused — 2D canvas effect) + * @param {object} state Shared state bus + * @param {object} _theme (unused — color is hardcoded green for matrix aesthetic) + */ +export function init(_scene, state, _theme) { + _state = state; + + _canvas = document.createElement('canvas'); + _canvas.id = 'matrix-rain'; + _canvas.width = window.innerWidth; + _canvas.height = window.innerHeight; + document.body.appendChild(_canvas); + + _ctx = _canvas.getContext('2d'); + _resetDrops(); + + window.addEventListener('resize', () => { + _canvas.width = window.innerWidth; + _canvas.height = window.innerHeight; + _resetDrops(); + }); + + // Run at ~20 fps independent of the Three.js RAF loop + setInterval(_draw, 50); +} + +/** + * update() is a no-op — rain runs on its own setInterval. + */ +export function update(_elapsed, _delta) {} diff --git a/modules/effects/rune-ring.js b/modules/effects/rune-ring.js new file mode 100644 index 0000000..e36c8e9 --- /dev/null +++ b/modules/effects/rune-ring.js @@ -0,0 +1,138 @@ +/** + * rune-ring.js — Orbiting Elder Futhark rune sprites + * + * Category: DATA-TETHERED AESTHETIC + * Data source: state.portals (count, colors, and online status from portals.json) + * + * Rune sprites orbit the scene in a ring. Count matches the portal count, + * colors come from portal colors, and brightness reflects portal online status. + * A faint torus marks the orbit track. + */ + +import * as THREE from 'three'; + +const RUNE_RING_RADIUS = 7.0; +const RUNE_RING_Y = 1.5; +const RUNE_ORBIT_SPEED = 0.08; // radians per second +const DEFAULT_RUNE_COUNT = 12; + +const ELDER_FUTHARK = ['ᚠ','ᚢ','ᚦ','ᚨ','ᚱ','ᚲ','ᚷ','ᚹ','ᚺ','ᚾ','ᛁ','ᛃ']; +const FALLBACK_COLORS = ['#00ffcc', '#ff44ff']; + +let _scene = null; +let _state = null; + +/** @type {Array<{sprite: THREE.Sprite, baseAngle: number, floatPhase: number, portalOnline: boolean}>} */ +const runeSprites = []; + +let _orbitRingMesh = null; +let _builtForPortalCount = -1; + +function _createRuneTexture(glyph, color) { + const W = 128, H = 128; + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, W, H); + ctx.shadowColor = color; + ctx.shadowBlur = 28; + ctx.font = 'bold 78px serif'; + ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(glyph, W / 2, H / 2); + return new THREE.CanvasTexture(canvas); +} + +function _clearSprites() { + for (const rune of runeSprites) { + _scene.remove(rune.sprite); + if (rune.sprite.material.map) rune.sprite.material.map.dispose(); + rune.sprite.material.dispose(); + } + runeSprites.length = 0; +} + +function _build(portals) { + _clearSprites(); + + const count = portals ? portals.length : DEFAULT_RUNE_COUNT; + _builtForPortalCount = count; + + for (let i = 0; i < count; i++) { + const glyph = ELDER_FUTHARK[i % ELDER_FUTHARK.length]; + const color = portals ? portals[i].color : FALLBACK_COLORS[i % FALLBACK_COLORS.length]; + const isOnline = portals ? portals[i].status === 'online' : true; + const texture = _createRuneTexture(glyph, color); + + const mat = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + opacity: isOnline ? 1.0 : 0.15, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + const sprite = new THREE.Sprite(mat); + sprite.scale.set(1.3, 1.3, 1); + + const baseAngle = (i / count) * Math.PI * 2; + sprite.position.set( + Math.cos(baseAngle) * RUNE_RING_RADIUS, + RUNE_RING_Y, + Math.sin(baseAngle) * RUNE_RING_RADIUS + ); + _scene.add(sprite); + runeSprites.push({ sprite, baseAngle, floatPhase: (i / count) * Math.PI * 2, portalOnline: isOnline }); + } +} + +/** + * @param {THREE.Scene} scene + * @param {object} state Shared state bus (reads state.portals) + * @param {object} _theme + */ +export function init(scene, state, _theme) { + _scene = scene; + _state = state; + + // Faint orbit track torus + const ringGeo = new THREE.TorusGeometry(RUNE_RING_RADIUS, 0.03, 6, 64); + const ringMat = new THREE.MeshBasicMaterial({ color: 0x224466, transparent: true, opacity: 0.22 }); + _orbitRingMesh = new THREE.Mesh(ringGeo, ringMat); + _orbitRingMesh.rotation.x = Math.PI / 2; + _orbitRingMesh.position.y = RUNE_RING_Y; + scene.add(_orbitRingMesh); + + // Initial build with defaults — will be rebuilt when portals load + _build(null); +} + +export function update(elapsed, _delta) { + // Rebuild rune sprites when portal data changes + const portals = _state?.portals ?? []; + if (portals.length > 0 && portals.length !== _builtForPortalCount) { + _build(portals); + } + + // Orbit and float + for (const rune of runeSprites) { + const angle = rune.baseAngle + elapsed * RUNE_ORBIT_SPEED; + rune.sprite.position.x = Math.cos(angle) * RUNE_RING_RADIUS; + rune.sprite.position.z = Math.sin(angle) * RUNE_RING_RADIUS; + rune.sprite.position.y = RUNE_RING_Y + Math.sin(elapsed * 0.7 + rune.floatPhase) * 0.4; + + const baseOpacity = rune.portalOnline ? 0.85 : 0.12; + const pulseRange = rune.portalOnline ? 0.15 : 0.03; + rune.sprite.material.opacity = baseOpacity + Math.sin(elapsed * 1.2 + rune.floatPhase) * pulseRange; + } +} + +/** + * Force a rebuild from current portal data. + * Called externally after portal health checks update statuses. + */ +export function rebuild() { + const portals = _state?.portals ?? []; + _build(portals.length > 0 ? portals : null); +} diff --git a/modules/effects/shockwave.js b/modules/effects/shockwave.js new file mode 100644 index 0000000..d2e3106 --- /dev/null +++ b/modules/effects/shockwave.js @@ -0,0 +1,183 @@ +/** + * 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; + } +}