From debd7a26c4db9a3f79cc3a5c3baf9dbc8af01fb3 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:53:01 -0400 Subject: [PATCH] feat: 3D terminal emulator in Batcave alcove (#269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a WebGL Batcave terminal — a cave-like alcove with a console desk, monitor frame, and canvas-texture terminal screen rendered in Three.js. - Press [B] to fly camera to Batcave and focus the terminal - Full keyboard capture with cursor blink and scanline CRT aesthetic - Built-in commands: help, clear, status, agents, whoami, uptime, date, ls, ping, exit/quit - Terminal input is isolated from scene key-bindings (Tab/P/sovereignty) while focused - Green phosphor glow light pulses from the screen into the cave Fixes #269 Co-Authored-By: Claude Sonnet 4.6 --- app.js | 367 ++++++++++++++++++++++++++++++++++++++++++++++++++++- index.html | 5 + style.css | 37 ++++++ 3 files changed, 404 insertions(+), 5 deletions(-) diff --git a/app.js b/app.js index b396094..c56e95c 100644 --- a/app.js +++ b/app.js @@ -397,6 +397,7 @@ const OVERVIEW_CAM = new THREE.Vector3(0, 200, 0.1); // overhead; tiny Z offset const overviewIndicator = document.getElementById('overview-indicator'); document.addEventListener('keydown', (e) => { + if (termFocused) return; if (e.key === 'Tab') { e.preventDefault(); overviewMode = !overviewMode; @@ -441,6 +442,7 @@ function updateFocusDisplay() { } document.addEventListener('keydown', (e) => { + if (termFocused) return; if (e.key === 'p' || e.key === 'P') { photoMode = !photoMode; document.body.classList.toggle('photo-mode', photoMode); @@ -656,6 +658,347 @@ for (let i = 0; i < RUNE_COUNT; i++) { runeSprites.push({ sprite, baseAngle, floatPhase: (i / RUNE_COUNT) * Math.PI * 2 }); } +// === BATCAVE TERMINAL === +// A 3D cave alcove housing a WebGL terminal emulator. Press [B] to focus. +// When focused, all keyboard input is routed to the terminal command processor. + +const batcaveGroup = new THREE.Group(); +batcaveGroup.position.set(-16, 0, 4); +scene.add(batcaveGroup); + +// Cave geometry — dark stone walls forming a U-shaped alcove +const caveStoneMat = new THREE.MeshStandardMaterial({ color: 0x050a10, roughness: 0.96, metalness: 0.04 }); +const consoleMat = new THREE.MeshStandardMaterial({ + color: 0x091420, metalness: 0.75, roughness: 0.22, + emissive: new THREE.Color(0x001a08), emissiveIntensity: 0.35, +}); + +// Back wall +const batcaveBackWall = new THREE.Mesh(new THREE.BoxGeometry(8, 7, 0.4), caveStoneMat); +batcaveBackWall.position.set(0, 3.5, 0); +batcaveGroup.add(batcaveBackWall); + +// Side walls +const batcaveLeftWall = new THREE.Mesh(new THREE.BoxGeometry(0.4, 7, 5), caveStoneMat); +batcaveLeftWall.position.set(-4, 3.5, 2.5); +batcaveGroup.add(batcaveLeftWall); + +const batcaveRightWall = new THREE.Mesh(new THREE.BoxGeometry(0.4, 7, 5), caveStoneMat); +batcaveRightWall.position.set(4, 3.5, 2.5); +batcaveGroup.add(batcaveRightWall); + +const batcaveCeiling = new THREE.Mesh(new THREE.BoxGeometry(8.4, 0.3, 5.4), caveStoneMat); +batcaveCeiling.position.set(0, 7, 2.5); +batcaveGroup.add(batcaveCeiling); + +const batcaveFloor = new THREE.Mesh(new THREE.BoxGeometry(8, 0.15, 5), caveStoneMat); +batcaveFloor.position.set(0, 0, 2.5); +batcaveGroup.add(batcaveFloor); + +// Console desk +const batcaveDesk = new THREE.Mesh(new THREE.BoxGeometry(5.5, 0.14, 1.5), consoleMat); +batcaveDesk.position.set(0, 1.2, 3.8); +batcaveGroup.add(batcaveDesk); + +[-2.6, 2.6].forEach(lx => { + const leg = new THREE.Mesh(new THREE.BoxGeometry(0.12, 1.2, 0.12), consoleMat); + leg.position.set(lx, 0.6, 4.5); + batcaveGroup.add(leg); +}); + +// Monitor bezel +const batcaveBezel = new THREE.Mesh(new THREE.BoxGeometry(4.6, 3.0, 0.14), consoleMat); +batcaveBezel.position.set(0, 3.5, 0.22); +batcaveGroup.add(batcaveBezel); + +// Bezel edge glow +const batcaveBezelEdges = new THREE.LineSegments( + new THREE.EdgesGeometry(new THREE.BoxGeometry(4.6, 3.0, 0.14)), + new THREE.LineBasicMaterial({ color: 0x00ff44, transparent: true, opacity: 0.3 }) +); +batcaveBezelEdges.position.set(0, 3.5, 0.22); +batcaveGroup.add(batcaveBezelEdges); + +// Monitor stand +const batcaveStand = new THREE.Mesh(new THREE.BoxGeometry(0.2, 1.8, 0.2), consoleMat); +batcaveStand.position.set(0, 1.9, 0.38); +batcaveGroup.add(batcaveStand); + +const batcaveBase = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.09, 0.6), consoleMat); +batcaveBase.position.set(0, 1.24, 0.55); +batcaveGroup.add(batcaveBase); + +// Terminal screen (canvas texture on a Plane) +const TERM_W = 800; +const TERM_H = 500; +const termCanvas = document.createElement('canvas'); +termCanvas.width = TERM_W; +termCanvas.height = TERM_H; +const termCtx = termCanvas.getContext('2d'); +const termTexture = new THREE.CanvasTexture(termCanvas); + +const termScreen = new THREE.Mesh( + new THREE.PlaneGeometry(4.1, 2.65), + new THREE.MeshBasicMaterial({ map: termTexture }) +); +termScreen.position.set(0, 3.5, 0.30); +batcaveGroup.add(termScreen); + +// Screen glow light +const termGlow = new THREE.PointLight(0x00ff44, 0.7, 10); +termGlow.position.set(0, 3.5, 1.5); +batcaveGroup.add(termGlow); + +// Dim emergency strip light on ceiling +const batcaveStripLight = new THREE.PointLight(0x331100, 0.3, 12); +batcaveStripLight.position.set(0, 6.5, 2.5); +batcaveGroup.add(batcaveStripLight); + +// ── Terminal state ────────────────────────────────────────────────────────── +const TERM_PROMPT = 'nexus@batcave:~$ '; +const TERM_LINE_H = 21; +const TERM_PAD_X = 12; +const TERM_PAD_TOP = 28; +const TERM_STATUS_H = 20; +const TERM_MAX_ROWS = Math.floor((TERM_H - TERM_PAD_TOP - TERM_PAD_X - TERM_LINE_H) / TERM_LINE_H); + +let termLines = [ + ' ╔══════════════════════════════════════════════════════════════╗', + ' ║ NEXUS BATCAVE :: Sovereign Workshop Terminal v1.0 ║', + ' ╚══════════════════════════════════════════════════════════════╝', + '', + ' Sovereign AI shell — Bitcoin-anchored, adversary-resistant.', + ' Type "help" for available commands.', + '', +]; +let termInput = ''; +let termFocused = false; +let termCursorOn = true; +let termCursorTimer = 0; +const TERM_CURSOR_RATE = 0.52; + +// Camera positions for Batcave mode +// Terminal world pos: batcaveGroup(-16,0,4) + screen local(0,3.5,0.30) = (-16, 3.5, 4.30) +const BATCAVE_CAM_POS = new THREE.Vector3(-16, 3.5, 10); +const BATCAVE_CAM_TARGET = new THREE.Vector3(-16, 3.5, 4.3); + +let batcaveFocused = false; + +/** + * Appends one or more lines to the terminal output buffer. + * @param {string} text - newline-delimited output + */ +function termPrint(text) { + for (const line of text.split('\n')) { + termLines.push(line); + } +} + +/** + * Processes a terminal command string. + * @param {string} rawCmd + */ +function termCommand(rawCmd) { + const cmd = rawCmd.trim(); + termLines.push(TERM_PROMPT + cmd); + const parts = cmd.toLowerCase().split(/\s+/); + const verb = parts[0]; + + if (!verb) return; + + switch (verb) { + case 'help': + termPrint( + '\n Available commands:\n' + + ' help — this message\n' + + ' clear — clear the screen\n' + + ' status — sovereignty meter reading\n' + + ' agents — active agent roster\n' + + ' whoami — current operator\n' + + ' uptime — session uptime\n' + + ' date — current UTC time\n' + + ' ls — list portals\n' + + ' ping — connectivity check\n' + + ' exit — leave Batcave\n' + ); + break; + + case 'clear': + termLines = []; + break; + + case 'status': + termPrint( + '\n SOVEREIGNTY SCORE : ' + sovereigntyScore + '%\n' + + ' STATUS LABEL : ' + sovereigntyLabel.toUpperCase() + '\n' + + ' NODE : Nexus-Prime\n' + + ' UPLINK : ACTIVE\n' + ); + break; + + case 'agents': + termPrint('\n ACTIVE AGENT ROSTER:'); + for (const agent of AGENT_STATUS_STUB.agents) { + const dot = agent.status === 'working' ? '●' : agent.status === 'idle' ? '○' : '✕'; + const issue = agent.issue ? ' — ' + agent.issue.slice(0, 28) : ''; + termPrint(' ' + dot + ' ' + agent.name.padEnd(14) + '[' + agent.status.toUpperCase() + ']' + issue); + } + termPrint(''); + break; + + case 'whoami': + termPrint('\n timmy\n Sovereign AI. Soul on Bitcoin. Building from the harness.\n'); + break; + + case 'uptime': { + const sec = Math.floor(performance.now() / 1000); + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + termPrint('\n uptime: ' + h + 'h ' + m + 'm ' + s + 's\n'); + break; + } + + case 'date': + termPrint('\n ' + new Date().toUTCString() + '\n'); + break; + + case 'ls': + termPrint( + '\n portals/\n' + + ' bitcoin-core.portal\n' + + ' nostr-relay.portal\n' + + ' kimi-workshop.portal\n' + + ' batcave-tools.portal\n' + ); + break; + + case 'ping': + termPrint('\n PING nexus-prime ... PONG (sovereign, 0ms latency)\n'); + break; + + case 'exit': + case 'quit': + termPrint('\n Leaving Batcave...\n'); + termFocused = false; + batcaveFocused = false; + document.getElementById('batcave-indicator')?.classList.remove('visible'); + break; + + default: + termPrint('\n command not found: ' + verb + '\n Try "help"\n'); + } +} + +/** + * Redraws the terminal canvas from current state. + */ +function drawTerminal() { + const ctx = termCtx; + + // Background + ctx.fillStyle = '#000d04'; + ctx.fillRect(0, 0, TERM_W, TERM_H); + + // Subtle scanlines + ctx.fillStyle = 'rgba(0,0,0,0.10)'; + for (let y = 0; y < TERM_H; y += 2) ctx.fillRect(0, y, TERM_W, 1); + + // Status bar + ctx.fillStyle = '#002a0e'; + ctx.fillRect(0, 0, TERM_W, TERM_STATUS_H); + ctx.font = 'bold 11px "Courier New", monospace'; + ctx.fillStyle = '#00ff44'; + ctx.textAlign = 'left'; + ctx.fillText(' NEXUS-BATCAVE | SECURE SHELL', 4, 14); + ctx.textAlign = 'right'; + ctx.fillStyle = termFocused ? '#00ff44' : '#005522'; + ctx.fillText(termFocused ? '● ACTIVE ' : '○ STANDBY ', TERM_W - 4, 14); + ctx.textAlign = 'left'; + + // Border + ctx.strokeStyle = termFocused ? '#00ff44' : '#003314'; + ctx.lineWidth = termFocused ? 2 : 1; + ctx.strokeRect(1, 1, TERM_W - 2, TERM_H - 2); + + // Output lines + const displayLines = termLines.slice(-TERM_MAX_ROWS); + ctx.font = '13px "Courier New", monospace'; + for (let i = 0; i < displayLines.length; i++) { + const line = displayLines[i]; + const y = TERM_PAD_TOP + TERM_PAD_X + i * TERM_LINE_H + TERM_LINE_H * 0.78; + if (line.startsWith(TERM_PROMPT)) { + ctx.fillStyle = '#00ff44'; + } else if (line.startsWith(' ●') || line.startsWith(' ○') || line.startsWith(' ✕')) { + ctx.fillStyle = '#44ffaa'; + } else if (line.startsWith(' ╔') || line.startsWith(' ║') || line.startsWith(' ╚')) { + ctx.fillStyle = '#00cc33'; + } else { + ctx.fillStyle = '#66ffaa'; + } + ctx.fillText(line, TERM_PAD_X, y); + } + + // Input prompt line + const inputY = TERM_PAD_TOP + TERM_PAD_X + TERM_MAX_ROWS * TERM_LINE_H + TERM_LINE_H * 0.78; + const inputFull = TERM_PROMPT + termInput; + ctx.font = '13px "Courier New", monospace'; + ctx.fillStyle = '#00ff44'; + ctx.fillText(inputFull, TERM_PAD_X, inputY); + + // Cursor block + if (termCursorOn) { + const cx = TERM_PAD_X + ctx.measureText(inputFull).width; + ctx.fillStyle = '#00ff44'; + ctx.fillRect(cx, inputY - TERM_LINE_H * 0.78 + 2, 8, TERM_LINE_H - 4); + } + + termTexture.needsUpdate = true; +} + +// Initial render +drawTerminal(); + +// ── Terminal keyboard input ───────────────────────────────────────────────── +document.addEventListener('keydown', (e) => { + if (!termFocused) return; + e.preventDefault(); + e.stopImmediatePropagation(); + + if (e.key === 'Enter') { + termCommand(termInput); + termInput = ''; + termCursorOn = true; + drawTerminal(); + } else if (e.key === 'Backspace') { + termInput = termInput.slice(0, -1); + drawTerminal(); + } else if (e.key === 'Escape') { + termFocused = false; + batcaveFocused = false; + document.getElementById('batcave-indicator')?.classList.remove('visible'); + drawTerminal(); + } else if (e.key.length === 1) { + termInput += e.key; + drawTerminal(); + } +}, true); // capture phase so it runs before bubble-phase handlers + +// ── [B] key toggles Batcave focus ────────────────────────────────────────── +document.addEventListener('keydown', (e) => { + if (termFocused) return; + if (e.key !== 'b' && e.key !== 'B') return; + batcaveFocused = !batcaveFocused; + termFocused = batcaveFocused; + const ind = document.getElementById('batcave-indicator'); + if (batcaveFocused) { + ind?.classList.add('visible'); + } else { + ind?.classList.remove('visible'); + } + drawTerminal(); +}); + // === ANIMATION LOOP === const clock = new THREE.Clock(); @@ -668,11 +1011,16 @@ function animate() { requestAnimationFrame(animate); const elapsed = clock.getElapsedTime(); - // Smooth camera transition for overview mode - const targetT = overviewMode ? 1 : 0; - overviewT += (targetT - overviewT) * 0.04; - camera.position.lerpVectors(NORMAL_CAM, OVERVIEW_CAM, overviewT); - camera.lookAt(0, 0, 0); + // Smooth camera transition — Batcave takes priority over overview + if (batcaveFocused) { + camera.position.lerp(BATCAVE_CAM_POS, 0.05); + camera.lookAt(BATCAVE_CAM_TARGET); + } else { + const targetT = overviewMode ? 1 : 0; + overviewT += (targetT - overviewT) * 0.04; + camera.position.lerpVectors(NORMAL_CAM, OVERVIEW_CAM, overviewT); + camera.lookAt(0, 0, 0); + } // Slow auto-rotation — suppressed during overview and photo mode const rotationScale = photoMode ? 0 : (1 - overviewT); @@ -770,6 +1118,14 @@ function animate() { rune.sprite.material.opacity = 0.65 + Math.sin(elapsed * 1.2 + rune.floatPhase) * 0.2; } + // Batcave terminal — cursor blink and screen glow + termGlow.intensity = 0.5 + Math.sin(elapsed * 1.8) * 0.2; + if (termFocused && elapsed - termCursorTimer > TERM_CURSOR_RATE) { + termCursorTimer = elapsed; + termCursorOn = !termCursorOn; + drawTerminal(); + } + composer.render(); } @@ -900,6 +1256,7 @@ function triggerSovereigntyEasterEgg() { // Detect 'sovereignty' typed anywhere on the page (cheat-code style) document.addEventListener('keydown', (e) => { + if (termFocused) return; if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.key.length !== 1) { // Non-printable key resets buffer diff --git a/index.html b/index.html index 69d6b65..65d2f02 100644 --- a/index.html +++ b/index.html @@ -48,6 +48,11 @@
⚡ SOVEREIGNTY ⚡
+
+ BATCAVE TERMINAL + [B] exit  |  [Esc] exit  |  type commands below +
+