Files
the-nexus/modules/effects/lightning.js

197 lines
6.4 KiB
JavaScript
Raw Normal View History

/**
* lightning.js Floating crystals and lightning arcs between them
*
* Category: DATA-TETHERED AESTHETIC
* Data source: state.zoneIntensity (commit activity drives arc count + intensity)
*
* Five octahedral crystals float above the platform. Lightning arcs jump
* between them when zone activity is high. Crystal count and colors are
* aligned to the five agent zones.
*/
import * as THREE from 'three';
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),
];
// Zone colors: Claude, Timmy, Kimi, Perplexity, center
const CRYSTAL_COLORS = [0xff6440, 0x40a0ff, 0x40ff8c, 0xc840ff, 0xffd700];
const LIGHTNING_POOL_SIZE = 6;
const LIGHTNING_SEGMENTS = 8;
const LIGHTNING_REFRESH_MS = 130;
let _state = null;
/** @type {THREE.Scene|null} */
let _scene = null;
/** @type {Array<{mesh: THREE.Mesh, light: THREE.PointLight, basePos: THREE.Vector3, floatPhase: number, flashStartTime: number}>} */
const crystals = [];
/** @type {THREE.Line[]} */
const lightningArcs = [];
/** @type {Array<{active: boolean, baseOpacity: number, srcIdx: number, dstIdx: number}>} */
const lightningArcMeta = [];
let _lastLightningRefreshTime = 0;
function _totalActivity() {
if (!_state) return 0;
if (typeof _state.totalActivity === 'function') return _state.totalActivity();
const zi = _state.zoneIntensity;
if (!zi) return 0;
const vals = Object.values(zi);
return vals.reduce((s, v) => s + v, 0) / Math.max(vals.length, 1);
}
function _lerpColor(colorA, colorB, t) {
const ar = (colorA >> 16) & 0xff, ag = (colorA >> 8) & 0xff, ab = colorA & 0xff;
const br = (colorB >> 16) & 0xff, bg = (colorB >> 8) & 0xff, bb = colorB & 0xff;
return (Math.round(ar + (br - ar) * t) << 16) |
(Math.round(ag + (bg - ag) * t) << 8) |
Math.round(ab + (bb - ab) * t);
}
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 jag = s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0;
out[s * 3] = start.x + (end.x - start.x) * t + jag;
out[s * 3 + 1] = start.y + (end.y - start.y) * t + jag;
out[s * 3 + 2] = start.z + (end.z - start.z) * t + jag;
}
return out;
}
/**
* @param {THREE.Scene} scene
* @param {object} state Shared state bus (reads state.zoneIntensity)
* @param {object} _theme
*/
export function init(scene, state, _theme) {
_scene = scene;
_state = state;
const crystalGroup = new THREE.Group();
scene.add(crystalGroup);
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, flashStartTime: -999 });
}
// Pre-allocate lightning arc pool
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);
lightningArcMeta.push({ active: false, baseOpacity: 0, srcIdx: 0, dstIdx: 0 });
}
}
function _refreshLightningArcs(elapsed) {
const activity = _totalActivity();
const activeCount = Math.round(activity * LIGHTNING_POOL_SIZE);
for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) {
const arc = lightningArcs[i];
const meta = lightningArcMeta[i];
if (i >= activeCount) {
arc.material.opacity = 0;
meta.active = false;
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.color.setHex(_lerpColor(CRYSTAL_COLORS[a], CRYSTAL_COLORS[b], 0.5));
const base = (0.35 + Math.random() * 0.55) * Math.min(activity * 1.5, 1.0);
arc.material.opacity = base;
meta.active = true;
meta.baseOpacity = base;
meta.srcIdx = a;
meta.dstIdx = b;
crystals[a].flashStartTime = elapsed;
crystals[b].flashStartTime = elapsed;
}
}
export function update(elapsed, _delta) {
const activity = _totalActivity();
// Float crystals
for (let i = 0; i < crystals.length; i++) {
const c = crystals[i];
c.mesh.position.y = c.basePos.y + Math.sin(elapsed * 0.7 + c.floatPhase) * 0.3;
c.light.position.y = c.mesh.position.y;
// Brief emissive flash on lightning strike
const flashAge = elapsed - c.flashStartTime;
const flashIntensity = flashAge < 0.15 ? (1.0 - flashAge / 0.15) : 0;
c.mesh.material.emissiveIntensity = 0.6 + flashIntensity * 1.2;
c.light.intensity = 0.3 + flashIntensity * 1.5;
// Color intensity tethered to total activity
c.mesh.material.opacity = 0.7 + activity * 0.18;
}
// Flicker active arcs
for (let i = 0; i < lightningArcMeta.length; i++) {
const meta = lightningArcMeta[i];
if (!meta.active) continue;
lightningArcs[i].material.opacity = meta.baseOpacity * (0.7 + Math.random() * 0.3);
}
// Periodically rebuild arcs
if (elapsed * 1000 - _lastLightningRefreshTime > LIGHTNING_REFRESH_MS) {
_lastLightningRefreshTime = elapsed * 1000;
_refreshLightningArcs(elapsed);
}
}