- Elastic scale-in from 0 to full size - Bloom flash at materialization peak - Neighbor pulse: nearby memories brighten on birth - Connection line progressive draw-in - Auto-wraps SpatialMemory.placeMemory() for zero-config use
264 lines
9.5 KiB
JavaScript
264 lines
9.5 KiB
JavaScript
/**
|
|
* 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 };
|