Implements the memory_pulse feature from FEATURES.yaml. Visual pulse wave radiates through connection graph when a crystal is clicked, illuminating linked memories by BFS hop distance with expanding ring effects. Closes #1263
257 lines
9.0 KiB
JavaScript
257 lines
9.0 KiB
JavaScript
// ═══════════════════════════════════════════════════════════
|
|
// MNEMOSYNE — Memory Pulse
|
|
// ═══════════════════════════════════════════════════════════
|
|
//
|
|
// Visual pulse wave that radiates through the connection graph
|
|
// when a memory crystal is clicked. Illuminates linked memories
|
|
// by BFS hop distance — closer neighbors light up first.
|
|
//
|
|
// Usage from app.js:
|
|
// import { MemoryPulse } from './nexus/components/memory-pulse.js';
|
|
// MemoryPulse.init(scene);
|
|
// MemoryPulse.trigger(clickedMemId, SpatialMemory);
|
|
//
|
|
// Depends on: SpatialMemory (getAllMemories, getMemoryFromMesh)
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
const MemoryPulse = (() => {
|
|
let _scene = null;
|
|
let _activePulses = []; // track running animations for cleanup
|
|
|
|
const HOP_DELAY = 300; // ms between each BFS hop wave
|
|
const GLOW_DURATION = 800; // ms each crystal glows at peak
|
|
const FADE_DURATION = 600; // ms to fade back to normal
|
|
const PULSE_COLOR = 0x4af0c0; // cyan-green pulse glow
|
|
const PULSE_INTENSITY = 6.0; // peak emissive during pulse
|
|
const RING_DURATION = 1200; // ms for the expanding ring effect
|
|
|
|
// ─── INIT ────────────────────────────────────────────────
|
|
function init(scene) {
|
|
_scene = scene;
|
|
}
|
|
|
|
// ─── BFS TRAVERSAL ───────────────────────────────────────
|
|
// Returns array of arrays: [[hop-0 ids], [hop-1 ids], [hop-2 ids], ...]
|
|
function bfsHops(startId, allMemories) {
|
|
const memMap = {};
|
|
for (const m of allMemories) {
|
|
memMap[m.id] = m;
|
|
}
|
|
|
|
if (!memMap[startId]) return [];
|
|
|
|
const visited = new Set([startId]);
|
|
const hops = [];
|
|
let frontier = [startId];
|
|
|
|
while (frontier.length > 0) {
|
|
hops.push([...frontier]);
|
|
const next = [];
|
|
for (const id of frontier) {
|
|
const mem = memMap[id];
|
|
if (!mem || !mem.connections) continue;
|
|
for (const connId of mem.connections) {
|
|
if (!visited.has(connId)) {
|
|
visited.add(connId);
|
|
next.push(connId);
|
|
}
|
|
}
|
|
}
|
|
frontier = next;
|
|
}
|
|
|
|
return hops;
|
|
}
|
|
|
|
// ─── EXPANDING RING ──────────────────────────────────────
|
|
// Creates a flat ring geometry that expands outward from a position
|
|
function createExpandingRing(position, color) {
|
|
const ringGeo = new THREE.RingGeometry(0.1, 0.2, 32);
|
|
const ringMat = new THREE.MeshBasicMaterial({
|
|
color: color,
|
|
transparent: true,
|
|
opacity: 0.8,
|
|
side: THREE.DoubleSide,
|
|
depthWrite: false
|
|
});
|
|
const ring = new THREE.Mesh(ringGeo, ringMat);
|
|
ring.position.copy(position);
|
|
ring.position.y += 0.1; // slightly above crystal
|
|
ring.rotation.x = -Math.PI / 2; // flat horizontal
|
|
ring.scale.set(0.1, 0.1, 0.1);
|
|
_scene.add(ring);
|
|
return ring;
|
|
}
|
|
|
|
// ─── ANIMATE RING ────────────────────────────────────────
|
|
function animateRing(ring, onComplete) {
|
|
const startTime = performance.now();
|
|
function tick() {
|
|
const elapsed = performance.now() - startTime;
|
|
const t = Math.min(1, elapsed / RING_DURATION);
|
|
|
|
// Expand outward
|
|
const scale = 0.1 + t * 4.0;
|
|
ring.scale.set(scale, scale, scale);
|
|
|
|
// Fade out
|
|
ring.material.opacity = 0.8 * (1 - t * t);
|
|
|
|
if (t < 1) {
|
|
requestAnimationFrame(tick);
|
|
} else {
|
|
_scene.remove(ring);
|
|
ring.geometry.dispose();
|
|
ring.material.dispose();
|
|
if (onComplete) onComplete();
|
|
}
|
|
}
|
|
requestAnimationFrame(tick);
|
|
}
|
|
|
|
// ─── PULSE CRYSTAL GLOW ──────────────────────────────────
|
|
// Temporarily boosts a crystal's emissive intensity
|
|
function pulseGlow(mesh, hopIndex) {
|
|
if (!mesh || !mesh.material) return;
|
|
|
|
const originalIntensity = mesh.material.emissiveIntensity;
|
|
const originalColor = mesh.material.emissive ? mesh.material.emissive.clone() : null;
|
|
const delay = hopIndex * HOP_DELAY;
|
|
|
|
setTimeout(() => {
|
|
if (!mesh.material) return;
|
|
|
|
// Store original for restore
|
|
const origInt = mesh.material.emissiveIntensity;
|
|
|
|
// Flash to pulse color
|
|
if (mesh.material.emissive) {
|
|
mesh.material.emissive.setHex(PULSE_COLOR);
|
|
}
|
|
mesh.material.emissiveIntensity = PULSE_INTENSITY;
|
|
|
|
// Also boost point light if present
|
|
let origLightIntensity = null;
|
|
let origLightColor = null;
|
|
if (mesh.children) {
|
|
for (const child of mesh.children) {
|
|
if (child.isPointLight) {
|
|
origLightIntensity = child.intensity;
|
|
origLightColor = child.color.clone();
|
|
child.intensity = 3.0;
|
|
child.color.setHex(PULSE_COLOR);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hold at peak, then fade
|
|
setTimeout(() => {
|
|
const fadeStart = performance.now();
|
|
function fadeTick() {
|
|
const elapsed = performance.now() - fadeStart;
|
|
const t = Math.min(1, elapsed / FADE_DURATION);
|
|
const eased = 1 - (1 - t) * (1 - t); // ease-out quad
|
|
|
|
mesh.material.emissiveIntensity = PULSE_INTENSITY + (origInt - PULSE_INTENSITY) * eased;
|
|
|
|
if (originalColor) {
|
|
const pr = ((PULSE_COLOR >> 16) & 0xff) / 255;
|
|
const pg = ((PULSE_COLOR >> 8) & 0xff) / 255;
|
|
const pb = (PULSE_COLOR & 0xff) / 255;
|
|
mesh.material.emissive.setRGB(
|
|
pr + (originalColor.r - pr) * eased,
|
|
pg + (originalColor.g - pg) * eased,
|
|
pb + (originalColor.b - pb) * eased
|
|
);
|
|
}
|
|
|
|
// Restore point light
|
|
if (origLightIntensity !== null && mesh.children) {
|
|
for (const child of mesh.children) {
|
|
if (child.isPointLight) {
|
|
child.intensity = 3.0 + (origLightIntensity - 3.0) * eased;
|
|
if (origLightColor) {
|
|
const pr = ((PULSE_COLOR >> 16) & 0xff) / 255;
|
|
const pg = ((PULSE_COLOR >> 8) & 0xff) / 255;
|
|
const pb = (PULSE_COLOR & 0xff) / 255;
|
|
child.color.setRGB(
|
|
pr + (origLightColor.r - pr) * eased,
|
|
pg + (origLightColor.g - pg) * eased,
|
|
pb + (origLightColor.b - pb) * eased
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (t < 1) {
|
|
requestAnimationFrame(fadeTick);
|
|
}
|
|
}
|
|
requestAnimationFrame(fadeTick);
|
|
}, GLOW_DURATION);
|
|
}, delay);
|
|
}
|
|
|
|
// ─── TRIGGER ─────────────────────────────────────────────
|
|
// Main entry point: fire a pulse wave from the given memory ID
|
|
function trigger(memId, spatialMemory) {
|
|
if (!_scene) return;
|
|
|
|
const allMemories = spatialMemory.getAllMemories();
|
|
const hops = bfsHops(memId, allMemories);
|
|
|
|
if (hops.length <= 1) {
|
|
// No connections — just do a local ring
|
|
const obj = spatialMemory.getMemoryFromMesh(
|
|
spatialMemory.getCrystalMeshes().find(m => m.userData.memId === memId)
|
|
);
|
|
if (obj && obj.mesh) {
|
|
const ring = createExpandingRing(obj.mesh.position, PULSE_COLOR);
|
|
animateRing(ring);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// For each hop level, create expanding rings and pulse glows
|
|
for (let hopIdx = 0; hopIdx < hops.length; hopIdx++) {
|
|
const idsInHop = hops[hopIdx];
|
|
|
|
for (const id of idsInHop) {
|
|
// Find mesh for this memory
|
|
const meshes = spatialMemory.getCrystalMeshes();
|
|
let targetMesh = null;
|
|
for (const m of meshes) {
|
|
if (m.userData && m.userData.memId === id) {
|
|
targetMesh = m;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!targetMesh) continue;
|
|
|
|
// Schedule pulse glow
|
|
pulseGlow(targetMesh, hopIdx);
|
|
|
|
// Create expanding ring at this hop's delay
|
|
((mesh, delay) => {
|
|
setTimeout(() => {
|
|
const ring = createExpandingRing(mesh.position, PULSE_COLOR);
|
|
animateRing(ring);
|
|
}, delay * HOP_DELAY);
|
|
})(targetMesh, hopIdx);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── CLEANUP ─────────────────────────────────────────────
|
|
function dispose() {
|
|
// Active pulses will self-clean via their animation callbacks
|
|
_activePulses = [];
|
|
}
|
|
|
|
return { init, trigger, dispose, bfsHops };
|
|
})();
|
|
|
|
export { MemoryPulse };
|