/** * Memory Birth Animation System * * Gives newly placed memory crystals a "materialization" entrance: * - Scale from 0 → 1 with elastic ease * - Bloom flash on arrival (emissive spike) * - Nearby related memories pulse in response * - Connection lines draw in progressively * * Usage: * import { MemoryBirth } from './nexus/components/memory-birth.js'; * MemoryBirth.init(scene); * // After placing a crystal via SpatialMemory.placeMemory(): * MemoryBirth.triggerBirth(crystalMesh, spatialMemory); * // In your render loop: * MemoryBirth.update(delta); */ const MemoryBirth = (() => { // ─── CONFIG ──────────────────────────────────────── const BIRTH_DURATION = 1.8; // seconds for full materialization const BLOOM_PEAK = 0.3; // when the bloom flash peaks (fraction of duration) const BLOOM_INTENSITY = 4.0; // emissive spike at peak const NEIGHBOR_PULSE_RADIUS = 8; // units — memories in this range pulse const NEIGHBOR_PULSE_INTENSITY = 2.5; const NEIGHBOR_PULSE_DURATION = 0.8; const LINE_DRAW_DURATION = 1.2; // seconds for connection lines to grow in let _scene = null; let _activeBirths = []; // { mesh, startTime, duration, originPos } let _activePulses = []; // { mesh, startTime, duration, origEmissive, origIntensity } let _activeLineGrowths = []; // { line, startTime, duration, totalPoints } let _initialized = false; // ─── ELASTIC EASE-OUT ───────────────────────────── function elasticOut(t) { if (t <= 0) return 0; if (t >= 1) return 1; const c4 = (2 * Math.PI) / 3; return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1; } // ─── SMOOTH STEP ────────────────────────────────── function smoothstep(edge0, edge1, x) { const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))); return t * t * (3 - 2 * t); } // ─── INIT ───────────────────────────────────────── function init(scene) { _scene = scene; _initialized = true; console.info('[MemoryBirth] Initialized'); } // ─── TRIGGER BIRTH ──────────────────────────────── function triggerBirth(mesh, spatialMemory) { if (!_initialized || !mesh) return; // Start at zero scale mesh.scale.setScalar(0.001); // Store original material values for bloom if (mesh.material) { mesh.userData._birthOrigEmissive = mesh.material.emissiveIntensity; mesh.userData._birthOrigOpacity = mesh.material.opacity; } _activeBirths.push({ mesh, startTime: Date.now() / 1000, duration: BIRTH_DURATION, spatialMemory, originPos: mesh.position.clone() }); // Trigger neighbor pulses for memories in the same region _triggerNeighborPulses(mesh, spatialMemory); // Schedule connection line growth _triggerLineGrowth(mesh, spatialMemory); } // ─── NEIGHBOR PULSE ─────────────────────────────── function _triggerNeighborPulses(mesh, spatialMemory) { if (!spatialMemory || !mesh.position) return; const allMems = spatialMemory.getAllMemories ? spatialMemory.getAllMemories() : []; const pos = mesh.position; const sourceId = mesh.userData.memId; allMems.forEach(mem => { if (mem.id === sourceId) return; if (!mem.position) return; const dx = mem.position[0] - pos.x; const dy = (mem.position[1] + 1.5) - pos.y; const dz = mem.position[2] - pos.z; const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); if (dist < NEIGHBOR_PULSE_RADIUS) { // Find the mesh for this memory const neighborMesh = _findMeshById(mem.id, spatialMemory); if (neighborMesh && neighborMesh.material) { _activePulses.push({ mesh: neighborMesh, startTime: Date.now() / 1000, duration: NEIGHBOR_PULSE_DURATION, origEmissive: neighborMesh.material.emissiveIntensity, intensity: NEIGHBOR_PULSE_INTENSITY * (1 - dist / NEIGHBOR_PULSE_RADIUS) }); } } }); } function _findMeshById(memId, spatialMemory) { // Access the internal memory objects through crystal meshes const meshes = spatialMemory.getCrystalMeshes ? spatialMemory.getCrystalMeshes() : []; return meshes.find(m => m.userData && m.userData.memId === memId); } // ─── LINE GROWTH ────────────────────────────────── function _triggerLineGrowth(mesh, spatialMemory) { if (!_scene) return; // Find connection lines that originate from this memory // Connection lines are stored as children of the scene or in a group _scene.children.forEach(child => { if (child.isLine && child.userData) { // Check if this line connects to our new memory if (child.userData.fromId === mesh.userData.memId || child.userData.toId === mesh.userData.memId) { _activeLineGrowths.push({ line: child, startTime: Date.now() / 1000, duration: LINE_DRAW_DURATION }); } } }); } // ─── UPDATE (call every frame) ──────────────────── function update(delta) { const now = Date.now() / 1000; // ── Process births ── for (let i = _activeBirths.length - 1; i >= 0; i--) { const birth = _activeBirths[i]; const elapsed = now - birth.startTime; const t = Math.min(1, elapsed / birth.duration); if (t >= 1) { // Birth complete — ensure final state birth.mesh.scale.setScalar(1); if (birth.mesh.material) { birth.mesh.material.emissiveIntensity = birth.mesh.userData._birthOrigEmissive || 1.5; birth.mesh.material.opacity = birth.mesh.userData._birthOrigOpacity || 0.9; } _activeBirths.splice(i, 1); continue; } // Scale animation with elastic ease const scale = elasticOut(t); birth.mesh.scale.setScalar(Math.max(0.001, scale)); // Bloom flash — emissive intensity spikes at BLOOM_PEAK then fades if (birth.mesh.material) { const origEI = birth.mesh.userData._birthOrigEmissive || 1.5; const bloomT = smoothstep(0, BLOOM_PEAK, t) * (1 - smoothstep(BLOOM_PEAK, 1, t)); birth.mesh.material.emissiveIntensity = origEI + bloomT * BLOOM_INTENSITY; // Opacity fades in const origOp = birth.mesh.userData._birthOrigOpacity || 0.9; birth.mesh.material.opacity = origOp * smoothstep(0, 0.3, t); } // Gentle upward float during birth (crystals are placed 1.5 above ground) birth.mesh.position.y = birth.originPos.y + (1 - scale) * 0.5; } // ── Process neighbor pulses ── for (let i = _activePulses.length - 1; i >= 0; i--) { const pulse = _activePulses[i]; const elapsed = now - pulse.startTime; const t = Math.min(1, elapsed / pulse.duration); if (t >= 1) { // Restore original if (pulse.mesh.material) { pulse.mesh.material.emissiveIntensity = pulse.origEmissive; } _activePulses.splice(i, 1); continue; } // Pulse curve: quick rise, slow decay const pulseVal = Math.sin(t * Math.PI) * pulse.intensity; if (pulse.mesh.material) { pulse.mesh.material.emissiveIntensity = pulse.origEmissive + pulseVal; } } // ── Process line growths ── for (let i = _activeLineGrowths.length - 1; i >= 0; i--) { const lg = _activeLineGrowths[i]; const elapsed = now - lg.startTime; const t = Math.min(1, elapsed / lg.duration); if (t >= 1) { // Ensure full visibility if (lg.line.material) { lg.line.material.opacity = lg.line.material.userData?._origOpacity || 0.6; } _activeLineGrowths.splice(i, 1); continue; } // Fade in the line if (lg.line.material) { const origOp = lg.line.material.userData?._origOpacity || 0.6; lg.line.material.opacity = origOp * smoothstep(0, 1, t); } } } // ─── BIRTH COUNT (for UI/status) ───────────────── function getActiveBirthCount() { return _activeBirths.length; } // ─── WRAP SPATIAL MEMORY ────────────────────────── /** * Wraps SpatialMemory.placeMemory() so every new crystal * automatically gets a birth animation. * Returns a proxy object that intercepts placeMemory calls. */ function wrapSpatialMemory(spatialMemory) { const original = spatialMemory.placeMemory.bind(spatialMemory); spatialMemory.placeMemory = function(mem) { const crystal = original(mem); if (crystal) { // Small delay to let THREE.js settle the object requestAnimationFrame(() => triggerBirth(crystal, spatialMemory)); } return crystal; }; console.info('[MemoryBirth] SpatialMemory.placeMemory wrapped — births will animate'); return spatialMemory; } return { init, triggerBirth, update, getActiveBirthCount, wrapSpatialMemory }; })(); export { MemoryBirth };