Spaces:
Running
Running
| /** | |
| * Lightning Effects — Three.js Magic ⚡ | |
| * ═══════════════════════════════════════════════════════════════════════════ | |
| * Procedural lightning bolts + floating particles + AUDIO REACTIVE! 🎵 | |
| * Inspired by Ivy's audio visualizer 🌿 | |
| * Made with 💙 by Kai | |
| * ═══════════════════════════════════════════════════════════════════════════ | |
| */ | |
| (function () { | |
| "use strict"; | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // CONFIGURATION | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| const CONFIG = { | |
| // Lightning | |
| lightning: { | |
| enabled: true, | |
| minInterval: 4000, // Min time between strikes (ms) | |
| maxInterval: 12000, // Max time between strikes (ms) | |
| duration: 200, // How long the bolt stays visible (ms) | |
| branches: 3, // Number of branch segments | |
| color: 0x3b82f6, // Electric blue | |
| glowColor: 0x60a5fa, // Lighter blue for glow | |
| intensity: 2.5 | |
| }, | |
| // Particles | |
| particles: { | |
| enabled: true, | |
| count: 120, // More particles! | |
| size: 2.5, | |
| baseSpeed: 0.15, | |
| color: 0x3b82f6, | |
| opacity: 0.5 | |
| }, | |
| // Color Palettes 🎨 | |
| palettes: { | |
| focus: { | |
| primary: 0x3b82f6, // Electric blue | |
| secondary: 0x8b5cf6, // Purple | |
| accent: 0x06b6d4, // Cyan | |
| glow: 0x60a5fa | |
| }, | |
| short: { | |
| primary: 0x10b981, // Emerald green | |
| secondary: 0x34d399, // Light green | |
| accent: 0x6ee7b7, // Mint | |
| glow: 0x34d399 | |
| }, | |
| long: { | |
| primary: 0x8b5cf6, // Purple | |
| secondary: 0xa855f7, // Light purple | |
| accent: 0xc084fc, // Lavender | |
| glow: 0xa855f7 | |
| } | |
| }, | |
| // Audio Reactive 🎵 | |
| audio: { | |
| enabled: true, | |
| beatThreshold: 0.5, // Trigger lightning on strong beats (reduced from 0.7) | |
| particleReactivity: 2.0, // How much particles react | |
| colorShift: true // Shift colors with music | |
| }, | |
| // Effects ✨ | |
| effects: { | |
| floatingOrbs: true, // Glowing orbs | |
| trailParticles: true, // Trailing effect | |
| pulsingGlow: true, // Ambient pulsing | |
| rainbowMode: false // Party mode! | |
| }, | |
| // Performance | |
| pixelRatio: Math.min(window.devicePixelRatio, 2) | |
| }; | |
| // Current color palette (changes with timer mode) | |
| let currentPalette = CONFIG.palettes.focus; | |
| let currentMode = "focus"; | |
| // Update palette based on timer mode | |
| window.setVisualizerMode = function (mode) { | |
| currentMode = mode; | |
| currentPalette = CONFIG.palettes[mode] || CONFIG.palettes.focus; | |
| updateSceneColors(); | |
| console.log(`✨ Visualizer mode: ${mode}`); | |
| }; | |
| function updateSceneColors() { | |
| // Update particles color | |
| if (particles && particles.material) { | |
| particles.material.color.setHex(currentPalette.primary); | |
| } | |
| // Update orbs | |
| if (orbs) { | |
| orbs.forEach((orb, i) => { | |
| const colors = [currentPalette.primary, currentPalette.secondary, currentPalette.accent]; | |
| orb.material.color.setHex(colors[i % 3]); | |
| }); | |
| } | |
| // Update stars ⭐ | |
| if (stars) { | |
| stars.forEach(star => { | |
| star.material.color.setHex(currentPalette.accent); | |
| }); | |
| } | |
| // Update halos 🔮 | |
| if (halos) { | |
| halos.forEach(halo => { | |
| halo.material.color.setHex(currentPalette.glow); | |
| }); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // AUDIO ANALYZER 🎵 (Inspired by Ivy 🌿) | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| let audioAnalyzer = { | |
| context: null, | |
| analyser: null, | |
| source: null, | |
| frequencyData: null, | |
| timeDomainData: null, | |
| isConnected: false, | |
| lastBeatTime: 0, | |
| bassLevel: 0, | |
| midLevel: 0, | |
| highLevel: 0, | |
| averageLevel: 0 | |
| }; | |
| // Cache for MediaElementSourceNodes — an element can only be connected ONCE | |
| const mediaSourceCache = new WeakMap(); | |
| // Connect to an audio element (radio or ambient) | |
| window.connectAudioVisualizer = function (audioElement) { | |
| if (!audioElement) return; | |
| try { | |
| // Disconnect previous source from analyser (but don't destroy it) | |
| if (audioAnalyzer.source && audioAnalyzer.analyser) { | |
| try { | |
| audioAnalyzer.source.disconnect(audioAnalyzer.analyser); | |
| } catch (e) {} | |
| } | |
| // Create or reuse AudioContext | |
| if (!audioAnalyzer.context) { | |
| audioAnalyzer.context = new (window.AudioContext || window.webkitAudioContext)(); | |
| } | |
| // Resume if suspended | |
| if (audioAnalyzer.context.state === "suspended") { | |
| audioAnalyzer.context.resume(); | |
| } | |
| // Create analyser | |
| if (!audioAnalyzer.analyser) { | |
| audioAnalyzer.analyser = audioAnalyzer.context.createAnalyser(); | |
| audioAnalyzer.analyser.fftSize = 256; | |
| audioAnalyzer.analyser.smoothingTimeConstant = 0.8; | |
| audioAnalyzer.analyser.connect(audioAnalyzer.context.destination); | |
| } | |
| // Get or create MediaElementSourceNode (can only be created ONCE per element!) | |
| let source = mediaSourceCache.get(audioElement); | |
| if (!source) { | |
| source = audioAnalyzer.context.createMediaElementSource(audioElement); | |
| mediaSourceCache.set(audioElement, source); | |
| console.log("🎵 Created new MediaElementSource for audio element"); | |
| } | |
| // Connect source to analyser | |
| source.connect(audioAnalyzer.analyser); | |
| audioAnalyzer.source = source; | |
| // Prepare data arrays | |
| if (!audioAnalyzer.frequencyData) { | |
| audioAnalyzer.frequencyData = new Uint8Array(audioAnalyzer.analyser.frequencyBinCount); | |
| audioAnalyzer.timeDomainData = new Uint8Array(audioAnalyzer.analyser.fftSize); | |
| } | |
| audioAnalyzer.isConnected = true; | |
| console.log("🎵 Audio visualizer connected! Particles will dance ⚡"); | |
| } catch (e) { | |
| console.log("🎵 Could not connect audio visualizer:", e.message); | |
| } | |
| }; | |
| window.disconnectAudioVisualizer = function () { | |
| if (audioAnalyzer.source && audioAnalyzer.analyser) { | |
| try { | |
| // Only disconnect from analyser, don't destroy the source | |
| // (MediaElementSourceNode can't be recreated for the same element) | |
| audioAnalyzer.source.disconnect(audioAnalyzer.analyser); | |
| } catch (e) {} | |
| } | |
| audioAnalyzer.isConnected = false; | |
| audioAnalyzer.source = null; | |
| console.log("🎵 Audio visualizer disconnected"); | |
| }; | |
| function updateAudioAnalysis() { | |
| if (!audioAnalyzer.isConnected || !audioAnalyzer.analyser) { | |
| audioAnalyzer.bassLevel = 0; | |
| audioAnalyzer.midLevel = 0; | |
| audioAnalyzer.highLevel = 0; | |
| audioAnalyzer.averageLevel = 0; | |
| return; | |
| } | |
| audioAnalyzer.analyser.getByteFrequencyData(audioAnalyzer.frequencyData); | |
| const bins = audioAnalyzer.frequencyData.length; | |
| let bass = 0, | |
| mid = 0, | |
| high = 0; | |
| // Split frequency spectrum into bass/mid/high | |
| for (let i = 0; i < bins; i++) { | |
| const value = audioAnalyzer.frequencyData[i] / 255; | |
| if (i < bins * 0.15) { | |
| bass += value; // Low frequencies (bass) | |
| } else if (i < bins * 0.5) { | |
| mid += value; // Mid frequencies | |
| } else { | |
| high += value; // High frequencies | |
| } | |
| } | |
| // Normalize | |
| audioAnalyzer.bassLevel = bass / (bins * 0.15); | |
| audioAnalyzer.midLevel = mid / (bins * 0.35); | |
| audioAnalyzer.highLevel = high / (bins * 0.5); | |
| audioAnalyzer.averageLevel = (audioAnalyzer.bassLevel + audioAnalyzer.midLevel + audioAnalyzer.highLevel) / 3; | |
| // Beat detection — trigger lightning on strong bass hits! | |
| const now = Date.now(); | |
| if (audioAnalyzer.bassLevel > CONFIG.audio.beatThreshold && now - audioAnalyzer.lastBeatTime > 500) { | |
| audioAnalyzer.lastBeatTime = now; | |
| if (CONFIG.audio.enabled) { | |
| createLightningBolt(); // ⚡ Lightning on beat! | |
| } | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // THREE.JS SETUP | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| const canvas = document.getElementById("lightning-canvas"); | |
| if (!canvas) return; | |
| // Scene | |
| const scene = new THREE.Scene(); | |
| // Camera | |
| const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.z = 50; | |
| // Renderer | |
| const renderer = new THREE.WebGLRenderer({ | |
| canvas: canvas, | |
| alpha: true, | |
| antialias: true | |
| }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(CONFIG.pixelRatio); | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // FLOATING PARTICLES (lo-fi dust/stars) | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| let particles; | |
| let orbs = []; | |
| function createParticles() { | |
| const geometry = new THREE.BufferGeometry(); | |
| const positions = new Float32Array(CONFIG.particles.count * 3); | |
| const colors = new Float32Array(CONFIG.particles.count * 3); | |
| const velocities = []; | |
| for (let i = 0; i < CONFIG.particles.count; i++) { | |
| // Random position in view | |
| positions[i * 3] = (Math.random() - 0.5) * 100; // x | |
| positions[i * 3 + 1] = (Math.random() - 0.5) * 100; // y | |
| positions[i * 3 + 2] = (Math.random() - 0.5) * 50; // z | |
| // Rainbow colors for particles ✨ | |
| const hue = (i / CONFIG.particles.count) * 0.3 + 0.5; // Blue to purple range | |
| const color = new THREE.Color().setHSL(hue, 0.8, 0.6); | |
| colors[i * 3] = color.r; | |
| colors[i * 3 + 1] = color.g; | |
| colors[i * 3 + 2] = color.b; | |
| // Random velocity | |
| velocities.push({ | |
| x: (Math.random() - 0.5) * CONFIG.particles.baseSpeed, | |
| y: (Math.random() - 0.5) * CONFIG.particles.baseSpeed, | |
| z: (Math.random() - 0.5) * CONFIG.particles.baseSpeed * 0.5 | |
| }); | |
| } | |
| geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); | |
| geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); | |
| const material = new THREE.PointsMaterial({ | |
| size: CONFIG.particles.size, | |
| transparent: true, | |
| opacity: CONFIG.particles.opacity, | |
| blending: THREE.AdditiveBlending, | |
| vertexColors: true // Use per-vertex colors! | |
| }); | |
| particles = new THREE.Points(geometry, material); | |
| particles.userData.velocities = velocities; | |
| scene.add(particles); | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // STARS ⭐ (Twinkling star shapes) | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| let stars = []; | |
| function createStarShape() { | |
| const shape = new THREE.Shape(); | |
| const outerRadius = 1; | |
| const innerRadius = 0.4; | |
| const points = 5; | |
| for (let i = 0; i < points * 2; i++) { | |
| const radius = i % 2 === 0 ? outerRadius : innerRadius; | |
| const angle = (i * Math.PI) / points; | |
| const x = Math.cos(angle) * radius; | |
| const y = Math.sin(angle) * radius; | |
| if (i === 0) shape.moveTo(x, y); | |
| else shape.lineTo(x, y); | |
| } | |
| shape.closePath(); | |
| return new THREE.ShapeGeometry(shape); | |
| } | |
| function createStars() { | |
| const starCount = 15; | |
| const starGeometry = createStarShape(); | |
| for (let i = 0; i < starCount; i++) { | |
| const material = new THREE.MeshBasicMaterial({ | |
| color: currentPalette.accent, | |
| transparent: true, | |
| opacity: 0.6, | |
| side: THREE.DoubleSide, | |
| blending: THREE.AdditiveBlending | |
| }); | |
| const star = new THREE.Mesh(starGeometry, material); | |
| star.position.set((Math.random() - 0.5) * 80, (Math.random() - 0.5) * 60, (Math.random() - 0.5) * 30 - 10); | |
| star.rotation.z = Math.random() * Math.PI * 2; | |
| star.userData = { | |
| twinkleSpeed: 0.5 + Math.random() * 1.5, | |
| twinklePhase: Math.random() * Math.PI * 2, | |
| rotationSpeed: (Math.random() - 0.5) * 0.01 | |
| }; | |
| scene.add(star); | |
| stars.push(star); | |
| } | |
| } | |
| function updateStars() { | |
| if (!stars.length) return; | |
| const time = Date.now() * 0.001; | |
| const audioBoost = 1 + audioAnalyzer.averageLevel * 0.5; | |
| stars.forEach(star => { | |
| // Twinkle effect | |
| const twinkle = Math.sin(time * star.userData.twinkleSpeed + star.userData.twinklePhase); | |
| star.material.opacity = 0.3 + twinkle * 0.3 + audioAnalyzer.highLevel * 0.4; | |
| // Gentle rotation | |
| star.rotation.z += star.userData.rotationSpeed * audioBoost; | |
| // Scale pulse with bass | |
| const scale = 1 + audioAnalyzer.bassLevel * 0.3; | |
| star.scale.setScalar(scale); | |
| }); | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // HALOS 🔮 (Pulsing rings of light) | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| let halos = []; | |
| function createHalos() { | |
| const haloCount = 3; | |
| for (let i = 0; i < haloCount; i++) { | |
| const geometry = new THREE.RingGeometry(8 + i * 5, 9 + i * 5, 32); | |
| const material = new THREE.MeshBasicMaterial({ | |
| color: currentPalette.glow, | |
| transparent: true, | |
| opacity: 0.1, | |
| side: THREE.DoubleSide, | |
| blending: THREE.AdditiveBlending | |
| }); | |
| const halo = new THREE.Mesh(geometry, material); | |
| halo.position.set(0, 0, -20 - i * 5); | |
| halo.userData = { | |
| baseScale: 1, | |
| pulseSpeed: 0.8 + i * 0.3, | |
| pulsePhase: i * Math.PI * 0.5 | |
| }; | |
| scene.add(halo); | |
| halos.push(halo); | |
| } | |
| } | |
| function updateHalos() { | |
| if (!halos.length) return; | |
| const time = Date.now() * 0.001; | |
| const bassBoost = 1 + audioAnalyzer.bassLevel * 2; | |
| halos.forEach((halo, i) => { | |
| // Pulse effect | |
| const pulse = Math.sin(time * halo.userData.pulseSpeed + halo.userData.pulsePhase); | |
| const scale = halo.userData.baseScale + pulse * 0.15 * bassBoost; | |
| halo.scale.setScalar(scale); | |
| // Opacity pulse | |
| halo.material.opacity = 0.05 + pulse * 0.05 + audioAnalyzer.averageLevel * 0.15; | |
| // Gentle rotation | |
| halo.rotation.z += 0.001 * (i + 1); | |
| }); | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // FLOATING ORBS ✨ (Glowing spheres) | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| function createOrbs() { | |
| if (!CONFIG.effects.floatingOrbs) return; | |
| const orbCount = 5; | |
| const colors = [ | |
| currentPalette.primary, | |
| currentPalette.secondary, | |
| currentPalette.accent, | |
| currentPalette.secondary, | |
| currentPalette.primary | |
| ]; | |
| for (let i = 0; i < orbCount; i++) { | |
| const geometry = new THREE.SphereGeometry(1 + Math.random() * 2, 16, 16); | |
| const material = new THREE.MeshBasicMaterial({ | |
| color: colors[i], | |
| transparent: true, | |
| opacity: 0.15, | |
| blending: THREE.AdditiveBlending | |
| }); | |
| const orb = new THREE.Mesh(geometry, material); | |
| orb.position.set((Math.random() - 0.5) * 60, (Math.random() - 0.5) * 40, (Math.random() - 0.5) * 20 - 10); | |
| orb.userData = { | |
| baseY: orb.position.y, | |
| speed: 0.5 + Math.random() * 0.5, | |
| phase: Math.random() * Math.PI * 2 | |
| }; | |
| scene.add(orb); | |
| orbs.push(orb); | |
| } | |
| } | |
| function updateOrbs() { | |
| if (!orbs.length) return; | |
| const time = Date.now() * 0.001; | |
| const audioBoost = 1 + audioAnalyzer.averageLevel * 2; | |
| orbs.forEach((orb, i) => { | |
| // Floating motion | |
| orb.position.y = orb.userData.baseY + Math.sin(time * orb.userData.speed + orb.userData.phase) * 5; | |
| orb.position.x += Math.sin(time * 0.3 + i) * 0.02; | |
| // Pulse with audio | |
| const scale = (1 + Math.sin(time * 2 + i) * 0.2) * audioBoost; | |
| orb.scale.setScalar(scale); | |
| // Opacity pulse | |
| orb.material.opacity = 0.1 + Math.sin(time + i) * 0.05 + audioAnalyzer.bassLevel * 0.1; | |
| }); | |
| } | |
| function updateParticles() { | |
| if (!particles) return; | |
| const positions = particles.geometry.attributes.position.array; | |
| const colors = particles.geometry.attributes.color.array; | |
| const velocities = particles.userData.velocities; | |
| // Audio reactivity 🎵 | |
| const audioBoost = 1 + audioAnalyzer.averageLevel * CONFIG.audio.particleReactivity; | |
| const bassBoost = 1 + audioAnalyzer.bassLevel * 3; | |
| // Update particle size based on audio | |
| if (audioAnalyzer.isConnected) { | |
| particles.material.size = CONFIG.particles.size * bassBoost; | |
| particles.material.opacity = Math.min(0.8, CONFIG.particles.opacity + audioAnalyzer.midLevel * 0.4); | |
| // Color shift with high frequencies | |
| if (CONFIG.audio.colorShift && audioAnalyzer.highLevel > 0.3) { | |
| const hue = (Date.now() * 0.001 + audioAnalyzer.highLevel) % 1; | |
| particles.material.color.setHSL(0.6 + hue * 0.2, 0.8, 0.6); | |
| } else { | |
| particles.material.color.setHex(CONFIG.particles.color); | |
| } | |
| } | |
| for (let i = 0; i < CONFIG.particles.count; i++) { | |
| // Update position with audio-reactive speed | |
| const speedMultiplier = audioBoost; | |
| positions[i * 3] += velocities[i].x * speedMultiplier; | |
| positions[i * 3 + 1] += velocities[i].y * speedMultiplier; | |
| positions[i * 3 + 2] += velocities[i].z * speedMultiplier; | |
| // Shift colors with time for rainbow effect ✨ | |
| if (audioAnalyzer.isConnected && CONFIG.audio.colorShift) { | |
| const time = Date.now() * 0.0005; | |
| const hue = (i / CONFIG.particles.count + time) % 1; | |
| const color = new THREE.Color().setHSL(hue, 0.8, 0.5 + audioAnalyzer.averageLevel * 0.3); | |
| colors[i * 3] = color.r; | |
| colors[i * 3 + 1] = color.g; | |
| colors[i * 3 + 2] = color.b; | |
| } | |
| // Wrap around edges | |
| if (positions[i * 3] > 50) positions[i * 3] = -50; | |
| if (positions[i * 3] < -50) positions[i * 3] = 50; | |
| if (positions[i * 3 + 1] > 50) positions[i * 3 + 1] = -50; | |
| if (positions[i * 3 + 1] < -50) positions[i * 3 + 1] = 50; | |
| } | |
| particles.geometry.attributes.position.needsUpdate = true; | |
| if (audioAnalyzer.isConnected) { | |
| particles.geometry.attributes.color.needsUpdate = true; | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // LIGHTNING BOLT GENERATION (with mesh pooling to reduce GC pressure) | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| let activeLightning = []; | |
| let lightningPool = []; // Reusable meshes | |
| const MAX_POOL_SIZE = 20; | |
| function createLightningBolt() { | |
| // Use current palette colors! 🎨 | |
| const boltColor = currentPalette.primary; | |
| const glowColor = currentPalette.glow; | |
| // Random start point (top area) | |
| const startX = (Math.random() - 0.5) * 80; | |
| const startY = 40 + Math.random() * 20; | |
| // Random end point (bottom area) | |
| const endX = startX + (Math.random() - 0.5) * 40; | |
| const endY = -40 - Math.random() * 20; | |
| // Generate main bolt path | |
| const points = generateBoltPath(startX, startY, endX, endY, 8); | |
| // Create main bolt | |
| const mainBolt = createBoltMesh(points, boltColor, 2); | |
| scene.add(mainBolt); | |
| activeLightning.push(mainBolt); | |
| // Create glow effect (thicker, more transparent) | |
| const glowBolt = createBoltMesh(points, glowColor, 6, 0.3); | |
| scene.add(glowBolt); | |
| activeLightning.push(glowBolt); | |
| // Create branches with accent color | |
| for (let i = 0; i < CONFIG.lightning.branches; i++) { | |
| const branchStart = Math.floor(Math.random() * (points.length - 2)) + 1; | |
| const branchPoint = points[branchStart]; | |
| const branchEndX = branchPoint.x + (Math.random() - 0.5) * 30; | |
| const branchEndY = branchPoint.y - 10 - Math.random() * 20; | |
| const branchPoints = generateBoltPath(branchPoint.x, branchPoint.y, branchEndX, branchEndY, 4); | |
| const branch = createBoltMesh(branchPoints, CONFIG.lightning.color, 1, 0.7); | |
| scene.add(branch); | |
| activeLightning.push(branch); | |
| } | |
| // Flash effect — briefly increase ambient | |
| flashScreen(); | |
| // Remove lightning after duration | |
| setTimeout(() => { | |
| activeLightning.forEach(bolt => { | |
| scene.remove(bolt); | |
| // Return to pool instead of destroying (reduce GC pressure) | |
| if (lightningPool.length < MAX_POOL_SIZE) { | |
| bolt.visible = false; | |
| lightningPool.push(bolt); | |
| } else { | |
| // Pool is full, dispose | |
| bolt.geometry.dispose(); | |
| bolt.material.dispose(); | |
| } | |
| }); | |
| activeLightning = []; | |
| }, CONFIG.lightning.duration); | |
| console.log("⚡ Lightning strike!"); | |
| } | |
| function generateBoltPath(startX, startY, endX, endY, segments) { | |
| const points = []; | |
| points.push(new THREE.Vector3(startX, startY, 0)); | |
| const dx = (endX - startX) / segments; | |
| const dy = (endY - startY) / segments; | |
| for (let i = 1; i < segments; i++) { | |
| const x = startX + dx * i + (Math.random() - 0.5) * 15; | |
| const y = startY + dy * i + (Math.random() - 0.5) * 5; | |
| const z = (Math.random() - 0.5) * 5; | |
| points.push(new THREE.Vector3(x, y, z)); | |
| } | |
| points.push(new THREE.Vector3(endX, endY, 0)); | |
| return points; | |
| } | |
| function createBoltMesh(points, color, lineWidth, opacity = 1) { | |
| const geometry = new THREE.BufferGeometry().setFromPoints(points); | |
| const material = new THREE.LineBasicMaterial({ | |
| color: color, | |
| linewidth: lineWidth, | |
| transparent: true, | |
| opacity: opacity, | |
| blending: THREE.AdditiveBlending | |
| }); | |
| return new THREE.Line(geometry, material); | |
| } | |
| // Reusable flash element (created once, reused) | |
| let flashElement = null; | |
| function flashScreen() { | |
| // Create flash element once and reuse | |
| if (!flashElement) { | |
| flashElement = document.createElement("div"); | |
| flashElement.style.cssText = ` | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(59, 130, 246, 0.1); | |
| pointer-events: none; | |
| z-index: 9999; | |
| opacity: 0; | |
| transition: opacity 0.15s ease-out; | |
| `; | |
| document.body.appendChild(flashElement); | |
| } | |
| // Trigger flash via opacity | |
| flashElement.style.opacity = "1"; | |
| requestAnimationFrame(() => { | |
| requestAnimationFrame(() => { | |
| flashElement.style.opacity = "0"; | |
| }); | |
| }); | |
| } | |
| // Add flash animation to document | |
| const style = document.createElement("style"); | |
| style.textContent = ` | |
| @keyframes flashFade { | |
| 0% { opacity: 1; } | |
| 100% { opacity: 0; } | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // LIGHTNING SCHEDULER | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| function scheduleLightning() { | |
| if (!CONFIG.lightning.enabled) return; | |
| const delay = | |
| CONFIG.lightning.minInterval + | |
| Math.random() * (CONFIG.lightning.maxInterval - CONFIG.lightning.minInterval); | |
| setTimeout(() => { | |
| createLightningBolt(); | |
| scheduleLightning(); | |
| }, delay); | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // ANIMATION LOOP | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| let isAnimating = true; | |
| let animationFrameId = null; | |
| function animate() { | |
| if (!isAnimating) return; // Pause rendering when disabled | |
| animationFrameId = requestAnimationFrame(animate); | |
| // Update audio analysis 🎵 | |
| updateAudioAnalysis(); | |
| // Update particles | |
| if (CONFIG.particles.enabled) { | |
| updateParticles(); | |
| } | |
| // Update floating orbs ✨ | |
| if (CONFIG.effects.floatingOrbs) { | |
| updateOrbs(); | |
| } | |
| // Update stars ⭐ | |
| updateStars(); | |
| // Update halos 🔮 | |
| updateHalos(); | |
| // Gentle camera sway (lo-fi vibe) — enhanced with audio | |
| const audioSway = audioAnalyzer.isConnected ? audioAnalyzer.bassLevel * 2 : 0; | |
| camera.position.x = Math.sin(Date.now() * 0.0001) * (2 + audioSway); | |
| camera.position.y = Math.cos(Date.now() * 0.00015) * (1 + audioSway * 0.5); | |
| renderer.render(scene, camera); | |
| } | |
| // Expose controls for external toggle | |
| window.pauseVisualEffects = function () { | |
| isAnimating = false; | |
| if (animationFrameId) { | |
| cancelAnimationFrame(animationFrameId); | |
| animationFrameId = null; | |
| } | |
| console.log("⚡ Visual effects paused (CPU saved!)"); | |
| }; | |
| window.resumeVisualEffects = function () { | |
| if (!isAnimating) { | |
| isAnimating = true; | |
| animate(); | |
| console.log("⚡ Visual effects resumed"); | |
| } | |
| }; | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // RESIZE HANDLER | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| function onResize() { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| window.addEventListener("resize", onResize); | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // INITIALIZATION | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| function init() { | |
| if (CONFIG.particles.enabled) { | |
| createParticles(); | |
| } | |
| if (CONFIG.effects.floatingOrbs) { | |
| createOrbs(); | |
| } | |
| // Create stars ⭐ | |
| createStars(); | |
| // Create halos 🔮 | |
| createHalos(); | |
| if (CONFIG.lightning.enabled) { | |
| // First lightning after a short delay | |
| setTimeout(createLightningBolt, 2000); | |
| scheduleLightning(); | |
| } | |
| animate(); | |
| console.log("⚡ Lightning effects initialized!"); | |
| console.log("💙 Particles floating... lo-fi vibes activated"); | |
| console.log("✨ Floating orbs created!"); | |
| console.log("⭐ Twinkling stars added!"); | |
| console.log("🔮 Pulsing halos activated!"); | |
| console.log("🎵 Audio visualizer ready — connect radio to make particles dance!"); | |
| console.log("🎨 Color palettes: focus (blue), short (green), long (purple)"); | |
| } | |
| init(); | |
| })(); | |