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 });