From e28d14ba4b471338c0ab77953da6927a395e5bad Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 01:10:53 -0400 Subject: [PATCH] feat: enhance lightning arcs with per-frame flicker, color blending, and crystal flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add lightningArcMeta array to track per-arc active state and base opacity - Per-frame opacity flicker (55–100% of base) applied each animation frame for realistic lightning appearance between geometry refreshes (130ms interval) - Arc color now blends between source and destination crystal colors via lerpColor() instead of a random crystal color, giving each arc visual context - Crystal emissive flash: struck crystals boost emissiveIntensity and point light intensity for 250ms when an arc connects, giving visible impact feedback - Pass elapsed time into updateLightningArcs() for flash timing Fixes #243 Co-Authored-By: Claude Sonnet 4.6 --- app.js | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/app.js b/app.js index 7765897..c0e8df6 100644 --- a/app.js +++ b/app.js @@ -1114,7 +1114,7 @@ for (let i = 0; i < CRYSTAL_COUNT; i++) { light.position.copy(basePos); crystalGroup.add(light); - crystals.push({ mesh, light, basePos, floatPhase: (i / CRYSTAL_COUNT) * Math.PI * 2 }); + crystals.push({ mesh, light, basePos, floatPhase: (i / CRYSTAL_COUNT) * Math.PI * 2, flashStartTime: -999 }); } // ---- Lightning arc pool ---- @@ -1127,6 +1127,12 @@ let lastLightningRefreshTime = 0; /** @type {Array} */ const lightningArcs = []; +/** + * Per-arc runtime state for per-frame flicker. + * @type {Array<{active: boolean, baseOpacity: number, srcIdx: number, dstIdx: number}>} + */ +const lightningArcMeta = []; + for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) { const positions = new Float32Array((LIGHTNING_SEGMENTS + 1) * 3); const geo = new THREE.BufferGeometry(); @@ -1141,6 +1147,7 @@ for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) { const arc = new THREE.Line(geo, mat); scene.add(arc); lightningArcs.push(arc); + lightningArcMeta.push({ active: false, baseOpacity: 0, srcIdx: 0, dstIdx: 0 }); } /** @@ -1171,16 +1178,35 @@ function totalActivity() { } /** - * Refreshes lightning arcs based on current activity level. + * Lerps between two hex colors. t=0 → colorA, t=1 → colorB. + * @param {number} colorA + * @param {number} colorB + * @param {number} t + * @returns {number} */ -function updateLightningArcs() { +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; +} + +/** + * Refreshes lightning arc geometry and metadata based on current activity level. + * @param {number} elapsed Current clock time in seconds. + */ +function updateLightningArcs(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; } @@ -1194,8 +1220,19 @@ function updateLightningArcs() { 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)]); + // Blend arc color between source and destination crystal + 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; + + // Trigger brief emissive flash on both struck crystals + crystals[a].flashStartTime = elapsed; + crystals[b].flashStartTime = elapsed; } } @@ -1427,7 +1464,7 @@ function animate() { } } - // Animate crystals — gentle float and slow spin + // Animate crystals — gentle float, slow spin, and lightning-strike flash const activity = totalActivity(); for (const crystal of crystals) { crystal.mesh.position.x = crystal.basePos.x; @@ -1435,13 +1472,24 @@ function animate() { 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; + 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; } - // Refresh lightning arcs periodically + // Per-frame lightning flicker — modulate opacity each frame for realism + 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); + } + } + + // Refresh lightning arc geometry periodically if (elapsed * 1000 - lastLightningRefreshTime > LIGHTNING_REFRESH_MS) { lastLightningRefreshTime = elapsed * 1000; - updateLightningArcs(); + updateLightningArcs(elapsed); } composer.render(); -- 2.43.0