Split the monolithic 5393-line app.js into 32 focused ES modules under modules/ with a thin ~330-line orchestrator. No bundler required — runs in-browser via import maps. Module structure: core/ — scene, ticker, state, theme, audio data/ — gitea, weather, bitcoin, loaders terrain/ — stars, clouds, island effects/ — matrix-rain, energy-beam, lightning, shockwave, rune-ring, gravity-zones panels/ — heatmap, sigil, sovereignty, dual-brain, batcave, earth, agent-board, lora-panel portals/ — portal-system, commit-banners narrative/ — bookshelves, oath, chat utils/ — perlin All files pass node --check. No new dependencies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
144 lines
5.3 KiB
JavaScript
144 lines
5.3 KiB
JavaScript
// modules/effects/lightning.js — Floating crystals + lightning arcs
|
|
import * as THREE from 'three';
|
|
import { state } from '../core/state.js';
|
|
|
|
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),
|
|
];
|
|
const CRYSTAL_COLORS = [0xff6440, 0x40a0ff, 0x40ff8c, 0xc840ff, 0xffd700];
|
|
|
|
const LIGHTNING_POOL_SIZE = 6;
|
|
const LIGHTNING_SEGMENTS = 8;
|
|
const LIGHTNING_REFRESH_MS = 130;
|
|
|
|
const crystals = [];
|
|
const lightningArcs = [];
|
|
const lightningArcMeta = [];
|
|
let lastLightningRefreshTime = 0;
|
|
let crystalGroup;
|
|
|
|
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;
|
|
const r = Math.round(ar + (br - ar) * t);
|
|
const g = Math.round(ag + (bg - ag) * t);
|
|
const b = Math.round(ab + (bb - ab) * t);
|
|
return (r << 16) | (g << 8) | b;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function updateLightningArcs(elapsed) {
|
|
const activity = state.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 init(scene) {
|
|
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 });
|
|
}
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
export function update(elapsed) {
|
|
const activity = state.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);
|
|
const flashAge = elapsed - crystal.flashStartTime;
|
|
const flashBoost = flashAge < 0.25 ? (1.0 - flashAge / 0.25) * 2.0 : 0.0;
|
|
crystal.light.intensity = 0.2 + activity * 0.8 + Math.sin(elapsed * 2.0 + crystal.floatPhase) * 0.1 + flashBoost;
|
|
crystal.mesh.material.emissiveIntensity = 1.0 + flashBoost * 0.8;
|
|
}
|
|
|
|
for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) {
|
|
const meta = lightningArcMeta[i];
|
|
if (meta.active) {
|
|
lightningArcs[i].material.opacity = meta.baseOpacity * (0.55 + Math.random() * 0.45);
|
|
}
|
|
}
|
|
|
|
if (elapsed * 1000 - lastLightningRefreshTime > LIGHTNING_REFRESH_MS) {
|
|
lastLightningRefreshTime = elapsed * 1000;
|
|
updateLightningArcs(elapsed);
|
|
}
|
|
}
|