particle.js
1 "use strict"; 2 3 import { CONFIG } from './config.js'; 4 5 export class Particle { 6 constructor(x, y, centerX, centerY) { 7 this.x = x; 8 this.y = y; 9 this.ox = x; 10 this.oy = y; 11 this.vx = 0; 12 this.vy = 0; 13 14 const dx = x - centerX; 15 const dy = y - centerY; 16 this.distFromCenter = Math.sqrt(dx * dx + dy * dy); 17 this.angle = Math.atan2(dy, dx); 18 19 this.active = false; 20 this.alpha = 0; // Fade alpha for smooth transitions 21 this.growthOffset = Math.random() * 0.15; 22 23 // Organic behavior traits 24 this.nervousness = 0.8 + Math.random() * 0.4; 25 this.awareness = Math.random() * 40 + 25; 26 } 27 28 update(state) { 29 const { 30 renderCursorX, renderCursorY, radiusMultiplier, spiralStrength, outbreaks, 31 anomalies, isMobile, activeParticleRatio, stations, 32 buffRadiusMultiplier, buffForceMultiplier, hasPulsatingBuff, pulseStrength, 33 cursorSpeed, cursorDirX, cursorDirY, now 34 } = state; 35 36 if (!this.active) return; 37 38 const dx = renderCursorX - this.x; 39 const dy = renderCursorY - this.y; 40 const distSq = dx * dx + dy * dy; 41 42 const dynamicRadiusSq = CONFIG.REPULSION_RADIUS ** 2 * radiusMultiplier ** 2 * buffRadiusMultiplier ** 2; 43 44 // Early exit: if particle is far from cursor and all entities, skip expensive calculations 45 const maxInteractionRange = 200; 46 const maxInteractionRangeSq = maxInteractionRange * maxInteractionRange; 47 48 let nearSomething = distSq < dynamicRadiusSq * 4; 49 50 if (!nearSomething && outbreaks.length > 0) { 51 for (const o of outbreaks) { 52 const odx = this.x - o.x; 53 const ody = this.y - o.y; 54 if (odx * odx + ody * ody < maxInteractionRangeSq) { 55 nearSomething = true; 56 break; 57 } 58 } 59 } 60 61 if (!nearSomething && anomalies.length > 0) { 62 for (const a of anomalies) { 63 const adx = this.x - a.x; 64 const ady = this.y - a.y; 65 if (adx * adx + ady * ady < maxInteractionRangeSq) { 66 nearSomething = true; 67 break; 68 } 69 } 70 } 71 72 if (!nearSomething) { 73 // Far from everything: just apply drag and spring back 74 this.vx *= CONFIG.PARTICLE_DRAG; 75 this.vy *= CONFIG.PARTICLE_DRAG; 76 this.x += this.vx + (this.ox - this.x) * CONFIG.PARTICLE_EASE; 77 this.y += this.vy + (this.oy - this.y) * CONFIG.PARTICLE_EASE; 78 return; 79 } 80 81 // Anticipation: only calculate if cursor is moving fast enough 82 if (cursorSpeed > 1.5) { 83 const dynamicRadius = Math.sqrt(dynamicRadiusSq); 84 const awarenessRadiusSq = (this.awareness + dynamicRadius) ** 2; 85 86 if (distSq < awarenessRadiusSq && distSq > dynamicRadiusSq) { 87 const distSqrt = Math.sqrt(distSq); 88 const toParticleX = -dx / distSqrt; 89 const toParticleY = -dy / distSqrt; 90 const alignmentDot = cursorDirX * toParticleX + cursorDirY * toParticleY; 91 92 if (alignmentDot > 0.4) { 93 const anticipationForce = alignmentDot * 0.2 * this.nervousness * buffForceMultiplier; 94 this.vx -= anticipationForce * cursorDirX; 95 this.vy -= anticipationForce * cursorDirY; 96 } 97 } 98 } 99 100 // Main repulsion 101 if (distSq < dynamicRadiusSq) { 102 const safeDist = Math.max(distSq, 1); 103 const baseForce = (-dynamicRadiusSq / safeDist) * CONFIG.REPULSION_STRENGTH * buffForceMultiplier; 104 const force = baseForce * this.nervousness; 105 106 // Use normalized vector instead of angle + trig 107 const distSqrt = Math.sqrt(distSq); 108 const normX = dx / distSqrt; 109 const normY = dy / distSqrt; 110 111 this.vx += force * normX; 112 this.vy += force * normY; 113 114 // Spiral: perpendicular to normalized direction 115 const dynamicRadius = Math.sqrt(dynamicRadiusSq); 116 const spiralForce = (force * spiralStrength * distSqrt) / dynamicRadius; 117 this.vx -= spiralForce * normY; // Perpendicular 118 this.vy += spiralForce * normX; 119 } 120 121 if (hasPulsatingBuff) { 122 const particleSpacing = isMobile ? CONFIG.SPACING_MOBILE : CONFIG.SPACING_DESKTOP; 123 const waveDistance = particleSpacing * 4; 124 const waveRadius = Math.sqrt(dynamicRadiusSq) + waveDistance; 125 const waveRadiusSq = waveRadius * waveRadius; 126 const waveThickness = particleSpacing * 6; 127 128 // Use squared distance for initial check 129 const distFromWaveSq = Math.abs(distSq - waveRadiusSq); 130 const waveThicknessSq = waveThickness * waveThickness; 131 132 if (distFromWaveSq < waveThicknessSq) { 133 const distSqrt = Math.sqrt(distSq); 134 const normX = dx / distSqrt; 135 const normY = dy / distSqrt; 136 const normalizedPulse = (pulseStrength) * 2 - 1; 137 const proximityFactor = 1.0 - (Math.sqrt(distFromWaveSq) / waveThickness); 138 const waveForce = normalizedPulse * proximityFactor * 8.0; 139 this.vx += waveForce * normX; 140 this.vy += waveForce * normY; 141 } 142 } 143 144 for (const o of outbreaks) { 145 const odx = this.x - o.x; 146 const ody = this.y - o.y; 147 const oDistSq = odx * odx + ody * ody; 148 149 // Simplified organic variation (removed one trig call) 150 const morphPulse = Math.sin(o.frame * 0.05) * 0.05 + 1.0; 151 const angleApprox = Math.atan2(ody, odx); // Only one atan2 152 const tentacleVariation = Math.sin(angleApprox * 3 + o.frame * 0.03) * 0.08; 153 const organicRadius = o.radius * morphPulse * (1.0 + tentacleVariation); 154 const currentRadiusSq = organicRadius ** 2; 155 156 if (o.frame < 360) { 157 const edgeThickness = 18; 158 const innerRadiusSq = Math.max(0, organicRadius - edgeThickness) ** 2; 159 if (oDistSq < currentRadiusSq && oDistSq > innerRadiusSq) { 160 const oDist = Math.sqrt(oDistSq); 161 const edgePos = (oDist - (organicRadius - edgeThickness)) / edgeThickness; 162 const force = (1.0 - edgePos) * 2.0; 163 // Use normalized vector 164 const normX = odx / oDist; 165 const normY = ody / oDist; 166 this.vx += force * normX; 167 this.vy += force * normY; 168 } 169 } else { 170 const pullAge = o.frame - 360; 171 const initialProgress = Math.min(pullAge / 1800, 1.0); 172 const continuousGrowth = Math.min(pullAge / 5400, 1.2); 173 const baseMultiplier = CONFIG.OUTBREAK_PULL_RADIUS_MIN + (CONFIG.OUTBREAK_PULL_RADIUS_MAX - CONFIG.OUTBREAK_PULL_RADIUS_MIN) * initialProgress; 174 const pullRadiusMultiplier = baseMultiplier + continuousGrowth * 1.67; 175 const pullRadius = organicRadius * pullRadiusMultiplier; 176 const pullRadiusSq = pullRadius ** 2; 177 178 if (oDistSq < pullRadiusSq) { 179 const oDist = Math.sqrt(oDistSq); 180 let pullStrength = Math.min(pullAge / 1200, 1.0) * 1.2; 181 pullStrength *= 1.0 + continuousGrowth * 0.3; 182 pullStrength *= 1.0 + (1.0 - activeParticleRatio) * 0.8; 183 const distanceFactor = 1.0 - Math.min(oDist / pullRadius, 1.0); 184 const blackHoleForce = pullStrength * (1.0 + distanceFactor * 1.4); 185 // Use normalized vector 186 const normX = odx / oDist; 187 const normY = ody / oDist; 188 this.vx -= blackHoleForce * normX; 189 this.vy -= blackHoleForce * normY; 190 } 191 } 192 } 193 194 // "Asteroid" Station Logic 195 for (const s of stations) { 196 if (s.isCapturing && s.captureTarget) { 197 const tdx = s.captureTarget.x - this.x; 198 const tdy = s.captureTarget.y - this.y; 199 if (tdx * tdx + tdy * tdy < 150 * 150) { 200 this.vx += tdx * 0.03; 201 this.vy += tdy * 0.03; 202 } 203 } else { 204 const dx = this.x - s.x; 205 const dy = this.y - s.y; 206 const stationDistSq = dx * dx + dy * dy; 207 const radius = 25 + Math.sin(this.angle * 5) * 5; // Irregular shape 208 const radiusSq = radius * radius; 209 210 if (stationDistSq < radiusSq) { 211 this.vx += (s.x - this.x) * 0.1; 212 this.vy += (s.y - this.y) * 0.1; 213 } 214 } 215 } 216 217 for (const a of anomalies) { 218 const adx = this.x - a.x; 219 const ady = this.y - a.y; 220 const aDistSq = adx * adx + ady * ady; 221 const vortexRadius = a.vortexRadius || CONFIG.ANOMALY_VORTEX_RADIUS; 222 const vortexRadiusSq = vortexRadius * vortexRadius; 223 224 if (aDistSq < vortexRadiusSq) { 225 const aDist = Math.sqrt(aDistSq); 226 const falloff = 1.0 - aDist / vortexRadius; 227 let vortexStrength = (a.vortexStrength || CONFIG.ANOMALY_VORTEX_STRENGTH) * falloff; 228 229 if (a.vortexBoostEndTime && now < a.vortexBoostEndTime) { 230 vortexStrength *= 1.5; 231 } 232 233 // Optimize synergy check: use squared distance to avoid sqrt 234 const synergyRangeSq = CONFIG.SYNERGY_RANGE * CONFIG.SYNERGY_RANGE; 235 for (const v of outbreaks) { 236 const vdx = v.x - a.x; 237 const vdy = v.y - a.y; 238 if (vdx * vdx + vdy * vdy < synergyRangeSq) { 239 vortexStrength *= v.maxed ? CONFIG.SYNERGY_VORTEX_BOOST * 1.4 : CONFIG.SYNERGY_VORTEX_BOOST; 240 break; 241 } 242 } 243 244 // Use normalized vector instead of angle + trig 245 const normX = adx / aDist; 246 const normY = ady / aDist; 247 this.vx -= (vortexStrength * 0.3) * normX; 248 this.vy -= (vortexStrength * 0.3) * normY; 249 250 // Perpendicular (tangential) force 251 this.vx -= (vortexStrength * 1.2) * normY; // Perpendicular 252 this.vy += (vortexStrength * 1.2) * normX; 253 } 254 } 255 this.vx *= CONFIG.PARTICLE_DRAG; 256 this.vy *= CONFIG.PARTICLE_DRAG; 257 this.x += this.vx + (this.ox - this.x) * CONFIG.PARTICLE_EASE; 258 this.y += this.vy + (this.oy - this.y) * CONFIG.PARTICLE_EASE; 259 } 260 }