Some checks failed
Deploy Nexus / deploy (push) Failing after 4s
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local> Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
197 lines
6.4 KiB
JavaScript
197 lines
6.4 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
}
|