diff --git a/app.js b/app.js index 9b0bf049..42cd37d8 100644 --- a/app.js +++ b/app.js @@ -2576,6 +2576,8 @@ function gameLoop() { // Project Mnemosyne - Memory Orb Animation if (typeof animateMemoryOrbs === 'function') { animateMemoryOrbs(delta); + animateHolographicThreads(delta); + animateRoomTransitions(delta); } @@ -3090,7 +3092,232 @@ function spawnRetrievalOrbs(results, center) { }); } +// ═══════════════════════════════════════════ +// MNEMOSYNE — SPATIAL MEMORY INTEGRATION +// ═══════════════════════════════════════════ + +// Spatial memory schema state (loaded async) +let spatialMemorySchema = null; +const holographicThreads = []; // Active thread meshes + +/** + * Load the spatial memory schema and store it for room mapping. + * Called during init. Falls back gracefully if schema unavailable. + */ +async function loadSpatialMemorySchema() { + try { + const resp = await fetch('/spatial-memory-schema.json'); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + spatialMemorySchema = await resp.json(); + console.info('[Mnemosyne] Spatial memory schema loaded:', + Object.keys(spatialMemorySchema.rooms).length, 'rooms'); + } catch (err) { + console.warn('[Mnemosyne] Could not load spatial schema, using defaults:', err.message); + spatialMemorySchema = null; + } +} + +/** + * Get the room definition for a memory category. + * @param {string} category - Memory category (user_pref, project, tool, general) + * @returns {object|null} Room definition with spatial_bounds and visual_theme + */ +function getRoomForCategory(category) { + if (!spatialMemorySchema) return null; + for (const [roomId, room] of Object.entries(spatialMemorySchema.rooms)) { + if (room.category === category) return { id: roomId, ...room }; + } + // Fallback to commons for unknown categories + if (spatialMemorySchema.rooms.commons) { + return { id: 'commons', ...spatialMemorySchema.rooms.commons }; + } + return null; +} + +/** + * Calculate a random position within a room's spatial bounds. + * @param {object} room - Room definition with spatial_bounds + * @returns {THREE.Vector3} Position within room bounds + */ +function getPositionInRoom(room) { + const bounds = room.spatial_bounds; + const dims = bounds.dimensions; + const center = bounds.center; + + return new THREE.Vector3( + center[0] + (Math.random() - 0.5) * dims[0] * 0.8, + center[1] + (Math.random() - 0.5) * dims[1] * 0.6 + 1.5, // Float above floor + center[2] + (Math.random() - 0.5) * dims[2] * 0.8 + ); +} + +/** + * Spawn a categorized memory orb placed in its corresponding room. + * Extends spawnMemoryOrb with spatial placement based on category. + * + * @param {string} category - Memory category (user_pref, project, tool, general) + * @param {object} metadata - Memory metadata (source, content, score, etc.) + * @param {number} importance - 0-1 importance score (affects size/glow) + * @returns {THREE.Mesh} The spawned orb + */ +function spawnCategorizedOrb(category, metadata = {}, importance = 0.5) { + const room = getRoomForCategory(category); + const position = room ? getPositionInRoom(room) : new THREE.Vector3(0, 2, 0); + + // Color from schema trust mapping or room theme + let color = 0x4af0c0; // Default cyan + if (room && room.visual_theme) { + const accent = room.visual_theme.colors?.accent; + if (accent) color = parseInt(accent.replace('#', ''), 16); + } + + // Size scales with importance + const size = 0.2 + importance * 0.5; + + const orb = spawnMemoryOrb(position, color, size, { + ...metadata, + category: category, + room: room ? room.id : 'unknown' + }); + + return orb; +} + +/** + * Draw a holographic thread connecting two memory orbs. + * @param {THREE.Mesh} orbA - First orb + * @param {THREE.Mesh} orbB - Second orb + * @param {number} color - Thread color (default: 0x4af0c0) + * @returns {THREE.Line} The thread mesh + */ +function drawHolographicThread(orbA, orbB, color = 0x4af0c0) { + if (typeof THREE === 'undefined' || !orbA || !orbB) return null; + + const points = [orbA.position.clone(), orbB.position.clone()]; + const geometry = new THREE.BufferGeometry().setFromPoints(points); + const material = new THREE.LineBasicMaterial({ + color: color, + transparent: true, + opacity: 0.4, + linewidth: 1 + }); + + const thread = new THREE.Line(geometry, material); + thread.userData = { + type: 'holographic_thread', + orbA: orbA, + orbB: orbB, + createdAt: Date.now() + }; + + scene.add(thread); + holographicThreads.push(thread); + return thread; +} + +/** + * Update holographic threads to follow their connected orbs. + * Called from the animation loop. + * @param {number} delta - Time since last frame + */ +function animateHolographicThreads(delta) { + for (let i = holographicThreads.length - 1; i >= 0; i--) { + const thread = holographicThreads[i]; + if (!thread || !thread.userData) continue; + + const { orbA, orbB } = thread.userData; + + // Remove thread if either orb is gone + if (!orbA || !orbA.parent || !orbB || !orbB.parent) { + scene.remove(thread); + thread.geometry.dispose(); + thread.material.dispose(); + holographicThreads.splice(i, 1); + continue; + } + + // Update line positions to follow orbs + const positions = thread.geometry.attributes.position; + positions.setXYZ(0, orbA.position.x, orbA.position.y, orbA.position.z); + positions.setXYZ(1, orbB.position.x, orbB.position.y, orbB.position.z); + positions.needsUpdate = true; + + // Pulse opacity + const age = (Date.now() - thread.userData.createdAt) / 1000; + thread.material.opacity = 0.3 + Math.sin(age * 2) * 0.1; + } +} + +/** + * Spawn a memory orb and animate it transitioning to its room. + * @param {string} category - Memory category + * @param {object} metadata - Memory metadata + * @param {number} importance - 0-1 importance score + * @param {THREE.Vector3} startPos - Starting position (default: above avatar) + * @returns {THREE.Mesh} The orb (already in transit) + */ +function spawnWithRoomTransition(category, metadata = {}, importance = 0.5, startPos = null) { + if (!startPos) startPos = new THREE.Vector3(0, 2, 0); + + const room = getRoomForCategory(category); + const endPos = room ? getPositionInRoom(room) : new THREE.Vector3(0, 2, 0); + + let color = 0x4af0c0; + if (room && room.visual_theme) { + const accent = room.visual_theme.colors?.accent; + if (accent) color = parseInt(accent.replace('#', ''), 16); + } + + const size = 0.2 + importance * 0.5; + + // Spawn at start position + const orb = spawnMemoryOrb(startPos, color, size, { + ...metadata, + category: category, + room: room ? room.id : 'unknown', + transitioning: true, + targetPos: endPos, + transitionStart: Date.now(), + transitionDuration: 2000 + Math.random() * 1000 + }); + + return orb; +} + +/** + * Animate room transitions for orbs that are in transit. + * @param {number} delta - Time since last frame + */ +function animateRoomTransitions(delta) { + for (const orb of memoryOrbs) { + if (!orb.userData?.transitioning || !orb.userData?.targetPos) continue; + + const elapsed = Date.now() - orb.userData.transitionStart; + const duration = orb.userData.transitionDuration; + const progress = Math.min(1, elapsed / duration); + + // Ease-out cubic + const eased = 1 - Math.pow(1 - progress, 3); + + orb.position.lerpVectors( + orb.position, // Current (already partially moved) + orb.userData.targetPos, + eased * 0.05 // Smooth interpolation factor per frame + ); + + if (progress >= 1) { + orb.position.copy(orb.userData.targetPos); + orb.userData.transitioning = false; + delete orb.userData.targetPos; + delete orb.userData.transitionStart; + delete orb.userData.transitionDuration; + } + } +} + + init().then(() => { + loadSpatialMemorySchema(); createAshStorm(); createPortalTunnel(); fetchGiteaData();