feat: 3D terminal emulator in Batcave alcove (#269)
Some checks failed
CI / validate (pull_request) Failing after 10s
CI / auto-merge (pull_request) Has been skipped

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 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-24 00:53:01 -04:00
parent 39e0eecb9e
commit debd7a26c4
3 changed files with 404 additions and 5 deletions

367
app.js
View File

@@ -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

View File

@@ -48,6 +48,11 @@
<div id="sovereignty-msg">⚡ SOVEREIGNTY ⚡</div>
<div id="batcave-indicator">
<span>BATCAVE TERMINAL</span>
<span class="batcave-hint">[B] exit &nbsp;|&nbsp; [Esc] exit &nbsp;|&nbsp; type commands below</span>
</div>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});

View File

@@ -184,6 +184,43 @@ body.photo-mode #overview-indicator {
100% { opacity: 0; transform: translate(-50%, -50%) scale(1); }
}
/* === BATCAVE TERMINAL INDICATOR === */
#batcave-indicator {
display: none;
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
color: #00ff44;
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
pointer-events: none;
z-index: 20;
border: 1px solid #00ff44;
padding: 4px 12px;
background: rgba(0, 13, 4, 0.8);
white-space: nowrap;
animation: batcave-pulse 1.5s ease-in-out infinite;
}
#batcave-indicator.visible {
display: block;
}
.batcave-hint {
margin-left: 12px;
color: #005522;
font-size: 10px;
letter-spacing: 0.1em;
}
@keyframes batcave-pulse {
0%, 100% { opacity: 0.75; box-shadow: 0 0 6px rgba(0,255,68,0.3); }
50% { opacity: 1; box-shadow: 0 0 12px rgba(0,255,68,0.6); }
}
/* === CRT / CYBERPUNK OVERLAY === */
.crt-overlay {
position: fixed;