feat: 3D terminal emulator in Batcave alcove (#269)
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:
367
app.js
367
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
|
||||
|
||||
@@ -48,6 +48,11 @@
|
||||
|
||||
<div id="sovereignty-msg">⚡ SOVEREIGNTY ⚡</div>
|
||||
|
||||
<div id="batcave-indicator">
|
||||
<span>BATCAVE TERMINAL</span>
|
||||
<span class="batcave-hint">[B] exit | [Esc] exit | type commands below</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
|
||||
37
style.css
37
style.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user