feat: add dual-brain holographic panel with brain pulse visualization (#407)
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Co-authored-by: Perplexity Computer <perplexity@tower.local> Co-committed-by: Perplexity Computer <perplexity@tower.local>
This commit was merged in pull request #407.
This commit is contained in:
410
app.js
410
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);
|
||||
|
||||
Reference in New Issue
Block a user