From e29b6ff0a82496ed2636a67f15d41652ef5a717c Mon Sep 17 00:00:00 2001 From: Perplexity Computer Date: Tue, 24 Mar 2026 16:41:13 +0000 Subject: [PATCH] feat: add dual-brain holographic panel with brain pulse visualization (#407) Co-authored-by: Perplexity Computer Co-committed-by: Perplexity Computer --- app.js | 410 +++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 342 insertions(+), 68 deletions(-) diff --git a/app.js b/app.js index 3ad6292..8fc0a80 100644 --- a/app.js +++ b/app.js @@ -32,11 +32,18 @@ loadingManager.onProgress = (url, itemsLoaded, itemsTotal) => { document.getElementById('loading-bar').style.width = `${progress}%`; }; -// Simulate loading a texture for demonstration -const textureLoader = new THREE.TextureLoader(loadingManager); -textureLoader.load('placeholder-texture.jpg', (texture) => { - loadedAssets.set('placeholder-texture', texture); -}); +// Procedural placeholder texture — avoids 404 for missing placeholder-texture.jpg +const _placeholderCanvas = document.createElement('canvas'); +_placeholderCanvas.width = 64; +_placeholderCanvas.height = 64; +const _placeholderCtx = _placeholderCanvas.getContext('2d'); +_placeholderCtx.fillStyle = '#0a0a18'; +_placeholderCtx.fillRect(0, 0, 64, 64); +const placeholderTexture = new THREE.CanvasTexture(_placeholderCanvas); +loadedAssets.set('placeholder-texture', placeholderTexture); +// Notify loading manager so it still counts one asset +loadingManager.itemStart('placeholder-texture'); +loadingManager.itemEnd('placeholder-texture'); // === MATRIX RAIN === // 2D canvas layer rendered behind the Three.js scene. @@ -1219,10 +1226,6 @@ function animateEnergyBeam() { energyBeamMaterial.opacity = 0.3 + pulseEffect * 0.4; } - // Update energy beam pulse - beamPulse += 0.02; - energyBeam.material.opacity = 0.6 + Math.sin(beamPulse) * 0.2; - // === RESIZE HANDLER === window.addEventListener('resize', () => { @@ -1234,65 +1237,6 @@ window.addEventListener('resize', () => { // === SOVEREIGNTY METER === -// === BATCAVE TERMINAL ENERGY BEAM === -// Vertical energy beam from Batcave terminal area to the sky with animated opacity for a subtle pulse effect. -const beamGeometry = new THREE.CylinderGeometry(0.2, 0.5, 50, 32); -const beamMaterial = new THREE.MeshBasicMaterial({ - color: NEXUS.colors.energy, - emissive: NEXUS.colors.energy, - emissiveIntensity: 0.8, - transparent: true, - opacity: 0.6 -}); -const energyBeam = new THREE.Mesh(beamGeometry, beamMaterial); -energyBeam.position.set(-10, 25, -10); // Positioned at Batcave terminal area -energyBeam.rotation.x = Math.PI / 2; -NEXUS.scene.add(energyBeam); - -// Animate beam opacity for subtle pulse effect -function animateBeam() { - beamMaterial.opacity = 0.6 + 0.2 * Math.sin(Date.now() * 0.002); - requestAnimationFrame(animateBeam); -} -animateBeam(); - -// === BATCAVE TERMINAL ENERGY BEAM === -// Vertical energy beam representing connection between Timmy and the outside world -const beamGeometry = new THREE.CylinderGeometry(0.2, 0.5, 100, 32); -const beamMaterial = new THREE.MeshBasicMaterial({ - color: NEXUS.colors.accent, - emissive: NEXUS.colors.accent, - emissiveIntensity: 0.8, - transparent: true, - opacity: 0.6, - blending: THREE.AdditiveBlending, - side: THREE.DoubleSide, - depthWrite: false -}); -const energyBeam = new THREE.Mesh(beamGeometry, beamMaterial); -energyBeam.position.set(10, 50, 10); // Positioned at Batcave terminal area -scene.add(energyBeam); - -// Energy beam from Batcave terminal to sky, representing Timmy's connection -const beamGeometry2 = new THREE.CylinderGeometry(0.5, 2, 1000, 32); -const beamMaterial2 = new THREE.MeshBasicMaterial({ - color: NEXUS.colors.accent, - emissive: NEXUS.colors.accent, - emissiveIntensity: 0.8, - transparent: true, - opacity: 0.6 -}); -const energyBeam2 = new THREE.Mesh(beamGeometry2, beamMaterial2); -energyBeam2.position.set(-40, 500, -40); // Centered above Batcave terminal area -energyBeam2.rotation.x = Math.PI / 2; -scene.add(energyBeam2); -NEXUS.animations = NEXUS.animations || []; -NEXUS.animations.push(() => { - beamMaterial2.opacity = 0.6 + 0.2 * Math.sin(Date.now() * 0.002); -}); - -// Animation variable for beam pulse effect -let beamPulse = 0; // Holographic arc gauge floating above the platform; reads from sovereignty-status.json const sovereigntyGroup = new THREE.Group(); sovereigntyGroup.position.set(0, 3.8, 0); @@ -2119,6 +2063,266 @@ batcaveGroup.traverse(obj => { // Probe state — timestamp of last reflection capture (seconds) let batcaveProbeLastUpdate = -999; +// === DUAL-BRAIN HOLOGRAPHIC PANEL === +// Floating panel showing Brain Gap Scorecard with two glowing brain orbs +// connected by an animated particle stream representing knowledge transfer. + +const DUAL_BRAIN_ORIGIN = new THREE.Vector3(10, 3, -8); +const dualBrainGroup = new THREE.Group(); +dualBrainGroup.position.copy(DUAL_BRAIN_ORIGIN); +// Face toward the centre platform +dualBrainGroup.lookAt(0, 3, 0); +scene.add(dualBrainGroup); + +// --- Canvas texture for the scorecard panel --- +function createDualBrainTexture() { + const W = 512, H = 512; + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d'); + + // Dark background + ctx.fillStyle = 'rgba(0, 6, 20, 0.90)'; + ctx.fillRect(0, 0, W, H); + + // Outer neon border + ctx.strokeStyle = '#4488ff'; + ctx.lineWidth = 2; + ctx.strokeRect(1, 1, W - 2, H - 2); + + // Inner subtle border + ctx.strokeStyle = '#223366'; + ctx.lineWidth = 1; + ctx.strokeRect(5, 5, W - 10, H - 10); + + // Title + ctx.font = 'bold 22px "Courier New", monospace'; + ctx.fillStyle = '#88ccff'; + ctx.textAlign = 'center'; + ctx.fillText('\u25C8 DUAL-BRAIN STATUS', W / 2, 40); + + // Separator under title + ctx.strokeStyle = '#1a3a6a'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(20, 52); + ctx.lineTo(W - 20, 52); + ctx.stroke(); + + // Section header + ctx.font = '11px "Courier New", monospace'; + ctx.fillStyle = '#556688'; + ctx.textAlign = 'left'; + ctx.fillText('BRAIN GAP SCORECARD', 20, 74); + + // Categories + const categories = [ + { name: 'Triage', score: 0.87, status: 'GRADUATED', color: '#00ff88' }, + { name: 'Tool Use', score: 0.78, status: 'PROBATION', color: '#ffcc00' }, + { name: 'Code Gen', score: 0.62, status: 'SHADOW', color: '#4488ff' }, + { name: 'Planning', score: 0.71, status: 'SHADOW', color: '#4488ff' }, + { name: 'Communication', score: 0.83, status: 'PROBATION', color: '#ffcc00' }, + { name: 'Reasoning', score: 0.55, status: 'CLOUD ONLY', color: '#ff4444' }, + ]; + + const barX = 20; + const barW = W - 130; + const barH = 20; + let y = 90; + + for (const cat of categories) { + // Category label + ctx.font = '13px "Courier New", monospace'; + ctx.fillStyle = '#ccd6f6'; + ctx.textAlign = 'left'; + ctx.fillText(cat.name, barX, y + 14); + + // Score value + ctx.font = 'bold 13px "Courier New", monospace'; + ctx.fillStyle = cat.color; + ctx.textAlign = 'right'; + ctx.fillText(cat.score.toFixed(2), W - 20, y + 14); + + y += 22; + + // Bar background + ctx.fillStyle = 'rgba(255, 255, 255, 0.06)'; + ctx.fillRect(barX, y, barW, barH); + + // Bar fill + ctx.fillStyle = cat.color; + ctx.globalAlpha = 0.7; + ctx.fillRect(barX, y, barW * cat.score, barH); + ctx.globalAlpha = 1.0; + + // Status label on bar + ctx.font = '10px "Courier New", monospace'; + ctx.fillStyle = '#000000'; + ctx.textAlign = 'left'; + ctx.fillText(cat.status, barX + 6, y + 14); + + y += barH + 12; + } + + // Separator + ctx.strokeStyle = '#1a3a6a'; + ctx.beginPath(); + ctx.moveTo(20, y + 4); + ctx.lineTo(W - 20, y + 4); + ctx.stroke(); + + y += 22; + + // Overall score + ctx.font = '12px "Courier New", monospace'; + ctx.fillStyle = '#556688'; + ctx.textAlign = 'left'; + ctx.fillText('OVERALL CONVERGENCE', 20, y); + + ctx.font = 'bold 36px "Courier New", monospace'; + ctx.fillStyle = '#88ccff'; + ctx.textAlign = 'center'; + ctx.fillText('0.73', W / 2, y + 44); + + // Brain indicators at bottom + y += 60; + // Cloud brain indicator + ctx.beginPath(); + ctx.arc(W / 2 - 60, y + 8, 6, 0, Math.PI * 2); + ctx.fillStyle = '#00ddff'; + ctx.fill(); + ctx.font = '11px "Courier New", monospace'; + ctx.fillStyle = '#00ddff'; + ctx.textAlign = 'left'; + ctx.fillText('CLOUD', W / 2 - 48, y + 12); + + // Local brain indicator + ctx.beginPath(); + ctx.arc(W / 2 + 30, y + 8, 6, 0, Math.PI * 2); + ctx.fillStyle = '#ffaa22'; + ctx.fill(); + ctx.fillStyle = '#ffaa22'; + ctx.fillText('LOCAL', W / 2 + 42, y + 12); + + return new THREE.CanvasTexture(canvas); +} + +// Panel sprite +const dualBrainTexture = createDualBrainTexture(); +const dualBrainMaterial = new THREE.SpriteMaterial({ + map: dualBrainTexture, + transparent: true, + opacity: 0.92, + depthWrite: false, +}); +const dualBrainSprite = new THREE.Sprite(dualBrainMaterial); +dualBrainSprite.scale.set(5.0, 5.0, 1); +dualBrainSprite.position.set(0, 0, 0); // local to group +dualBrainSprite.userData = { + baseY: 0, + floatPhase: 0, + floatSpeed: 0.22, + zoomLabel: 'Dual-Brain Status', +}; +dualBrainGroup.add(dualBrainSprite); + +// Accent light for the panel +const dualBrainLight = new THREE.PointLight(0x4488ff, 0.6, 10); +dualBrainLight.position.set(0, 0.5, 1); +dualBrainGroup.add(dualBrainLight); + +// --- Brain Orbs --- +// Cloud brain orb (cyan) — positioned left of panel +const CLOUD_ORB_COLOR = 0x00ddff; +const cloudOrbGeo = new THREE.SphereGeometry(0.35, 32, 32); +const cloudOrbMat = new THREE.MeshStandardMaterial({ + color: CLOUD_ORB_COLOR, + emissive: new THREE.Color(CLOUD_ORB_COLOR), + emissiveIntensity: 1.5, + metalness: 0.3, + roughness: 0.2, + transparent: true, + opacity: 0.85, +}); +const cloudOrb = new THREE.Mesh(cloudOrbGeo, cloudOrbMat); +cloudOrb.position.set(-2.0, 3.0, 0); +cloudOrb.userData.zoomLabel = 'Cloud Brain'; +dualBrainGroup.add(cloudOrb); + +const cloudOrbLight = new THREE.PointLight(CLOUD_ORB_COLOR, 0.8, 5); +cloudOrbLight.position.copy(cloudOrb.position); +dualBrainGroup.add(cloudOrbLight); + +// Local brain orb (amber) — positioned right of panel +const LOCAL_ORB_COLOR = 0xffaa22; +const localOrbGeo = new THREE.SphereGeometry(0.35, 32, 32); +const localOrbMat = new THREE.MeshStandardMaterial({ + color: LOCAL_ORB_COLOR, + emissive: new THREE.Color(LOCAL_ORB_COLOR), + emissiveIntensity: 1.5, + metalness: 0.3, + roughness: 0.2, + transparent: true, + opacity: 0.85, +}); +const localOrb = new THREE.Mesh(localOrbGeo, localOrbMat); +localOrb.position.set(2.0, 3.0, 0); +localOrb.userData.zoomLabel = 'Local Brain'; +dualBrainGroup.add(localOrb); + +const localOrbLight = new THREE.PointLight(LOCAL_ORB_COLOR, 0.8, 5); +localOrbLight.position.copy(localOrb.position); +dualBrainGroup.add(localOrbLight); + +// --- Brain Pulse Particle Stream --- +// Particles flow from cloud orb → local orb along a curved arc +const BRAIN_PARTICLE_COUNT = 120; +const brainParticlePositions = new Float32Array(BRAIN_PARTICLE_COUNT * 3); +const brainParticlePhases = new Float32Array(BRAIN_PARTICLE_COUNT); // 0..1 progress along arc +const brainParticleSpeeds = new Float32Array(BRAIN_PARTICLE_COUNT); + +for (let i = 0; i < BRAIN_PARTICLE_COUNT; i++) { + brainParticlePhases[i] = Math.random(); + brainParticleSpeeds[i] = 0.15 + Math.random() * 0.2; + // Initial positions will be set in animate() + brainParticlePositions[i * 3] = 0; + brainParticlePositions[i * 3 + 1] = 0; + brainParticlePositions[i * 3 + 2] = 0; +} + +const brainParticleGeo = new THREE.BufferGeometry(); +brainParticleGeo.setAttribute('position', new THREE.BufferAttribute(brainParticlePositions, 3)); + +const brainParticleMat = new THREE.PointsMaterial({ + color: 0x44ddff, + size: 0.08, + sizeAttenuation: true, + transparent: true, + opacity: 0.8, + depthWrite: false, +}); + +const brainParticles = new THREE.Points(brainParticleGeo, brainParticleMat); +dualBrainGroup.add(brainParticles); + +// Scanning line overlay canvas — redrawn each frame in animate() +const _scanCanvas = document.createElement('canvas'); +_scanCanvas.width = 512; +_scanCanvas.height = 512; +const _scanCtx = _scanCanvas.getContext('2d'); +const dualBrainScanTexture = new THREE.CanvasTexture(_scanCanvas); +const dualBrainScanMat = new THREE.SpriteMaterial({ + map: dualBrainScanTexture, + transparent: true, + opacity: 0.18, + depthWrite: false, +}); +const dualBrainScanSprite = new THREE.Sprite(dualBrainScanMat); +dualBrainScanSprite.scale.set(5.0, 5.0, 1); +dualBrainScanSprite.position.set(0, 0, 0.01); +dualBrainGroup.add(dualBrainScanSprite); + // === ANIMATION LOOP === const clock = new THREE.Clock(); @@ -2393,6 +2597,76 @@ function animate() { gz.discMat.opacity = 0.02 + Math.sin(elapsed * 1.5 + gz.zone.x) * 0.02; } + // === DUAL-BRAIN ANIMATION === + // Panel float + dualBrainSprite.position.y = dualBrainSprite.userData.baseY + + Math.sin(elapsed * dualBrainSprite.userData.floatSpeed + dualBrainSprite.userData.floatPhase) * 0.22; + dualBrainScanSprite.position.y = dualBrainSprite.position.y; + + // Orb glow pulse + const cloudPulse = 1.2 + Math.sin(elapsed * 1.8) * 0.4; + const localPulse = 1.2 + Math.sin(elapsed * 1.8 + Math.PI) * 0.4; + cloudOrbMat.emissiveIntensity = cloudPulse; + localOrbMat.emissiveIntensity = localPulse; + cloudOrbLight.intensity = 0.5 + Math.sin(elapsed * 1.8) * 0.3; + localOrbLight.intensity = 0.5 + Math.sin(elapsed * 1.8 + Math.PI) * 0.3; + + // Orb hover + cloudOrb.position.y = 3.0 + Math.sin(elapsed * 0.9) * 0.15; + localOrb.position.y = 3.0 + Math.sin(elapsed * 0.9 + 1.0) * 0.15; + cloudOrbLight.position.y = cloudOrb.position.y; + localOrbLight.position.y = localOrb.position.y; + + // Brain pulse particles — flow along a curved arc from cloud → local orb + { + const pos = brainParticleGeo.attributes.position.array; + const startX = cloudOrb.position.x; + const endX = localOrb.position.x; + const arcHeight = 1.2; // peak height of arc above orbs + const simRate = 0.73; // simulated learning rate tied to overall score + + for (let i = 0; i < BRAIN_PARTICLE_COUNT; i++) { + brainParticlePhases[i] += brainParticleSpeeds[i] * simRate * 0.016; + if (brainParticlePhases[i] > 1.0) brainParticlePhases[i] -= 1.0; + + const t = brainParticlePhases[i]; + // Lerp X between orbs + pos[i * 3] = startX + (endX - startX) * t; + // Arc Y: parabolic curve peaking at midpoint + const midY = (cloudOrb.position.y + localOrb.position.y) / 2 + arcHeight; + pos[i * 3 + 1] = cloudOrb.position.y + (midY - cloudOrb.position.y) * 4 * t * (1 - t) + + (localOrb.position.y - cloudOrb.position.y) * t; + // Slight Z wobble for volume + pos[i * 3 + 2] = Math.sin(t * Math.PI * 4 + elapsed * 2 + i) * 0.12; + } + brainParticleGeo.attributes.position.needsUpdate = true; + + // Colour lerp from cyan → amber based on progress (approximated via hue shift) + const pulseIntensity = 0.6 + Math.sin(elapsed * 2.0) * 0.2; + brainParticleMat.opacity = pulseIntensity; + } + + // Scanning line effect — thin horizontal line sweeps down the panel + { + const W = 512, H = 512; + _scanCtx.clearRect(0, 0, W, H); + const scanY = ((elapsed * 60) % H); + _scanCtx.fillStyle = 'rgba(68, 136, 255, 0.5)'; + _scanCtx.fillRect(0, scanY, W, 2); + // Faint glow around scan line + const grad = _scanCtx.createLinearGradient(0, scanY - 8, 0, scanY + 10); + grad.addColorStop(0, 'rgba(68, 136, 255, 0)'); + grad.addColorStop(0.4, 'rgba(68, 136, 255, 0.15)'); + grad.addColorStop(0.6, 'rgba(68, 136, 255, 0.15)'); + grad.addColorStop(1, 'rgba(68, 136, 255, 0)'); + _scanCtx.fillStyle = grad; + _scanCtx.fillRect(0, scanY - 8, W, 18); + dualBrainScanTexture.needsUpdate = true; + } + + // Panel accent light pulse + dualBrainLight.intensity = 0.4 + Math.sin(elapsed * 1.1) * 0.2; + // Portal collision detection forwardVector.set(0, 0, -1).applyQuaternion(camera.quaternion); raycaster.set(camera.position, forwardVector);