diff --git a/app.js b/app.js index 3817444f..43c06bce 100644 --- a/app.js +++ b/app.js @@ -7,6 +7,7 @@ import { SpatialMemory } from './nexus/components/spatial-memory.js'; import { MemoryBirth } from './nexus/components/memory-birth.js'; import { MemoryOptimizer } from './nexus/components/memory-optimizer.js'; import { MemoryInspect } from './nexus/components/memory-inspect.js'; +import { MemoryPulse } from './nexus/components/memory-pulse.js'; // ═══════════════════════════════════════════ // NEXUS v1.1 — Portal System Update @@ -715,6 +716,7 @@ async function init() { MemoryBirth.wrapSpatialMemory(SpatialMemory); SpatialMemory.setCamera(camera); MemoryInspect.init({ onNavigate: _navigateToMemory }); + MemoryPulse.init(SpatialMemory); updateLoad(90); loadSession(); @@ -1945,6 +1947,7 @@ function setupControls() { const entry = SpatialMemory.getMemoryFromMesh(hits[0].object); if (entry) { SpatialMemory.highlightMemory(entry.data.id); + MemoryPulse.triggerPulse(entry.data.id); const regionDef = SpatialMemory.REGIONS[entry.region] || SpatialMemory.REGIONS.working; MemoryInspect.show(entry.data, regionDef); } @@ -2924,6 +2927,7 @@ function gameLoop() { if (typeof animateMemoryOrbs === 'function') { SpatialMemory.update(delta); MemoryBirth.update(delta); + MemoryPulse.update(); animateMemoryOrbs(delta); } diff --git a/nexus/components/memory-pulse.js b/nexus/components/memory-pulse.js new file mode 100644 index 00000000..d66e9b39 --- /dev/null +++ b/nexus/components/memory-pulse.js @@ -0,0 +1,160 @@ +// ═══════════════════════════════════════════════════ +// PROJECT MNEMOSYNE — MEMORY PULSE +// ═══════════════════════════════════════════════════ +// +// BFS wave animation triggered on crystal click. +// When a memory crystal is clicked, a visual pulse +// radiates through the connection graph — illuminating +// linked memories hop-by-hop with a glow that rises +// sharply and then fades. +// +// Usage: +// MemoryPulse.init(SpatialMemory); +// MemoryPulse.triggerPulse(memId); +// MemoryPulse.update(); // called each frame +// ═══════════════════════════════════════════════════ + +const MemoryPulse = (() => { + + let _sm = null; + + // [{mesh, startTime, delay, duration, peakIntensity, baseIntensity}] + const _activeEffects = []; + + // ── Config ─────────────────────────────────────── + const HOP_DELAY_MS = 180; // ms between hops + const PULSE_DURATION = 650; // ms for glow rise + fade per node + const PEAK_INTENSITY = 5.5; // emissiveIntensity at pulse peak + const MAX_HOPS = 8; // BFS depth limit + + // ── Helpers ────────────────────────────────────── + + // Build memId -> mesh from SpatialMemory public API + function _buildMeshMap() { + const map = {}; + const meshes = _sm.getCrystalMeshes(); + for (const mesh of meshes) { + const entry = _sm.getMemoryFromMesh(mesh); + if (entry) map[entry.data.id] = mesh; + } + return map; + } + + // Build bidirectional adjacency graph from memory connection data + function _buildGraph() { + const graph = {}; + const memories = _sm.getAllMemories(); + for (const mem of memories) { + if (!graph[mem.id]) graph[mem.id] = []; + if (mem.connections) { + for (const targetId of mem.connections) { + graph[mem.id].push(targetId); + if (!graph[targetId]) graph[targetId] = []; + graph[targetId].push(mem.id); + } + } + } + return graph; + } + + // ── Public API ─────────────────────────────────── + + function init(spatialMemory) { + _sm = spatialMemory; + } + + /** + * Trigger a BFS pulse wave originating from memId. + * Each hop level illuminates after HOP_DELAY_MS * hop ms. + * @param {string} memId - ID of the clicked memory crystal + */ + function triggerPulse(memId) { + if (!_sm) return; + + const meshMap = _buildMeshMap(); + const graph = _buildGraph(); + + if (!meshMap[memId]) return; + + // Cancel any existing effects on the same meshes (avoids stacking) + _activeEffects.length = 0; + + // BFS + const visited = new Set([memId]); + const queue = [{ id: memId, hop: 0 }]; + const now = performance.now(); + const scheduled = []; + + while (queue.length > 0) { + const { id, hop } = queue.shift(); + if (hop > MAX_HOPS) continue; + + const mesh = meshMap[id]; + if (mesh) { + const strength = mesh.userData.strength || 0.7; + const baseIntensity = 1.0 + Math.sin(mesh.userData.pulse || 0) * 0.5 * strength; + + scheduled.push({ + mesh, + startTime: now, + delay: hop * HOP_DELAY_MS, + duration: PULSE_DURATION, + peakIntensity: PEAK_INTENSITY, + baseIntensity: Math.max(0.5, baseIntensity) + }); + } + + for (const neighborId of (graph[id] || [])) { + if (!visited.has(neighborId)) { + visited.add(neighborId); + queue.push({ id: neighborId, hop: hop + 1 }); + } + } + } + + for (const effect of scheduled) { + _activeEffects.push(effect); + } + + console.info('[MemoryPulse] Pulse triggered from', memId, '—', scheduled.length, 'nodes in wave'); + } + + /** + * Advance all active pulse animations. Call once per frame. + */ + function update() { + if (_activeEffects.length === 0) return; + + const now = performance.now(); + + for (let i = _activeEffects.length - 1; i >= 0; i--) { + const e = _activeEffects[i]; + const elapsed = now - e.startTime - e.delay; + + if (elapsed < 0) continue; // waiting for its hop delay + + if (elapsed >= e.duration) { + // Animation complete — restore base intensity + if (e.mesh.material) { + e.mesh.material.emissiveIntensity = e.baseIntensity; + } + _activeEffects.splice(i, 1); + continue; + } + + // t: 0 → 1 over duration + const t = elapsed / e.duration; + // sin curve over [0, π]: smooth rise then fall + const glow = Math.sin(t * Math.PI); + + if (e.mesh.material) { + e.mesh.material.emissiveIntensity = + e.baseIntensity + glow * (e.peakIntensity - e.baseIntensity); + } + } + } + + return { init, triggerPulse, update }; +})(); + +export { MemoryPulse };