/ js / main.js
main.js
  1  "use strict";
  2  
  3  import { CONFIG } from './config.js';
  4  import { Particle } from './particle.js';
  5  import { updateOutbreaks, updateAnomalies, updateStations } from './entities.js';
  6  import DebugPanel from './debug-panel.js';
  7  
  8  const state = {
  9      container: null, canvas: null, ctx: null,
 10      list: [], w: 0, h: 0, centerX: 0, centerY: 0,
 11      isMobile: false,
 12      cursorX: 0, cursorY: 0, cursorVx: 0, cursorVy: 0,
 13      targetX: 0, targetY: 0, pathTime: 0,
 14      isManual: false, manualTimeout: null,
 15      radiusMultiplier: 1.0, transitionBlend: 0, transitionStartTime: 0,
 16      pathRadiusVariation: 0, pathSpeedVariation: 0, pathStartOffset: 0,
 17      wobblePhaseX: 0, wobblePhaseY: 0,
 18      disruptionActive: false, disruptionIntensity: 0, disruptionCenterX: 0, disruptionCenterY: 0,
 19      disruptionTarget: null, disruptionSecondaryTarget: null, nextDisruptionTime: 0,
 20      outbreaks: [], nextOutbreakTime: 0,
 21      anomalies: [], nextAnomalyTime: 0,
 22      stations: [], nextStationTime: 0,
 23      cursorBuffs: {
 24          shieldActive: false, shieldEndTime: 0,
 25          damageBoostActive: false, damageBoostEndTime: 0,
 26          blackHoleKillBoostActive: false, blackHoleKillBoostEndTime: 0,
 27      },
 28      startTime: 0, lastFrameTime: 0, frameToggle: true,
 29      maxParticleDistance: 0, activeParticleRatio: 0,
 30      imageData: null, imageDataBuffer: null,
 31      debugPanel: null, screenSizeMultiplier: 1.0,
 32      frameCount: 0, fps: 0, lastFpsUpdate: 0,
 33      resizeTimeout: null,
 34      lastPhysicsTime: 0,
 35      debugParticleVisibility: 0, // 0 = auto, 1-100 = force percentage
 36      renderCursorX: 0, renderCursorY: 0, // Smooth 60fps cursor position
 37      lastWidth: 0, lastHeight: 0, // Track real resizes
 38  };
 39  
 40  // Heavy cursor calculations (physics frames only)
 41  function updateCursorForces(deltaTime) {
 42  	const elapsed = Date.now() - state.startTime;
 43  	const speedProgress = Math.min(elapsed / CONFIG.CURSOR_SPEED_DURATION, 1);
 44      const { w, h, centerX, centerY, outbreaks, anomalies, cursorBuffs, isMobile, activeParticleRatio, isManual } = state;
 45  
 46  	let hasLargeBlackHole = false;
 47  	for (const o of outbreaks) {
 48  		if (o.frame < 360) continue;
 49  		const pullAge = o.frame - 360;
 50  		const initialProgress = Math.min(pullAge / 1800, 1.0);
 51  		const continuousGrowth = Math.min(pullAge / 5400, 1.2);
 52  		const pullRadius = o.radius * (CONFIG.OUTBREAK_PULL_RADIUS_MIN + (CONFIG.OUTBREAK_PULL_RADIUS_MAX - CONFIG.OUTBREAK_PULL_RADIUS_MIN) * initialProgress + continuousGrowth * 1.67);
 53  		if (pullRadius >= Math.sqrt(w * w + h * h) * 0.7) {
 54  			hasLargeBlackHole = true;
 55  			break;
 56  		}
 57  	}
 58  
 59  	if (!state.disruptionActive && Date.now() >= state.nextDisruptionTime && activeParticleRatio > 0.3 && hasLargeBlackHole) {
 60  		state.disruptionActive = true;
 61  		state.disruptionIntensity = 0;
 62  		state.disruptionCenterX = state.cursorX;
 63  		state.disruptionCenterY = state.cursorY;
 64          // Targeting logic...
 65          setTimeout(() => {
 66              state.disruptionActive = false;
 67              state.disruptionTarget = null;
 68              state.disruptionSecondaryTarget = null;
 69          }, 3000);
 70          let nextDisruptionDelay = 20000;
 71          if (activeParticleRatio < 0.5) {
 72              const dominanceFactor = (0.5 - activeParticleRatio) / 0.5;
 73              nextDisruptionDelay = Math.max(5000, nextDisruptionDelay - (dominanceFactor**2 * 15000));
 74          }
 75          state.nextDisruptionTime = Date.now() + 3000 + nextDisruptionDelay;
 76  	}
 77  
 78  	if (state.disruptionActive) state.disruptionIntensity = Math.min(1.0, state.disruptionIntensity + 0.02);
 79  	else state.disruptionIntensity = Math.max(0.0, state.disruptionIntensity - 0.015);
 80  
 81  	let speed = (CONFIG.CURSOR_PATH_SPEED_MIN + (CONFIG.CURSOR_PATH_SPEED_MAX - CONFIG.CURSOR_PATH_SPEED_MIN) * (speedProgress ** 3)) / 100;
 82  	speed *= state.pathSpeedVariation;
 83  
 84      let activityBoost;
 85      if (isMobile) {
 86          if (activeParticleRatio < 0.5) activityBoost = 1.0 + activeParticleRatio * 0.3;
 87          else if (activeParticleRatio < 0.7) activityBoost = 1.15 - ((activeParticleRatio - 0.5) / 0.2) * 0.25;
 88          else activityBoost = 0.9 - ((activeParticleRatio - 0.7) / 0.3) * 0.3;
 89      } else {
 90          // Desktop: faster base speed
 91          if (activeParticleRatio < 0.5) activityBoost = 1.1 + activeParticleRatio * 0.3;
 92          else if (activeParticleRatio < 0.7) activityBoost = 1.25 - ((activeParticleRatio - 0.5) / 0.2) * 0.25;
 93          else activityBoost = 1.0 - ((activeParticleRatio - 0.7) / 0.3) * 0.3;
 94      }
 95      speed *= activityBoost;
 96  
 97  	if (state.disruptionIntensity > 0.3) speed *= 0.6 - state.disruptionIntensity * 0.2;
 98  
 99  	if (!isManual && state.transitionBlend < 1.0) {
100  		state.transitionBlend = Math.min((Date.now() - state.transitionStartTime) / 2500, 1.0);
101  	} else if (isManual) {
102  		state.transitionBlend = 0;
103  	}
104  
105  	if (!state.isManual) {
106  		state.pathTime += deltaTime * speed;
107  		const radius = (isMobile ? 0.75 : CONFIG.CURSOR_PATH_RADIUS) * state.pathRadiusVariation;
108  		const wobbleX = Math.sin((elapsed + state.wobblePhaseX) * 0.0004) * w * 0.08;
109  		const wobbleY = Math.cos((elapsed + state.wobblePhaseY) * 0.0005) * h * 0.08;
110  		let baseTargetX = centerX + Math.sin(state.pathTime + state.pathStartOffset) * w * radius + wobbleX;
111  		let baseTargetY = centerY + Math.cos((state.pathTime + state.pathStartOffset) * 0.8) * h * radius + wobbleY;
112  		state.targetX = baseTargetX;
113  		state.targetY = baseTargetY;
114  
115  		const damping = (Math.sin(elapsed * 0.0012) * 0.5 + 0.5) < 0.2 ? 0.95 : 0.88;
116  		const movementStrength = 0.1 + state.transitionBlend * 0.1;
117  		state.cursorVx = (state.targetX - state.cursorX) * movementStrength;
118  		state.cursorVy = (state.targetY - state.cursorY) * movementStrength;
119  		state.cursorVx *= damping;
120  		state.cursorVy *= damping;
121  	} else {
122          const dx = state.targetX - state.cursorX;
123          const dy = state.targetY - state.cursorY;
124          state.cursorVx += dx * CONFIG.CURSOR_SPRING;
125          state.cursorVy += dy * CONFIG.CURSOR_SPRING;
126          state.cursorVx *= CONFIG.CURSOR_DAMPING;
127          state.cursorVy *= CONFIG.CURSOR_DAMPING;
128      }
129  
130      let anomalyShieldStrength = 0;
131      for (const a of anomalies) {
132          const aDist = Math.sqrt((a.x - state.cursorX)**2 + (a.y - state.cursorY)**2);
133          if (aDist < 120) anomalyShieldStrength = Math.max(anomalyShieldStrength, (1.0 - aDist / 120) * 0.7);
134      }
135      if (cursorBuffs.shieldActive) anomalyShieldStrength = Math.max(anomalyShieldStrength, 0.5);
136  
137      for (const o of outbreaks) {
138          if (o.frame < 360) continue;
139          const dx = o.x - state.cursorX;
140          const dy = o.y - state.cursorY;
141          const dist = Math.sqrt(dx * dx + dy * dy);
142          const pullAge = o.frame - 360;
143          const initialProgress = Math.min(pullAge / 1200, 1.0);
144          const continuousGrowth = Math.min(pullAge / 3600, 1.5);
145          const pullRadius = o.radius * (CONFIG.OUTBREAK_PULL_RADIUS_MIN + (CONFIG.OUTBREAK_PULL_RADIUS_MAX - CONFIG.OUTBREAK_PULL_RADIUS_MIN) * initialProgress + continuousGrowth * 1.5);
146          if (dist < pullRadius && dist > 30) {
147              let pullStrength = Math.min(pullAge / 600, 1.0) * (state.isManual ? 0.5 : 0.4);
148              pullStrength *= 1.0 + continuousGrowth * 0.5;
149              const distanceFactor = 1.0 - Math.min(dist / pullRadius, 1.0);
150              const gravityForce = pullStrength * (1.0 + distanceFactor * (state.isManual ? 1.2 : 1.0)) * (1.0 - anomalyShieldStrength);
151              state.cursorVx += (dx / dist) * gravityForce;
152              state.cursorVy += (dy / dist) * gravityForce;
153          }
154      }
155  
156      let particleScaling = 1.0;
157      if (activeParticleRatio < 0.4) particleScaling = 1.0 + (activeParticleRatio / 0.4) * (isMobile ? 0.25 : 0.3);
158      else particleScaling = 1.0 + (isMobile ? 0.25 : 0.3) - ((activeParticleRatio - 0.4) / 0.6) * 0.65;
159      if (state.disruptionIntensity > 0) particleScaling *= 1.0 + state.disruptionIntensity * 0.6;
160      const radiusPulse = Math.sin(elapsed * 0.0012) * 0.18;
161      const targetRadius = particleScaling * (1.0 + radiusPulse) * (isMobile ? 0.7 : 1.0); // Mobile: 70% cursor size
162      state.radiusMultiplier += (targetRadius - state.radiusMultiplier) * 0.1;
163  }
164  
165  // Light cursor position update (every frame for smooth 60fps)
166  function updateCursorPosition() {
167  	// Calculate particle resistance when moving manually
168  	if (state.isManual) {
169  		let resistanceForce = 0;
170  		const cursorSpeed = Math.sqrt(state.cursorVx * state.cursorVx + state.cursorVy * state.cursorVy);
171  
172  		if (cursorSpeed > 0.1) {
173  			// Check nearby particles for resistance
174  			const checkRadius = CONFIG.REPULSION_RADIUS * state.radiusMultiplier * 1.5;
175  			const checkRadiusSq = checkRadius * checkRadius;
176  			let nearbyCount = 0;
177  
178  			for (let i = 0; i < state.list.length; i++) {
179  				const p = state.list[i];
180  				if (!p.active) continue;
181  
182  				const dx = p.x - state.cursorX;
183  				const dy = p.y - state.cursorY;
184  				const distSq = dx * dx + dy * dy;
185  
186  				if (distSq < checkRadiusSq) {
187  					nearbyCount++;
188  					if (nearbyCount > 20) break; // Cap for performance
189  				}
190  			}
191  
192  			// More particles = more resistance, but scale down on bigger screens
193  			// Mobile: max 40% slowdown, Desktop: max 25% slowdown
194  			const maxResistance = state.isMobile ? 0.4 : 0.25;
195  			resistanceForce = Math.min(nearbyCount / 50, maxResistance);
196  		}
197  
198  		state.cursorVx *= (1.0 - resistanceForce);
199  		state.cursorVy *= (1.0 - resistanceForce);
200  	}
201  
202  	// Skip position update on mobile when manual - position is set directly in handleInput
203  	if (state.isMobile && state.isManual) return;
204  
205  	// Scale velocity by inverse of screen size - bigger screens = slower cursor
206  	const speedScale = Math.max(0.5, 1.0 / state.screenSizeMultiplier);
207  	state.cursorX += state.cursorVx * speedScale;
208  	state.cursorY += state.cursorVy * speedScale;
209  }
210  
211  function draw() {
212      const { ctx, w, h, list, imageDataBuffer, imageData, outbreaks, stations } = state;
213  	// Clear buffer to opaque black (0xFF000000 = alpha 255, RGB 0,0,0)
214  	imageDataBuffer.fill(0xFF000000);
215  
216  	// Pre-compute outbreak visibility data (only mature outbreaks affect visibility)
217  	const matureOutbreaks = [];
218  	for (const o of outbreaks) {
219  		if (o.frame < 360) continue;
220  		const pullAge = o.frame - 360;
221  		const initialProgress = Math.min(pullAge / 1200, 1.0);
222  		const continuousGrowth = Math.min(pullAge / 3600, 1.5);
223  		const pullRadius = o.radius * (CONFIG.OUTBREAK_PULL_RADIUS_MIN + (CONFIG.OUTBREAK_PULL_RADIUS_MAX - CONFIG.OUTBREAK_PULL_RADIUS_MIN) * initialProgress + continuousGrowth * 1.5);
224  		matureOutbreaks.push({ x: o.x, y: o.y, pullRadius, pullRadiusSq: pullRadius * pullRadius });
225  	}
226  
227  	// Pre-compute station positions
228  	const stationPositions = [];
229  	for (const s of stations) {
230  		if (s.isCapturing) continue;
231  		const p1x = s.x + Math.cos(s.orbitAngle) * s.orbitRadius;
232  		const p1y = s.y + Math.sin(s.orbitAngle) * s.orbitRadius;
233  		const p2x = s.x - Math.cos(s.orbitAngle) * s.orbitRadius;
234  		const p2y = s.y - Math.sin(s.orbitAngle) * s.orbitRadius;
235  		stationPositions.push({ p1x, p1y, p2x, p2y });
236  	}
237  
238  	for (let i = 0; i < list.length; i++) {
239  		const p = list[i];
240  		if (!p.active || p.alpha === 0) continue;
241  
242          let color = CONFIG.COLOR;
243          let visibility = p.alpha; // Apply alpha fade
244  
245          // Check visibility dimming from mature outbreaks
246          for (let j = 0; j < matureOutbreaks.length; j++) {
247  			const o = matureOutbreaks[j];
248              const dx = p.x - o.x;
249              const dy = p.y - o.y;
250  			const distSq = dx * dx + dy * dy;
251              if (distSq < o.pullRadiusSq) {
252  				const dist = Math.sqrt(distSq);
253                  const fadeProgress = 1.0 - dist / o.pullRadius;
254                  let dimming = (fadeProgress < 0.6) ? 0.2 + (fadeProgress / 0.6) * 0.3 : 0.5 - ((fadeProgress - 0.6) / 0.4) * 0.3;
255                  visibility = Math.min(visibility, 1.0 - dimming);
256              }
257          }
258          color *= visibility;
259  
260          // Check if particle is part of a station
261  		for (let j = 0; j < stationPositions.length; j++) {
262  			const s = stationPositions[j];
263              if (((p.x - s.p1x)**2 + (p.y - s.p1y)**2 < 144) || ((p.x - s.p2x)**2 + (p.y - s.p2y)**2 < 144)) {
264                  color = 255;
265  				break;
266              }
267          }
268  
269  		if (p.x > 0 && p.x < w && p.y > 0 && p.y < h) {
270  			const index = (Math.floor(p.y) * w + Math.floor(p.x));
271  			imageDataBuffer[index] = (255 << 24) | (color << 16) | (color << 8) | color;
272  		}
273  	}
274  	ctx.putImageData(imageData, 0, 0);
275  }
276  
277  function updateGrowth() {
278      // Debug mode: override particle visibility with slider value
279      if (state.debugParticleVisibility > 0) {
280          const targetRatio = state.debugParticleVisibility / 100;
281          const targetCount = Math.floor(state.list.length * targetRatio);
282  
283          // Sort particles by distance from center and activate closest ones
284          const sorted = [...state.list].sort((a, b) => a.distFromCenter - b.distFromCenter);
285  
286          for (let i = 0; i < state.list.length; i++) {
287              state.list[i].active = false;
288              state.list[i].alpha = 0; // Set alpha for fade system
289          }
290  
291          for (let i = 0; i < targetCount; i++) {
292              sorted[i].active = true;
293              sorted[i].alpha = 1.0; // Full alpha for visible particles
294          }
295  
296          state.activeParticleRatio = targetRatio;
297          return;
298      }
299  
300      const elapsed = Date.now() - state.startTime;
301      const progress = Math.min(elapsed / CONFIG.GROWTH_DURATION, 1);
302      let radiusProgress;
303      if (progress < 0.6) {
304          const easedProgress = 1 - (1 - progress / 0.6)**3;
305          radiusProgress = CONFIG.GROWTH_START_RADIUS + easedProgress * (0.68 - CONFIG.GROWTH_START_RADIUS);
306      } else {
307          const wave = Math.sin((elapsed - CONFIG.GROWTH_DURATION * 0.6) * 0.0003) * 0.5 + 0.5;
308          // Desktop: limit max size, Mobile: full size
309          const maxSize = state.isMobile ? 0.70 : 0.55;
310          const waveAmount = state.isMobile ? 0.15 : 0.10;
311          radiusProgress = (maxSize - waveAmount) + wave * waveAmount;
312      }
313      const baseThreshold = state.maxParticleDistance * radiusProgress;
314      const time = elapsed * 0.001;
315      const timeA = time * 0.5;
316      const timeB = time * 0.8;
317  
318      // Manual cursor trailing effect
319      const cursorDx = state.cursorX - state.centerX;
320      const cursorDy = state.cursorY - state.centerY;
321      const cursorAngle = Math.atan2(cursorDy, cursorDx);
322  
323      let activeCount = 0;
324      for (let i = 0; i < state.list.length; i++) {
325          const p = state.list[i];
326          // Very subtle directional growth with more noise to avoid stripes
327          const directionalGrowth = Math.sin(p.angle * 3 + timeA) * 0.04 + Math.sin(p.angle * 7 - timeB) * 0.03;
328          const noiseA = Math.sin(p.angle * 11 + time * 0.3) * 0.03;
329          const noiseB = Math.cos(p.angle * 13 - time * 0.4) * 0.02;
330  
331          // Cursor trailing: particles behind cursor direction activate more easily
332          let trailingBonus = 0;
333          if (state.isManual) {
334              const angleDiff = Math.abs(p.angle - cursorAngle);
335              const normalizedAngle = Math.min(angleDiff, Math.PI * 2 - angleDiff);
336              if (normalizedAngle > Math.PI * 0.5) { // Particles behind cursor
337                  trailingBonus = (normalizedAngle - Math.PI * 0.5) / (Math.PI * 0.5) * 0.15;
338              }
339          }
340  
341          const threshold = baseThreshold * (1 + directionalGrowth + noiseA + noiseB + trailingBonus + p.growthOffset);
342          const shouldBeActive = p.distFromCenter <= threshold;
343  
344          // Smooth alpha fading instead of instant on/off
345          if (shouldBeActive) {
346              if (!p.active) p.active = true;
347              // Gradual fade in
348              p.alpha = Math.min(1.0, p.alpha + 0.02);
349          } else {
350              // Gradual fade out (even slower)
351              const buffer = baseThreshold * 0.02;
352              if (p.distFromCenter > threshold + buffer) {
353                  p.alpha = Math.max(0, p.alpha - 0.01);
354                  // Only deactivate after fully faded
355                  if (p.alpha === 0) p.active = false;
356              }
357          }
358  
359          if (p.active && p.alpha > 0) activeCount++;
360      }
361      state.activeParticleRatio = activeCount / state.list.length;
362  }
363  
364  function loop() {
365  	const now = Date.now();
366  	const deltaTime = (now - state.lastFrameTime) / 1000;
367  	state.lastFrameTime = now;
368  
369  	// Run physics at 40fps (every 25ms)
370  	const physicsInterval = 1000 / 40;
371  	const shouldUpdatePhysics = (now - state.lastPhysicsTime) >= physicsInterval;
372  
373  	if (shouldUpdatePhysics) {
374  		state.lastPhysicsTime = now;
375  		const elapsed = now - state.startTime;
376  		state.spiralStrength = CONFIG.SPIRAL_STRENGTH_BASE + elapsed * CONFIG.SPIRAL_TIGHTENING_RATE;
377  
378  		// Pre-calculate expensive values ONCE per frame instead of per particle
379  		const cursorSpeedSq = state.cursorVx * state.cursorVx + state.cursorVy * state.cursorVy;
380  		state.cursorSpeed = Math.sqrt(cursorSpeedSq);
381  		state.cursorDirX = state.cursorSpeed > 0 ? state.cursorVx / state.cursorSpeed : 0;
382  		state.cursorDirY = state.cursorSpeed > 0 ? state.cursorVy / state.cursorSpeed : 0;
383  
384  		// Pre-calculate buff multipliers and pulse values ONCE
385  		const manualReduction = state.isManual ? 0.3 : 1.0;
386  		state.buffRadiusMultiplier = 1.0;
387  		state.buffForceMultiplier = 1.0;
388  		state.hasPulsatingBuff = false;
389  		state.pulseStrength = 0;
390  
391  		if (state.cursorBuffs.shieldActive) {
392  			state.hasPulsatingBuff = true;
393  			const pulse = Math.sin(now * 0.008) * 0.5 + 0.5;
394  			state.pulseStrength = pulse * 12.0 * manualReduction;
395  			state.buffRadiusMultiplier = 1.0 + (0.8 * manualReduction);
396  		}
397  
398  		if (state.cursorBuffs.damageBoostActive) {
399  			state.hasPulsatingBuff = true;
400  			const pulse = Math.sin(now * 0.014) * 0.5 + 0.5;
401  			state.pulseStrength = Math.max(state.pulseStrength, pulse * 15.0 * manualReduction);
402  			state.buffRadiusMultiplier = Math.max(state.buffRadiusMultiplier, 1.0 + (1.0 * manualReduction));
403  		}
404  
405  		if (state.cursorBuffs.blackHoleKillBoostActive) {
406  			state.hasPulsatingBuff = true;
407  			const pulse = Math.sin(now * 0.018) * 0.5 + 0.5;
408  			state.pulseStrength = pulse * 25.0 * manualReduction;
409  			state.buffRadiusMultiplier = 1.0 + (1.5 * manualReduction);
410  		}
411  
412  		state.now = now; // Pass timestamp to particles
413  
414  		updateCursorForces(deltaTime);
415  		updateCursorPosition();
416  		updateGrowth();
417  		updateOutbreaks(state);
418  		updateAnomalies(state);
419  		updateStations(state);
420  		for (const p of state.list) {
421  			p.update(state);
422  		}
423  	}
424  
425  	// Smooth cursor interpolation for 60fps visuals
426  	const alpha = 0.3; // Smoothing factor
427  	state.renderCursorX += (state.cursorX - state.renderCursorX) * alpha;
428  	state.renderCursorY += (state.cursorY - state.renderCursorY) * alpha;
429  
430  	draw();
431  	if (state.debugPanel) updateDebugPanel();
432  	requestAnimationFrame(loop);
433  }
434  
435  function init() {
436      state.isMobile = window.innerWidth <= 768 || window.matchMedia("(orientation: portrait)").matches;
437      const containerId = state.isMobile ? "container-mobile" : "container";
438      const newContainer = document.getElementById(containerId);
439      if (!newContainer) return;
440  
441      // Remove canvas from old container if switching between mobile/desktop
442      if (state.container && state.container !== newContainer && state.canvas) {
443          state.container.removeChild(state.canvas);
444      }
445      state.container = newContainer;
446  
447      if (!state.canvas) {
448          state.canvas = document.createElement("canvas");
449          state.ctx = state.canvas.getContext("2d", { alpha: false });
450      }
451      const vw = window.innerWidth;
452      const vh = window.innerHeight;
453      state.w = state.canvas.width = vw;
454      state.h = state.canvas.height = state.isMobile ? 600 : vh;
455      state.centerX = state.w * 0.5;
456      state.centerY = state.h * 0.5;
457      state.screenSizeMultiplier = Math.max(0.7, Math.min(1.5, Math.sqrt((state.w * state.h) / (1920 * 1080))));
458  
459      const spacing = state.isMobile ? CONFIG.SPACING_MOBILE : CONFIG.SPACING_DESKTOP;
460      const marginH = state.isMobile ? state.w * 0.05 : Math.min(vw, vh) * 0.15;
461      const marginV = state.isMobile ? state.w * 0.05 : Math.min(vw, vh) * 0.05;
462      const cols = Math.floor((state.w - marginH * 2) / spacing);
463      const rows = Math.floor((state.h - marginV * 2) / spacing);
464      const offsetX = (state.w - cols * spacing) / 2;
465      const offsetY = (state.h - rows * spacing) / 2;
466  
467      state.list = [];
468      for (let row = 0; row < rows; row++) {
469          for (let col = 0; col < cols; col++) {
470              state.list.push(new Particle(offsetX + col * spacing, offsetY + row * spacing, state.centerX, state.centerY));
471          }
472      }
473      state.maxParticleDistance = Math.max(...state.list.map(p => p.distFromCenter));
474      state.cursorX = state.targetX = state.centerX;
475      state.cursorY = state.targetY = state.centerY;
476      state.renderCursorX = state.centerX;
477      state.renderCursorY = state.centerY;
478      state.pathRadiusVariation = 0.8 + Math.random() * 0.4;
479      state.pathSpeedVariation = 0.9 + Math.random() * 0.2;
480      state.pathStartOffset = Math.random() * Math.PI * 2;
481      state.wobblePhaseX = Math.random() * 10000;
482      state.wobblePhaseY = Math.random() * 10000;
483      state.startTime = state.lastFrameTime = state.lastPhysicsTime = Date.now();
484      state.nextOutbreakTime = state.startTime + (state.isMobile ? 15000 : 12000);
485      state.nextAnomalyTime = state.startTime + (state.isMobile ? 20000 : 10000);
486      state.nextStationTime = state.startTime + CONFIG.STATION_SPAWN_MIN;
487      state.nextDisruptionTime = state.startTime + 45000;
488  
489      // Append canvas if not already in container
490      if (!state.container.contains(state.canvas)) {
491          state.container.appendChild(state.canvas);
492      }
493  
494      // Pre-allocate imageData buffer once
495      state.imageData = state.ctx.createImageData(state.w, state.h);
496      state.imageDataBuffer = new Uint32Array(state.imageData.data.buffer);
497  
498      // Track dimensions for resize detection
499      state.lastWidth = vw;
500      state.lastHeight = vh;
501  
502      if (!state.debugPanel) state.debugPanel = new DebugPanel();
503      if (state.list.length > 0) loop();
504  }
505  
506  function setupEventListeners() {
507      // Detect Firefox
508      const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
509  
510      const handleInput = (clientX, clientY) => {
511          if (!state.canvas) return;
512  
513          // Convert viewport coordinates to canvas coordinates
514          const rect = state.canvas.getBoundingClientRect();
515  
516          // Scale from display size to canvas buffer size
517          const scaleX = state.canvas.width / rect.width;
518          const scaleY = state.canvas.height / rect.height;
519  
520          const canvasX = (clientX - rect.left) * scaleX;
521          const canvasY = (clientY - rect.top) * scaleY;
522  
523          clearTimeout(state.manualTimeout);
524  
525          // On mobile, set position directly for responsive feel
526          if (state.isMobile) {
527              state.cursorX = state.targetX = canvasX;
528              state.cursorY = state.targetY = canvasY;
529              state.cursorVx = 0;
530              state.cursorVy = 0;
531          } else {
532              state.targetX = canvasX;
533              state.targetY = canvasY;
534          }
535  
536          if (!state.isManual) state.transitionBlend = 0;
537          state.isManual = true;
538          state.manualTimeout = setTimeout(() => {
539              state.isManual = false;
540              state.transitionStartTime = Date.now();
541          }, CONFIG.MANUAL_TIMEOUT);
542      };
543      document.addEventListener("mousemove", e => handleInput(e.clientX, e.clientY));
544      document.addEventListener("touchstart", e => { if (e.touches.length === 1) handleInput(e.touches[0].clientX, e.touches[0].clientY); }, { passive: true });
545      document.addEventListener("touchmove", e => {
546          if (e.touches.length === 1) {
547              // Only prevent default if touching the canvas
548              if (e.target === state.canvas) {
549                  e.preventDefault();
550              }
551              handleInput(e.touches[0].clientX, e.touches[0].clientY);
552          }
553      }, { passive: false });
554      window.addEventListener("resize", () => {
555          clearTimeout(state.resizeTimeout);
556          state.resizeTimeout = setTimeout(() => {
557              const newWidth = window.innerWidth;
558              const newHeight = window.innerHeight;
559  
560              // Only reinitialize if change is significant (more than 100px in either dimension)
561              // This avoids reinitialization from mobile browser toolbars appearing/disappearing
562              const widthChange = Math.abs(newWidth - state.lastWidth);
563              const heightChange = Math.abs(newHeight - state.lastHeight);
564  
565              if (widthChange > 100 || heightChange > 100) {
566                  init();
567              }
568          }, 300);
569      });
570  }
571  
572  // Debug function to set particle visibility percentage
573  window.debugSetParticleVisibility = (percentage) => {
574      state.debugParticleVisibility = percentage;
575      console.log('Debug particle visibility:', percentage === 0 ? 'Auto' : percentage + '%');
576  };
577  
578  function updateDebugPanel() {
579      if (!state.debugPanel) return;
580  
581      const now = Date.now();
582      state.frameCount++;
583      if (now - state.lastFpsUpdate > 1000) {
584          state.fps = state.frameCount;
585          state.frameCount = 0;
586          state.lastFpsUpdate = now;
587      }
588  
589      const largestVirus = state.outbreaks.reduce((largest, o) => (!largest || o.radius > largest.radius) ? o : largest, null);
590  
591      const buffs = [];
592      if (state.cursorBuffs.shieldActive) buffs.push(`Shield (${Math.ceil((state.cursorBuffs.shieldEndTime - now) / 1000)}s)`);
593      if (state.cursorBuffs.damageBoostActive) buffs.push(`Damage Boost (${Math.ceil((state.cursorBuffs.damageBoostEndTime - now) / 1000)}s)`);
594      if (state.cursorBuffs.blackHoleKillBoostActive) buffs.push(`⚡ EMPOWERED (${Math.ceil((state.cursorBuffs.blackHoleKillBoostEndTime - now) / 1000)}s)`);
595  
596      const debugData = {
597          time: { elapsed: ((now - state.startTime) / 1000).toFixed(1) + 's' },
598          performance: { fps: state.fps, canvasWidth: state.w, canvasHeight: state.h, screenMultiplier: state.screenSizeMultiplier.toFixed(2), isMobile: state.isMobile },
599          particles: {
600              active: Math.round(state.activeParticleRatio * state.list.length),
601              total: state.list.length,
602              percentage: (state.activeParticleRatio * 100).toFixed(1),
603          },
604          cursor: {
605              x: state.cursorX.toFixed(1), y: state.cursorY.toFixed(1),
606              vx: state.cursorVx.toFixed(2), vy: state.cursorVy.toFixed(2),
607              speed: Math.sqrt(state.cursorVx**2 + state.cursorVy**2).toFixed(2),
608              radius: (CONFIG.REPULSION_RADIUS * state.radiusMultiplier).toFixed(1),
609              mode: state.isManual ? 'Manual' : 'Automatic',
610              buffs: buffs,
611          },
612          viruses: {
613              count: state.outbreaks.length,
614              nextSpawn: ((state.nextOutbreakTime - now) / 1000).toFixed(1) + 's',
615              largest: largestVirus ? {
616                  health: largestVirus.health.toFixed(0),
617                  maxHealth: largestVirus.maxHealth.toFixed(0),
618                  healthPercent: (largestVirus.health / largestVirus.maxHealth * 100).toFixed(0),
619                  radius: largestVirus.radius.toFixed(1),
620              } : null,
621          },
622          anomalies: { count: state.anomalies.length },
623          stations: {
624              count: state.stations.length,
625              nextSpawn: state.nextStationTime > now ? `${Math.ceil((state.nextStationTime - now) / 1000)}s` : 'Ready',
626          },
627          gameState: {}
628      };
629  
630      state.debugPanel.update(debugData);
631  }
632  
633  window.debugSpawnVirus = () => {
634      const angle = Math.random() * Math.PI * 2;
635      const distance = 150;
636      state.outbreaks.push({
637          x: state.cursorX + Math.cos(angle) * distance,
638          y: state.cursorY + Math.sin(angle) * distance,
639          vx: 0, vy: 0, radius: 30, frame: 0, health: 200, maxHealth: 200,
640          threatened: false, everTouched: false
641      });
642  };
643  
644  window.debugSpawnAnomaly = () => {
645      const angle = Math.random() * Math.PI * 2;
646      const distance = 150;
647      const x = state.cursorX + Math.cos(angle) * distance;
648      const y = state.cursorY + Math.sin(angle) * distance;
649      state.anomalies.push({
650          x, y, orbitCenterX: x, orbitCenterY: y,
651          orbitAngle: Math.random() * Math.PI * 2,
652          orbitRadius: CONFIG.ANOMALY_ORBIT_RADIUS,
653          vortexRadius: CONFIG.ANOMALY_VORTEX_RADIUS,
654          vortexStrength: CONFIG.ANOMALY_VORTEX_STRENGTH,
655          driftTargetX: null, driftTargetY: null, isDrifting: false,
656          driftTimer: 0, orbitTimer: 5000, health: 60, maxHealth: 60,
657          frame: 0, age: 0
658      });
659  };
660  
661  window.debugSpawnStation = () => {
662      const angle = Math.random() * Math.PI * 2;
663      const distance = (0.25 + Math.random() * 0.25) * Math.min(state.w, state.h);
664      state.stations.push({
665          x: state.centerX + Math.cos(angle) * distance,
666          y: state.centerY + Math.sin(angle) * distance,
667          driftAngle: Math.random() * Math.PI * 2,
668          spawnTime: Date.now(),
669          orbitAngle: Math.random() * Math.PI * 2,
670          orbitRadius: 12,
671          orbitSpeed: 0.03,
672          isCapturing: false,
673          captureTarget: null,
674          captureTime: 0,
675      });
676  };
677  
678  document.addEventListener('DOMContentLoaded', () => {
679      // Set current year
680      const yearSpan = document.getElementById('year');
681      if (yearSpan) {
682          yearSpan.textContent = new Date().getFullYear();
683      }
684  
685      init();
686      setupEventListeners();
687  });