diff --git a/app.js b/app.js index 540375d..89afc84 100644 --- a/app.js +++ b/app.js @@ -1024,6 +1024,134 @@ function startWarp() { warpPass.uniforms['distortionStrength'].value = 0.0; } +// === FLOATING CRYSTALS & LIGHTNING ARCS === +// Crystals float above the platform. When zone activity is high, lightning arcs jump between them. + +const CRYSTAL_COUNT = 5; +const CRYSTAL_BASE_POSITIONS = [ + new THREE.Vector3(-4.5, 3.2, -3.8), + new THREE.Vector3( 4.8, 2.8, -4.0), + new THREE.Vector3(-5.5, 4.0, 1.5), + new THREE.Vector3( 5.2, 3.5, 2.0), + new THREE.Vector3( 0.0, 5.0, -5.5), +]; +// Colors aligned to agent zones: Claude, Timmy, Kimi, Perplexity, center +const CRYSTAL_COLORS = [0xff6440, 0x40a0ff, 0x40ff8c, 0xc840ff, 0xffd700]; + +const crystalGroup = new THREE.Group(); +scene.add(crystalGroup); + +/** + * @type {Array<{mesh: THREE.Mesh, light: THREE.PointLight, basePos: THREE.Vector3, floatPhase: number}>} + */ +const crystals = []; + +for (let i = 0; i < CRYSTAL_COUNT; i++) { + const geo = new THREE.OctahedronGeometry(0.35, 0); + const color = CRYSTAL_COLORS[i]; + const mat = new THREE.MeshStandardMaterial({ + color, + emissive: new THREE.Color(color).multiplyScalar(0.6), + roughness: 0.05, + metalness: 0.3, + transparent: true, + opacity: 0.88, + }); + const mesh = new THREE.Mesh(geo, mat); + const basePos = CRYSTAL_BASE_POSITIONS[i].clone(); + mesh.position.copy(basePos); + mesh.userData.zoomLabel = 'Crystal'; + crystalGroup.add(mesh); + + const light = new THREE.PointLight(color, 0.3, 6); + light.position.copy(basePos); + crystalGroup.add(light); + + crystals.push({ mesh, light, basePos, floatPhase: (i / CRYSTAL_COUNT) * Math.PI * 2 }); +} + +// ---- Lightning arc pool ---- + +const LIGHTNING_POOL_SIZE = 6; +const LIGHTNING_SEGMENTS = 8; +const LIGHTNING_REFRESH_MS = 130; +let lastLightningRefreshTime = 0; + +/** @type {Array} */ +const lightningArcs = []; + +for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) { + const positions = new Float32Array((LIGHTNING_SEGMENTS + 1) * 3); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const mat = new THREE.LineBasicMaterial({ + color: 0x88ccff, + transparent: true, + opacity: 0.0, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const arc = new THREE.Line(geo, mat); + scene.add(arc); + lightningArcs.push(arc); +} + +/** + * Builds a jagged lightning path between two points. + * @param {THREE.Vector3} start + * @param {THREE.Vector3} end + * @param {number} jagAmount + * @returns {Float32Array} + */ +function buildLightningPath(start, end, jagAmount) { + const out = new Float32Array((LIGHTNING_SEGMENTS + 1) * 3); + for (let s = 0; s <= LIGHTNING_SEGMENTS; s++) { + const t = s / LIGHTNING_SEGMENTS; + const x = start.x + (end.x - start.x) * t + (s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0); + const y = start.y + (end.y - start.y) * t + (s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0); + const z = start.z + (end.z - start.z) * t + (s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0); + out[s * 3] = x; out[s * 3 + 1] = y; out[s * 3 + 2] = z; + } + return out; +} + +/** + * Returns mean activity [0..1] across all agent zones. + */ +function totalActivity() { + const vals = Object.values(zoneIntensity); + return vals.reduce((s, v) => s + v, 0) / Math.max(vals.length, 1); +} + +/** + * Refreshes lightning arcs based on current activity level. + */ +function updateLightningArcs() { + const activity = totalActivity(); + const activeCount = Math.round(activity * LIGHTNING_POOL_SIZE); + + for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) { + const arc = lightningArcs[i]; + if (i >= activeCount) { + arc.material.opacity = 0; + continue; + } + + const a = Math.floor(Math.random() * CRYSTAL_COUNT); + let b = Math.floor(Math.random() * (CRYSTAL_COUNT - 1)); + if (b >= a) b++; + + const jagAmount = 0.45 + activity * 0.85; + const path = buildLightningPath(crystals[a].mesh.position, crystals[b].mesh.position, jagAmount); + const attr = arc.geometry.attributes.position; + attr.array.set(path); + attr.needsUpdate = true; + + arc.material.opacity = (0.35 + Math.random() * 0.55) * Math.min(activity * 1.5, 1.0); + arc.material.color.setHex(CRYSTAL_COLORS[Math.floor(Math.random() * CRYSTAL_COLORS.length)]); + } +} + // === ANIMATION LOOP === const clock = new THREE.Clock(); @@ -1199,6 +1327,23 @@ function animate() { } } + // Animate crystals — gentle float and slow spin + const activity = totalActivity(); + for (const crystal of crystals) { + crystal.mesh.position.x = crystal.basePos.x; + crystal.mesh.position.y = crystal.basePos.y + Math.sin(elapsed * 0.65 + crystal.floatPhase) * 0.35; + crystal.mesh.position.z = crystal.basePos.z; + crystal.mesh.rotation.y = elapsed * 0.4 + crystal.floatPhase; + crystal.light.position.copy(crystal.mesh.position); + crystal.light.intensity = 0.2 + activity * 0.8 + Math.sin(elapsed * 2.0 + crystal.floatPhase) * 0.1; + } + + // Refresh lightning arcs periodically + if (elapsed * 1000 - lastLightningRefreshTime > LIGHTNING_REFRESH_MS) { + lastLightningRefreshTime = elapsed * 1000; + updateLightningArcs(); + } + composer.render(); }