Created
December 11, 2024 07:05
-
-
Save bosley/c79b6aca6fc9c6146b67ad0ac4a78fdb to your computer and use it in GitHub Desktop.
chaos-particls-color-spread.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Particle Zen</title> | |
| <style> | |
| /* Ma - embracing meaningful void */ | |
| body, html { | |
| margin: 0; | |
| padding: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: #000; | |
| color: rgba(255, 255, 255, 0.85); | |
| font-family: "Space Mono", monospace; | |
| overflow: hidden; | |
| } | |
| /* Kanso - elimination of clutter */ | |
| #particle-canvas { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| z-index: 1; | |
| } | |
| /* Seijaku - tranquil control panel */ | |
| .control-panel { | |
| position: fixed; | |
| right: 2rem; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| background: rgba(0, 0, 0, 0.2); | |
| backdrop-filter: blur(8px); | |
| padding: 2rem; | |
| border-left: 1px solid rgba(255, 255, 255, 0.1); | |
| z-index: 2; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1.5rem; | |
| min-width: 200px; | |
| } | |
| /* Yugen - mysterious grace in controls */ | |
| .control-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| } | |
| label { | |
| font-size: 0.8rem; | |
| opacity: 0.7; | |
| transition: opacity 0.3s; | |
| } | |
| label:hover { | |
| opacity: 1; | |
| } | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| width: 100%; | |
| height: 2px; | |
| background: rgba(255, 255, 255, 0.2); | |
| outline: none; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 8px; | |
| height: 8px; | |
| background: rgba(255, 255, 255, 0.8); | |
| border-radius: 50%; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| } | |
| input[type="range"]::-webkit-slider-thumb:hover { | |
| transform: scale(1.2); | |
| background: rgba(255, 255, 255, 1); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="particle-canvas"></canvas> | |
| <div class="control-panel"> | |
| <div class="control-group"> | |
| <label for="particle-count">Particle Density</label> | |
| <input type="range" id="particle-count" min="100" max="2000" value="800"> | |
| </div> | |
| <div class="control-group"> | |
| <label for="particle-speed">Flow Speed</label> | |
| <input type="range" id="particle-speed" min="0.1" max="5" step="0.1" value="1"> | |
| </div> | |
| <div class="control-group"> | |
| <label for="connection-distance">Connection Range</label> | |
| <input type="range" id="connection-distance" min="50" max="200" value="100"> | |
| </div> | |
| <div class="control-group"> | |
| <label for="particle-size">Particle Size</label> | |
| <input type="range" id="particle-size" min="1" max="5" step="0.5" value="2"> | |
| </div> | |
| <div class="control-group"> | |
| <label for="max-connections">Max Connections per Particle</label> | |
| <input type="range" id="max-connections" min="1" max="10" value="3"> | |
| </div> | |
| <div class="control-group"> | |
| <label for="max-spread">Max Color Spread</label> | |
| <input type="range" id="max-spread" min="5" max="30" value="15"> | |
| </div> | |
| <div class="control-group"> | |
| <label for="color-lifetime">Color Lifetime</label> | |
| <input type="range" id="color-lifetime" min="1" max="10" step="0.5" value="3"> | |
| </div> | |
| <div class="control-group"> | |
| <label for="spread-speed">Spread Aggressiveness</label> | |
| <input type="range" id="spread-speed" min="0.01" max="0.2" step="0.01" value="0.05"> | |
| </div> | |
| <div class="control-group"> | |
| <label for="colony-size">Colony Survival Size</label> | |
| <input type="range" id="colony-size" min="2" max="8" step="1" value="3"> | |
| </div> | |
| <div class="control-group"> | |
| <label for="join-speed">Join Speed Threshold</label> | |
| <input type="range" id="join-speed" min="0.1" max="1" step="0.05" value="0.2"> | |
| </div> | |
| </div> | |
| <script> | |
| // Particle System with Zen-inspired implementation | |
| class ParticleSystem { | |
| #particles = []; | |
| #ctx = null; | |
| #canvas = null; | |
| #settings = { | |
| count: 800, | |
| speed: 1, | |
| connectionDistance: 100, | |
| size: 2, | |
| colorPropagationSpeed: 0.05, | |
| colorFadeRate: 0.01, | |
| clickEffectRadius: 50, | |
| maxConnectionsPerParticle: 3, | |
| maxColorSpreadCount: 15, | |
| colorLifetime: 3, | |
| spreadSpeed: 0.05, | |
| colonySize: 3, | |
| springStrength: 0.03, // Spring force strength | |
| springDamping: 0.7, // Damping factor for spring oscillations | |
| springLength: 30, // Desired distance between connected particles | |
| collisionRadius: 15, // Radius for collision detection with bundles | |
| bundleThreshold: 4, // Minimum connections to form a bundle | |
| bundlePullStrength: 0.03, // Increased from 0.01 for faster movement | |
| bundleBounceDamping: 0.9, // Energy retention on wall bounce | |
| clusterLifetime: 10, // How long a cluster lives after formation (seconds) | |
| emissionChance: 0.25, // Chance to emit on death | |
| emissionRadius: 50, // Size of emission effect | |
| rippleLifetime: 0.8, // Ripple animation duration in seconds | |
| rippleSize: 100, // Maximum ripple size | |
| rippleWidth: 2, // Width of ripple circle | |
| velocityColorThreshold: 0.2, // Threshold for velocity-based color joining | |
| baseExplosionForce: 2, // Base force for explosions | |
| baseExplosionRadius: 100, // Base explosion radius | |
| densityAbsorptionBonus: 0.1, // How much density increases absorption chance | |
| minAbsorptionChance: 0.2, // Minimum chance to absorb on collision | |
| explosionInfectionChance: 0.05, // 5% chance for explosive infection | |
| explosionColorLinger: 5, // How long explosion-colored particles keep their color | |
| }; | |
| #ripples = []; | |
| constructor() { | |
| this.#initCanvas(); | |
| this.#initParticles(); | |
| this.#bindEvents(); | |
| this.#bindClickEvent(); | |
| this.animate(); | |
| } | |
| #initCanvas() { | |
| this.#canvas = document.getElementById('particle-canvas'); | |
| this.#ctx = this.#canvas.getContext('2d'); | |
| this.#setCanvasSize(); | |
| } | |
| #setCanvasSize() { | |
| this.#canvas.width = window.innerWidth; | |
| this.#canvas.height = window.innerHeight; | |
| } | |
| #initParticles() { | |
| for (let i = 0; i < this.#settings.count; i++) { | |
| this.#particles.push({ | |
| x: Math.random() * this.#canvas.width, | |
| y: Math.random() * this.#canvas.height, | |
| vx: (Math.random() - 0.5) * this.#settings.speed, | |
| vy: (Math.random() - 0.5) * this.#settings.speed, | |
| size: this.#settings.size | |
| }); | |
| } | |
| } | |
| #bindEvents() { | |
| window.addEventListener('resize', () => { | |
| this.#setCanvasSize(); | |
| }); | |
| // Zen-inspired reactive controls | |
| document.getElementById('particle-count').addEventListener('input', (e) => { | |
| const newCount = parseInt(e.target.value); | |
| const diff = newCount - this.#particles.length; | |
| if (diff > 0) { | |
| for (let i = 0; i < diff; i++) { | |
| this.#particles.push({ | |
| x: Math.random() * this.#canvas.width, | |
| y: Math.random() * this.#canvas.height, | |
| vx: (Math.random() - 0.5) * this.#settings.speed, | |
| vy: (Math.random() - 0.5) * this.#settings.speed, | |
| size: this.#settings.size | |
| }); | |
| } | |
| } else { | |
| this.#particles.splice(newCount, Math.abs(diff)); | |
| } | |
| }); | |
| document.getElementById('particle-speed').addEventListener('input', (e) => { | |
| const speed = parseFloat(e.target.value); | |
| this.#particles.forEach(p => { | |
| const angle = Math.atan2(p.vy, p.vx); | |
| const velocity = speed; | |
| p.vx = Math.cos(angle) * velocity; | |
| p.vy = Math.sin(angle) * velocity; | |
| }); | |
| this.#settings.speed = speed; | |
| }); | |
| document.getElementById('connection-distance').addEventListener('input', (e) => { | |
| this.#settings.connectionDistance = parseInt(e.target.value); | |
| }); | |
| document.getElementById('particle-size').addEventListener('input', (e) => { | |
| this.#settings.size = parseFloat(e.target.value); | |
| this.#particles.forEach(p => p.size = this.#settings.size); | |
| }); | |
| document.getElementById('max-connections').addEventListener('input', (e) => { | |
| this.#settings.maxConnectionsPerParticle = parseInt(e.target.value); | |
| }); | |
| document.getElementById('max-spread').addEventListener('input', (e) => { | |
| this.#settings.maxColorSpreadCount = parseInt(e.target.value); | |
| }); | |
| document.getElementById('color-lifetime').addEventListener('input', (e) => { | |
| this.#settings.colorLifetime = parseFloat(e.target.value); | |
| }); | |
| document.getElementById('spread-speed').addEventListener('input', (e) => { | |
| this.#settings.spreadSpeed = parseFloat(e.target.value); | |
| this.#settings.colorPropagationSpeed = this.#settings.spreadSpeed; | |
| }); | |
| document.getElementById('colony-size').addEventListener('input', (e) => { | |
| this.#settings.colonySize = parseInt(e.target.value); | |
| }); | |
| document.getElementById('join-speed').addEventListener('input', (e) => { | |
| this.#settings.velocityColorThreshold = parseFloat(e.target.value); | |
| }); | |
| } | |
| #bindClickEvent() { | |
| this.#canvas.addEventListener('click', (e) => { | |
| const rect = this.#canvas.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| const color = `hsl(${Math.random() * 360}, 80%, 50%)`; | |
| // Create ripple effect | |
| this.#createRipple(x, y, color); | |
| // Existing particle coloring logic | |
| this.#particles.forEach(p => { | |
| const dx = p.x - x; | |
| const dy = p.y - y; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| if (distance < this.#settings.clickEffectRadius) { | |
| p.color = color; | |
| p.colorProgress = 1; | |
| p.colorStartTime = performance.now(); | |
| } | |
| }); | |
| }); | |
| } | |
| #updateParticleColors() { | |
| const currentTime = performance.now(); | |
| // Track bundles that need to explode | |
| const bundlesToExplode = new Map(); | |
| this.#particles.forEach(p => { | |
| if (p.colorProgress > 0) { | |
| if (p.isExplosionColored) { | |
| // Explosion-colored particles fade more quickly | |
| p.colorProgress -= this.#settings.colorFadeRate * 1.5; | |
| } else if (p.inBundle && p.bundleFormedTime) { | |
| const bundleAge = (currentTime - p.bundleFormedTime) / 1000; | |
| if (bundleAge > this.#settings.clusterLifetime) { | |
| if (p.colorProgress > 0.3 && p.colorProgress < 0.35) { | |
| const bundle = this.#findConnectedParticles(p); | |
| if (bundle.size >= this.#settings.bundleThreshold) { | |
| bundlesToExplode.set(p, Array.from(bundle)); | |
| } | |
| } | |
| p.colorProgress -= this.#settings.colorFadeRate * 3; | |
| } | |
| } else if (!p.inBundle && p.colorStartTime && | |
| (currentTime - p.colorStartTime) / 1000 > this.#settings.colorLifetime) { | |
| p.colorProgress -= this.#settings.colorFadeRate * 2; | |
| } else if (!p.inBundle) { | |
| p.colorProgress -= this.#settings.colorFadeRate; | |
| } | |
| if (p.colorProgress <= 0) { | |
| delete p.color; | |
| delete p.colorProgress; | |
| delete p.colorStartTime; | |
| delete p.bundleFormedTime; | |
| delete p.inBundle; | |
| delete p.isExplosionColored; | |
| } | |
| } | |
| }); | |
| bundlesToExplode.forEach((bundle, p) => { | |
| this.#explodeColony(bundle); | |
| }); | |
| } | |
| #countColorSpread(color) { | |
| return this.#particles.reduce((count, p) => { | |
| return count + ((p.color === color || p.incomingColor === color) ? 1 : 0); | |
| }, 0); | |
| } | |
| #checkColonySurvival(particle) { | |
| if (!particle.color) return false; | |
| let colonyCount = 1; // Start with this particle | |
| const checked = new Set([particle]); | |
| const toCheck = [particle]; | |
| // Breadth-first search for connected same-color particles | |
| while (toCheck.length > 0 && colonyCount < this.#settings.colonySize) { | |
| const current = toCheck.shift(); | |
| // Check all particles for connections | |
| this.#particles.forEach(p => { | |
| if (checked.has(p)) return; | |
| const dx = p.x - current.x; | |
| const dy = p.y - current.y; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| if (distance < this.#settings.connectionDistance && | |
| p.color === current.color) { | |
| checked.add(p); | |
| toCheck.push(p); | |
| colonyCount++; | |
| } | |
| }); | |
| } | |
| return colonyCount >= this.#settings.colonySize; | |
| } | |
| #updateSpringForces(bundle) { | |
| bundle.forEach(p1 => { | |
| // Reset accumulated pull direction for this particle | |
| p1.pullX = 0; | |
| p1.pullY = 0; | |
| let connectionCount = 0; | |
| bundle.forEach(p2 => { | |
| if (p1 === p2) return; | |
| const dx = p2.x - p1.x; | |
| const dy = p2.y - p1.y; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| if (distance === 0) return; | |
| // Calculate spring force | |
| const force = (distance - this.#settings.springLength) * this.#settings.springStrength; | |
| const fx = (dx / distance) * force; | |
| const fy = (dy / distance) * force; | |
| // Apply forces with damping | |
| p1.vx += fx; | |
| p1.vy += fy; | |
| p2.vx -= fx; | |
| p2.vy -= fy; | |
| }); | |
| // Calculate pull from external connections | |
| this.#particles.forEach(p2 => { | |
| if (bundle.includes(p2)) return; // Skip bundle members | |
| const dx = p2.x - p1.x; | |
| const dy = p2.y - p1.y; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| if (distance < this.#settings.connectionDistance) { | |
| p1.pullX += dx / distance; | |
| p1.pullY += dy / distance; | |
| connectionCount++; | |
| } | |
| }); | |
| // Apply normalized pull force if there are external connections | |
| if (connectionCount > 0) { | |
| const pullMagnitude = Math.sqrt(p1.pullX * p1.pullX + p1.pullY * p1.pullY); | |
| if (pullMagnitude > 0) { | |
| p1.vx += (p1.pullX / pullMagnitude) * this.#settings.bundlePullStrength; | |
| p1.vy += (p1.pullY / pullMagnitude) * this.#settings.bundlePullStrength; | |
| } | |
| } | |
| // Apply damping only to spring forces, not to directional movement | |
| p1.vx *= this.#settings.springDamping; | |
| p1.vy *= this.#settings.springDamping; | |
| }); | |
| } | |
| #handleBundleCollisions(p, bundles) { | |
| bundles.forEach(bundle => { | |
| // Calculate bundle density (particles per area) | |
| const bundleRadius = this.#getBundleRadius(bundle); | |
| const bundleArea = Math.PI * bundleRadius * bundleRadius; | |
| const density = bundle.length / bundleArea; | |
| // Increase absorption chance based on density | |
| const absorptionBonus = density * this.#settings.densityAbsorptionBonus; | |
| const absorptionChance = Math.min(0.8, this.#settings.minAbsorptionChance + absorptionBonus); | |
| bundle.forEach(bp => { | |
| const dx = p.x - bp.x; | |
| const dy = p.y - bp.y; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| if (distance < this.#settings.collisionRadius) { | |
| const relativeVelocity = Math.sqrt(p.vx * p.vx + p.vy * p.vy); | |
| const baseVelocity = this.#settings.speed; | |
| const velocityRatio = relativeVelocity / baseVelocity; | |
| // Check for absorption based on velocity and density | |
| if (!p.color && (velocityRatio > this.#settings.velocityColorThreshold || | |
| Math.random() < absorptionChance)) { | |
| p.color = bp.color; | |
| p.colorProgress = 1; | |
| p.colorStartTime = performance.now(); | |
| if (bp.bundleFormedTime) { | |
| p.bundleFormedTime = bp.bundleFormedTime; | |
| } | |
| } | |
| // Regular collision response | |
| const angle = Math.atan2(dy, dx); | |
| const targetX = bp.x + Math.cos(angle) * this.#settings.collisionRadius; | |
| const targetY = bp.y + Math.sin(angle) * this.#settings.collisionRadius; | |
| p.x = targetX; | |
| p.y = targetY; | |
| const dot = p.vx * Math.cos(angle) + p.vy * Math.sin(angle); | |
| p.vx = p.vx - 2 * dot * Math.cos(angle); | |
| p.vy = p.vy - 2 * dot * Math.sin(angle); | |
| p.vx *= 0.8; | |
| p.vy *= 0.8; | |
| } | |
| }); | |
| }); | |
| } | |
| #findBundles() { | |
| const bundles = []; | |
| const checked = new Set(); | |
| this.#particles.forEach(p => { | |
| if (checked.has(p) || !p.color) return; | |
| const bundle = this.#findConnectedParticles(p); | |
| if (bundle.size >= this.#settings.bundleThreshold) { | |
| const bundleArray = Array.from(bundle); | |
| const now = performance.now(); | |
| bundleArray.forEach(bp => { | |
| bp.inBundle = true; | |
| bp.vx *= 0.1; | |
| bp.vy *= 0.1; | |
| // Add formation timestamp if not already set | |
| if (!bp.bundleFormedTime) { | |
| bp.bundleFormedTime = now; | |
| } | |
| }); | |
| bundles.push(bundleArray); | |
| } | |
| bundle.forEach(bp => checked.add(bp)); | |
| }); | |
| return bundles; | |
| } | |
| #findConnectedParticles(particle) { | |
| const connected = new Set([particle]); | |
| const toCheck = [particle]; | |
| while (toCheck.length > 0) { | |
| const current = toCheck.shift(); | |
| this.#particles.forEach(p => { | |
| if (connected.has(p) || p.color !== current.color) return; | |
| const dx = p.x - current.x; | |
| const dy = p.y - current.y; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| if (distance < this.#settings.connectionDistance) { | |
| connected.add(p); | |
| toCheck.push(p); | |
| } | |
| }); | |
| } | |
| return connected; | |
| } | |
| #emitFromParticle(particle) { | |
| const color = `hsl(${Math.random() * 360}, 80%, 50%)`; | |
| // Create ripple effect at emission point | |
| this.#createRipple(particle.x, particle.y, color); | |
| // Existing emission logic | |
| this.#particles.forEach(p => { | |
| if (p === particle) return; | |
| const dx = p.x - particle.x; | |
| const dy = p.y - particle.y; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| if (distance < this.#settings.emissionRadius) { | |
| p.color = color; | |
| p.colorProgress = 1; | |
| p.colorStartTime = performance.now(); | |
| const angle = Math.atan2(dy, dx); | |
| const force = 0.5 * (1 - distance / this.#settings.emissionRadius); | |
| p.vx += Math.cos(angle) * force; | |
| p.vy += Math.sin(angle) * force; | |
| } | |
| }); | |
| } | |
| #createRipple(x, y, color) { | |
| this.#ripples.push({ | |
| x, | |
| y, | |
| color, | |
| startTime: performance.now(), | |
| progress: 0 | |
| }); | |
| } | |
| #drawRipples() { | |
| const currentTime = performance.now(); | |
| // Update and draw ripples | |
| this.#ripples = this.#ripples.filter(ripple => { | |
| const age = (currentTime - ripple.startTime) / 1000; | |
| if (age > this.#settings.rippleLifetime) return false; | |
| // Calculate ripple progress (0 to 1) | |
| ripple.progress = age / this.#settings.rippleLifetime; | |
| // Draw ripple | |
| this.#ctx.beginPath(); | |
| this.#ctx.strokeStyle = this.#adjustColorOpacity( | |
| ripple.color, | |
| 0.5 * (1 - ripple.progress) | |
| ); | |
| this.#ctx.lineWidth = this.#settings.rippleWidth * (1 - ripple.progress); | |
| this.#ctx.arc( | |
| ripple.x, | |
| ripple.y, | |
| this.#settings.rippleSize * ripple.progress, | |
| 0, | |
| Math.PI * 2 | |
| ); | |
| this.#ctx.stroke(); | |
| return true; | |
| }); | |
| } | |
| animate = () => { | |
| this.#ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height); | |
| // Draw ripples first (under particles) | |
| this.#drawRipples(); | |
| // Find and update bundles | |
| const bundles = this.#findBundles(); | |
| bundles.forEach(bundle => this.#updateSpringForces(bundle)); | |
| // Update colors first | |
| this.#updateParticleColors(); | |
| this.#particles.forEach((p, i) => { | |
| if (!p.inBundle) { | |
| // Update position only for non-bundle particles | |
| p.x += p.vx; | |
| p.y += p.vy; | |
| // Handle collisions with bundles | |
| this.#handleBundleCollisions(p, bundles); | |
| } else { | |
| // Update bundle particle position without friction | |
| p.x += p.vx; | |
| p.y += p.vy; | |
| } | |
| // Bounce off edges for all particles | |
| if (p.x < 0) { | |
| p.x = 0; | |
| p.vx = Math.abs(p.vx) * this.#settings.bundleBounceDamping; | |
| } | |
| if (p.x > this.#canvas.width) { | |
| p.x = this.#canvas.width; | |
| p.vx = -Math.abs(p.vx) * this.#settings.bundleBounceDamping; | |
| } | |
| if (p.y < 0) { | |
| p.y = 0; | |
| p.vy = Math.abs(p.vy) * this.#settings.bundleBounceDamping; | |
| } | |
| if (p.y > this.#canvas.height) { | |
| p.y = this.#canvas.height; | |
| p.vy = -Math.abs(p.vy) * this.#settings.bundleBounceDamping; | |
| } | |
| // Draw particle with color if it exists | |
| this.#ctx.beginPath(); | |
| if (p.color && p.colorProgress > 0) { | |
| this.#ctx.fillStyle = this.#adjustColorOpacity(p.color, p.colorProgress); | |
| } else { | |
| this.#ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; | |
| } | |
| this.#ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); | |
| this.#ctx.fill(); | |
| // Track connections for this particle | |
| let connectionCount = 0; | |
| // Connect nearby particles with subtle lines and handle color propagation | |
| for (let j = i + 1; j < this.#particles.length; j++) { | |
| const p2 = this.#particles[j]; | |
| const dx = p2.x - p.x; | |
| const dy = p2.y - p.y; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| // Check if we've hit the connection limit for either particle | |
| if (connectionCount >= this.#settings.maxConnectionsPerParticle) { | |
| break; | |
| } | |
| if (distance < this.#settings.connectionDistance) { | |
| this.#ctx.beginPath(); | |
| // Handle color propagation between connected particles | |
| if (p.color && p.colorProgress > 0 && !p2.color) { | |
| if (!p.inBundle) { // Only non-bundle particles can spread color | |
| const currentSpread = this.#countColorSpread(p.color); | |
| if (currentSpread < this.#settings.maxColorSpreadCount) { | |
| if (!p2.incomingColor) { | |
| p2.incomingColor = p.color; | |
| p2.colorProgress = 0; | |
| } else if (p2.incomingColor === p.color) { | |
| p2.colorProgress += this.#settings.spreadSpeed; | |
| if (p2.colorProgress >= 1) { | |
| p2.color = p2.incomingColor; | |
| p2.colorStartTime = performance.now(); | |
| delete p2.incomingColor; | |
| } | |
| } | |
| } | |
| } | |
| } else if (p.color && p2.color && p.color !== p2.color) { | |
| // Colors clash - both lose their color | |
| delete p.color; | |
| delete p2.color; | |
| delete p.colorProgress; | |
| delete p2.colorProgress; | |
| delete p.incomingColor; | |
| delete p2.incomingColor; | |
| } | |
| // Draw connection line with gradient if colors are propagating | |
| if (p.color || p2.color) { | |
| const gradient = this.#ctx.createLinearGradient(p.x, p.y, p2.x, p2.y); | |
| gradient.addColorStop(0, this.#getParticleColor(p)); | |
| gradient.addColorStop(1, this.#getParticleColor(p2)); | |
| this.#ctx.strokeStyle = gradient; | |
| } else { | |
| this.#ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; | |
| } | |
| this.#ctx.moveTo(p.x, p.y); | |
| this.#ctx.lineTo(p2.x, p2.y); | |
| this.#ctx.stroke(); | |
| connectionCount++; | |
| } | |
| } | |
| }); | |
| requestAnimationFrame(this.animate); | |
| } | |
| #adjustColorOpacity(color, opacity) { | |
| // Ensure opacity is between 0 and 1 | |
| opacity = Math.max(0, Math.min(1, opacity)); | |
| if (color.startsWith('hsl')) { | |
| return color.replace('hsl', 'hsla').replace(')', `, ${opacity})`); | |
| } | |
| // Fallback to white with opacity if color is invalid | |
| return `rgba(255, 255, 255, ${opacity})`; | |
| } | |
| #getParticleColor(particle) { | |
| if (particle.color && particle.colorProgress > 0) { | |
| return this.#adjustColorOpacity(particle.color, particle.colorProgress); | |
| } | |
| if (particle.incomingColor && particle.colorProgress >= 0) { | |
| return this.#adjustColorOpacity(particle.incomingColor, particle.colorProgress); | |
| } | |
| // Always return a valid color string | |
| return 'rgba(255, 255, 255, 0.1)'; | |
| } | |
| #explodeColony(bundle) { | |
| const explosionScale = Math.sqrt(bundle.length) / 2; | |
| const explosionRadius = this.#settings.baseExplosionRadius * explosionScale; | |
| const explosionForce = this.#settings.baseExplosionForce * explosionScale; | |
| const center = bundle.reduce((acc, p) => { | |
| acc.x += p.x; | |
| acc.y += p.y; | |
| return acc; | |
| }, { x: 0, y: 0 }); | |
| center.x /= bundle.length; | |
| center.y /= bundle.length; | |
| // Create ripple effect for explosion | |
| this.#createRipple(center.x, center.y, bundle[0].color); | |
| // Determine if this explosion is infectious | |
| const isInfectious = Math.random() < this.#settings.explosionInfectionChance; | |
| this.#particles.forEach(p => { | |
| const dx = p.x - center.x; | |
| const dy = p.y - center.y; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| if (distance < explosionRadius) { | |
| // Apply explosion force | |
| const force = explosionForce * (1 - distance / explosionRadius); | |
| const angle = Math.atan2(dy, dx); | |
| p.vx += Math.cos(angle) * force; | |
| p.vy += Math.sin(angle) * force; | |
| // Only color particles if explosion is infectious | |
| if (isInfectious && Math.random() < 0.3) { | |
| p.color = bundle[0].color; | |
| p.colorProgress = 1; | |
| p.colorStartTime = performance.now(); | |
| p.isExplosionColored = true; // Mark as explosion-colored | |
| } | |
| } | |
| }); | |
| } | |
| #getBundleRadius(bundle) { | |
| if (bundle.length === 0) return 0; | |
| const center = bundle.reduce((acc, p) => { | |
| acc.x += p.x; | |
| acc.y += p.y; | |
| return acc; | |
| }, { x: 0, y: 0 }); | |
| center.x /= bundle.length; | |
| center.y /= bundle.length; | |
| // Find the furthest particle from center | |
| return Math.max(...bundle.map(p => { | |
| const dx = p.x - center.x; | |
| const dy = p.y - center.y; | |
| return Math.sqrt(dx * dx + dy * dy); | |
| })); | |
| } | |
| } | |
| // Initialize the particle system when the DOM is ready | |
| document.addEventListener('DOMContentLoaded', () => { | |
| new ParticleSystem(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment