From 3838819d1a6c2903008a7e6c0aee5f54b31526de Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:09:57 -0400 Subject: [PATCH] feat: add memory wall holo panel with conversation highlights (#127) Creates a 3D holographic panel (Three.js PlaneGeometry + CanvasTexture) positioned in the Nexus scene showing the 5 most recent chat messages received via WebSocket. Panel floats with a subtle sine animation and pulses opacity. Toggle visibility with [M] key. - buildMemoryWall(): creates canvas-textured plane at (-5, 0, -2) - renderMemoryWall(): draws header, timestamps, authors, message text - addMemoryEntry(): appends to ring buffer (max 5), triggers re-render - Listens for 'chat-message' CustomEvents from wsClient - Seeded with placeholder highlights until live data arrives - HUD hint [M] memory wall shown bottom-right Fixes #127 Co-Authored-By: Claude Sonnet 4.6 --- app.js | 178 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 2 + style.css | 18 ++++++ 3 files changed, 198 insertions(+) diff --git a/app.js b/app.js index 4902f2f..6dbc99d 100644 --- a/app.js +++ b/app.js @@ -250,6 +250,12 @@ function animate() { // Subtle pulse on constellation opacity constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06; + // Memory wall subtle float animation + if (MEMORY_WALL.mesh && MEMORY_WALL.visible) { + MEMORY_WALL.mesh.position.y = Math.sin(elapsed * 0.4) * 0.08; + MEMORY_WALL.mesh.material.opacity = 0.80 + Math.sin(elapsed * 0.7) * 0.08; + } + if (photoMode) { orbitControls.update(); composer.render(); @@ -260,6 +266,178 @@ function animate() { animate(); +// === MEMORY WALL === +// Holographic panel showing recent conversation highlights +const MEMORY_WALL = { + maxEntries: 5, + entries: [], + visible: true, + mesh: null, + canvas: null, + texture: null, +}; + +/** + * Creates the canvas texture for the memory wall panel. + * @returns {HTMLCanvasElement} + */ +function createMemoryCanvas() { + const canvas = document.createElement('canvas'); + canvas.width = 512; + canvas.height = 384; + return canvas; +} + +/** + * Renders current memory entries onto the canvas texture. + */ +function renderMemoryWall() { + const canvas = MEMORY_WALL.canvas; + const ctx = canvas.getContext('2d'); + const w = canvas.width; + const h = canvas.height; + + // Background + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = 'rgba(0, 0, 20, 0.85)'; + ctx.fillRect(0, 0, w, h); + + // Border + ctx.strokeStyle = 'rgba(68, 136, 255, 0.8)'; + ctx.lineWidth = 2; + ctx.strokeRect(4, 4, w - 8, h - 8); + + // Inner border glow + ctx.strokeStyle = 'rgba(68, 136, 255, 0.25)'; + ctx.lineWidth = 1; + ctx.strokeRect(8, 8, w - 16, h - 16); + + // Header + ctx.fillStyle = 'rgba(68, 136, 255, 0.15)'; + ctx.fillRect(4, 4, w - 8, 40); + + ctx.font = 'bold 13px "Courier New", monospace'; + ctx.fillStyle = '#4488ff'; + ctx.letterSpacing = '0.2em'; + ctx.fillText('// MEMORY WALL', 18, 27); + + ctx.font = '10px "Courier New", monospace'; + ctx.fillStyle = 'rgba(68, 136, 255, 0.5)'; + ctx.fillText(`${MEMORY_WALL.entries.length} HIGHLIGHTS`, w - 110, 27); + + // Divider line + ctx.strokeStyle = 'rgba(68, 136, 255, 0.4)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(18, 48); + ctx.lineTo(w - 18, 48); + ctx.stroke(); + + // Entries + if (MEMORY_WALL.entries.length === 0) { + ctx.font = '11px "Courier New", monospace'; + ctx.fillStyle = 'rgba(68, 136, 255, 0.3)'; + ctx.fillText('awaiting conversation...', 18, 80); + } else { + const entryHeight = (h - 60) / MEMORY_WALL.maxEntries; + MEMORY_WALL.entries.forEach((entry, idx) => { + const y = 58 + idx * entryHeight; + const age = MEMORY_WALL.entries.length - idx; // 1 = newest + const alpha = 0.4 + (0.6 * (MEMORY_WALL.entries.length - age) / MEMORY_WALL.entries.length); + + // Timestamp + ctx.font = '9px "Courier New", monospace'; + ctx.fillStyle = `rgba(68, 136, 255, ${alpha * 0.6})`; + ctx.fillText(entry.time, 18, y + 12); + + // Author + ctx.font = 'bold 10px "Courier New", monospace'; + ctx.fillStyle = `rgba(100, 180, 255, ${alpha})`; + ctx.fillText(entry.author + ':', 18, y + 26); + + // Message (truncated to fit) + ctx.font = '10px "Courier New", monospace'; + ctx.fillStyle = `rgba(204, 214, 246, ${alpha})`; + const maxChars = 54; + const msg = entry.text.length > maxChars ? entry.text.slice(0, maxChars) + '…' : entry.text; + ctx.fillText(msg, 18, y + 40); + + // Separator + if (idx < MEMORY_WALL.entries.length - 1) { + ctx.strokeStyle = `rgba(68, 136, 255, ${alpha * 0.2})`; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(18, y + entryHeight - 2); + ctx.lineTo(w - 18, y + entryHeight - 2); + ctx.stroke(); + } + }); + } + + MEMORY_WALL.texture.needsUpdate = true; +} + +/** + * Adds a new entry to the memory wall. + * @param {string} author + * @param {string} text + */ +function addMemoryEntry(author, text) { + const now = new Date(); + const time = now.toTimeString().slice(0, 5); + MEMORY_WALL.entries.push({ author, text, time }); + if (MEMORY_WALL.entries.length > MEMORY_WALL.maxEntries) { + MEMORY_WALL.entries.shift(); + } + renderMemoryWall(); +} + +/** + * Builds and adds the memory wall 3D mesh to the scene. + */ +function buildMemoryWall() { + MEMORY_WALL.canvas = createMemoryCanvas(); + MEMORY_WALL.texture = new THREE.CanvasTexture(MEMORY_WALL.canvas); + + const geo = new THREE.PlaneGeometry(5.12, 3.84); + const mat = new THREE.MeshBasicMaterial({ + map: MEMORY_WALL.texture, + transparent: true, + opacity: 0.92, + side: THREE.DoubleSide, + depthWrite: false, + }); + + MEMORY_WALL.mesh = new THREE.Mesh(geo, mat); + MEMORY_WALL.mesh.position.set(-5, 0, -2); + MEMORY_WALL.mesh.rotation.y = Math.PI / 8; + scene.add(MEMORY_WALL.mesh); + + renderMemoryWall(); +} + +buildMemoryWall(); + +// Toggle memory wall with 'M' +document.addEventListener('keydown', (e) => { + if (e.key === 'm' || e.key === 'M') { + MEMORY_WALL.visible = !MEMORY_WALL.visible; + MEMORY_WALL.mesh.visible = MEMORY_WALL.visible; + } +}); + +// Subscribe to chat messages from the WebSocket +window.addEventListener('chat-message', (/** @type {CustomEvent} */ event) => { + const { username, message } = event.detail || {}; + if (username && message) { + addMemoryEntry(username, message); + } +}); + +// Seed with placeholder highlights until live data arrives +addMemoryEntry('Timmy', 'Nexus is coming online. Portals loading...'); +addMemoryEntry('system', 'Star field initialized. 800 nodes active.'); + // === DEBUG MODE === let debugMode = false; diff --git a/index.html b/index.html index 6d4000f..0072266 100644 --- a/index.html +++ b/index.html @@ -41,6 +41,8 @@ [Tab] to exit +
+
PHOTO MODE [P] exit  |  [[] focus-   []] focus+   focus: 5.0 diff --git a/style.css b/style.css index 92029bb..8bab5b6 100644 --- a/style.css +++ b/style.css @@ -150,3 +150,21 @@ body.photo-mode #overview-indicator { #photo-focus { color: var(--color-primary); } + +/* === MEMORY WALL HUD HINT === */ +#memory-wall-hint { + position: fixed; + bottom: 16px; + right: 16px; + font-family: var(--font-body); + font-size: 10px; + letter-spacing: 0.15em; + color: var(--color-text-muted); + pointer-events: none; + z-index: 10; + text-transform: uppercase; +} + +#memory-wall-hint::before { + content: '[M] memory wall'; +} -- 2.43.0