feat: add dual-brain holographic panel with brain pulse visualization #407
410
app.js
410
app.js
@@ -32,11 +32,18 @@ loadingManager.onProgress = (url, itemsLoaded, itemsTotal) => {
|
|||||||
document.getElementById('loading-bar').style.width = `${progress}%`;
|
document.getElementById('loading-bar').style.width = `${progress}%`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Simulate loading a texture for demonstration
|
// Procedural placeholder texture — avoids 404 for missing placeholder-texture.jpg
|
||||||
const textureLoader = new THREE.TextureLoader(loadingManager);
|
const _placeholderCanvas = document.createElement('canvas');
|
||||||
textureLoader.load('placeholder-texture.jpg', (texture) => {
|
_placeholderCanvas.width = 64;
|
||||||
loadedAssets.set('placeholder-texture', texture);
|
_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 ===
|
// === MATRIX RAIN ===
|
||||||
// 2D canvas layer rendered behind the Three.js scene.
|
// 2D canvas layer rendered behind the Three.js scene.
|
||||||
@@ -1219,10 +1226,6 @@ function animateEnergyBeam() {
|
|||||||
energyBeamMaterial.opacity = 0.3 + pulseEffect * 0.4;
|
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 ===
|
// === RESIZE HANDLER ===
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
@@ -1234,65 +1237,6 @@ window.addEventListener('resize', () => {
|
|||||||
|
|
||||||
// === SOVEREIGNTY METER ===
|
// === 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
|
// Holographic arc gauge floating above the platform; reads from sovereignty-status.json
|
||||||
const sovereigntyGroup = new THREE.Group();
|
const sovereigntyGroup = new THREE.Group();
|
||||||
sovereigntyGroup.position.set(0, 3.8, 0);
|
sovereigntyGroup.position.set(0, 3.8, 0);
|
||||||
@@ -2119,6 +2063,266 @@ batcaveGroup.traverse(obj => {
|
|||||||
// Probe state — timestamp of last reflection capture (seconds)
|
// Probe state — timestamp of last reflection capture (seconds)
|
||||||
let batcaveProbeLastUpdate = -999;
|
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 ===
|
// === ANIMATION LOOP ===
|
||||||
const clock = new THREE.Clock();
|
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;
|
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
|
// Portal collision detection
|
||||||
forwardVector.set(0, 0, -1).applyQuaternion(camera.quaternion);
|
forwardVector.set(0, 0, -1).applyQuaternion(camera.quaternion);
|
||||||
raycaster.set(camera.position, forwardVector);
|
raycaster.set(camera.position, forwardVector);
|
||||||
|
|||||||
Reference in New Issue
Block a user