| 1 | // Initially vibe coded with AI, but a lot of tuning was done manually |
| 2 | // for it to feel natural. Some of the tuning was done through AI and some |
| 3 | // was manual. This implementation is not very performant and might get |
| 4 | // re-visited, but it runs mostly-fine, mostly in chromium. |
| 5 | // |
| 6 | // The parts that need improvement are how particles are computed. Those |
| 7 | // nested loops take way too long. 2d Canvas is fine for rendering. A |
| 8 | // good chance moving computation to WASM and rendering to JS would help. |
| 9 | (globalThis as any).canvas_2021 = function(canvas: HTMLCanvasElement) { |
| 10 | const isStandalone = canvas.getAttribute("data-standalone") === "true"; |
| 11 | // Constants for simulation |
| 12 | const PARTICLE_RADIUS = 4.5; |
| 13 | const PARTICLE_DENSITY = 0.004; // Particles per pixel |
| 14 | const MIN_SPEED = 0.05; |
| 15 | const MAX_SPEED = 6.0; |
| 16 | const FRICTION = 0.96; |
| 17 | const REPULSION_STRENGTH = 0.1; |
| 18 | const REPULSION_RADIUS = 50; |
| 19 | const FORCE_RADIUS = 400; // Increased radius |
| 20 | const FORCE_STRENGTH = 0.25; |
| 21 | const FORCE_FALLOFF_EXPONENT = 3; // Higher value = sharper falloff |
| 22 | const FORCE_SPACING = 10; // Pixels between force points |
| 23 | const MIN_FORCE_STRENGTH = 0.05; // Minimum force strength for very slow movements |
| 24 | const MAX_FORCE_STRENGTH = 0.4; // Maximum force strength for fast movements |
| 25 | const MIN_SPEED_THRESHOLD = 1; // Movement speed (px/frame) that produces minimum force |
| 26 | const MAX_SPEED_THRESHOLD = 20; // Movement speed that produces maximum force |
| 27 | const OVERSCAN_PIXELS = 250; |
| 28 | const CELL_SIZE = REPULSION_RADIUS; // For spatial hashing |
| 29 | |
| 30 | let globalOpacity = 0; |
| 31 | |
| 32 | if (isStandalone) { |
| 33 | canvas.style.backgroundColor = "#301D02"; |
| 34 | } else { |
| 35 | canvas.style.backgroundColor = "transparent"; |
| 36 | } |
| 37 | |
| 38 | // Interfaces |
| 39 | interface Particle { |
| 40 | x: number; |
| 41 | y: number; |
| 42 | vx: number; |
| 43 | vy: number; |
| 44 | charge: number; // 0 to 1, affecting color |
| 45 | } |
| 46 | |
| 47 | interface Force { |
| 48 | x: number; |
| 49 | y: number; |
| 50 | dx: number; |
| 51 | dy: number; |
| 52 | strength: number; |
| 53 | radius: number; |
| 54 | createdAt: number; |
| 55 | } |
| 56 | |
| 57 | interface SpatialHash { |
| 58 | [key: string]: Particle[]; |
| 59 | } |
| 60 | |
| 61 | // State |
| 62 | let first = true; |
| 63 | let particles: Particle[] = []; |
| 64 | let forces: Force[] = []; |
| 65 | let width = canvas.width; |
| 66 | let height = canvas.height; |
| 67 | let targetParticleCount = 0; |
| 68 | let spatialHash: SpatialHash = {}; |
| 69 | let ctx: CanvasRenderingContext2D | null = null; |
| 70 | let animationId: number | null = null; |
| 71 | let isRunning = false; |
| 72 | |
| 73 | // Mouse tracking |
| 74 | let lastMousePosition: { x: number; y: number } | null = null; |
| 75 | // Track position of the last created force |
| 76 | let lastForcePosition: { x: number; y: number } | null = null; |
| 77 | |
| 78 | // Keep track of previous canvas dimensions for resize logic |
| 79 | let previousWidth = 0; |
| 80 | let previousHeight = 0; |
| 81 | |
| 82 | // Initialize and cleanup |
| 83 | function init(): void { |
| 84 | ctx = canvas.getContext("2d"); |
| 85 | if (!ctx) return; |
| 86 | |
| 87 | // Set canvas to full size |
| 88 | resizeCanvas(); |
| 89 | |
| 90 | // Event listeners |
| 91 | window.addEventListener("resize", resizeCanvas); |
| 92 | document.addEventListener("mousemove", handleMouseMove); |
| 93 | |
| 94 | // Start animation immediately |
| 95 | start(); |
| 96 | } |
| 97 | |
| 98 | function cleanup(): void { |
| 99 | // Stop the animation |
| 100 | stop(); |
| 101 | |
| 102 | // Remove event listeners |
| 103 | window.removeEventListener("resize", resizeCanvas); |
| 104 | document.removeEventListener("mousemove", handleMouseMove); |
| 105 | |
| 106 | // Clear arrays |
| 107 | particles = []; |
| 108 | forces = []; |
| 109 | spatialHash = {}; |
| 110 | lastMousePosition = null; |
| 111 | lastForcePosition = null; |
| 112 | } |
| 113 | |
| 114 | // Resize canvas and adjust particle count |
| 115 | function resizeCanvas(): void { |
| 116 | // Store previous dimensions |
| 117 | previousWidth = width; |
| 118 | previousHeight = height; |
| 119 | |
| 120 | // Update to new dimensions |
| 121 | width = window.innerWidth; |
| 122 | height = window.innerHeight; |
| 123 | canvas.width = width; |
| 124 | canvas.height = height; |
| 125 | |
| 126 | const oldTargetCount = targetParticleCount; |
| 127 | targetParticleCount = Math.floor(width * height * PARTICLE_DENSITY); |
| 128 | |
| 129 | // Adjust particle count |
| 130 | if (targetParticleCount > oldTargetCount) { |
| 131 | // Add more particles if needed, but only in newly available space |
| 132 | addParticles(targetParticleCount - oldTargetCount, !first); |
| 133 | first = false; |
| 134 | } |
| 135 | // Note: Removal of excess particles happens naturally during update |
| 136 | } |
| 137 | |
| 138 | // Handle mouse movement |
| 139 | function handleMouseMove(e: MouseEvent): void { |
| 140 | const rect = canvas.getBoundingClientRect(); |
| 141 | const currentX = e.clientX - rect.left; |
| 142 | const currentY = e.clientY - rect.top; |
| 143 | |
| 144 | // Initialize positions if this is the first movement |
| 145 | if (!lastMousePosition || !lastForcePosition) { |
| 146 | lastMousePosition = { x: currentX, y: currentY }; |
| 147 | lastForcePosition = { x: currentX, y: currentY }; |
| 148 | return; |
| 149 | } |
| 150 | |
| 151 | // Store current mouse position |
| 152 | const mouseX = currentX; |
| 153 | const mouseY = currentY; |
| 154 | |
| 155 | // Calculate vector from last mouse position to current |
| 156 | const dx = mouseX - lastMousePosition.x; |
| 157 | const dy = mouseY - lastMousePosition.y; |
| 158 | const distMoved = Math.sqrt(dx * dx + dy * dy); |
| 159 | |
| 160 | // Skip if essentially no movement (avoids numerical issues) |
| 161 | if (distMoved < 0.1) { |
| 162 | return; |
| 163 | } |
| 164 | |
| 165 | // Get the vector from the last force to the current mouse position |
| 166 | const forceDx = mouseX - lastForcePosition.x; |
| 167 | const forceDy = mouseY - lastForcePosition.y; |
| 168 | const forceDistance = Math.sqrt(forceDx * forceDx + forceDy * forceDy); |
| 169 | |
| 170 | // Only create forces if we've moved far enough from the last force |
| 171 | if (forceDistance >= FORCE_SPACING) { |
| 172 | // Calculate the direction vector from last force to current mouse |
| 173 | let dirX = forceDx / forceDistance; |
| 174 | let dirY = forceDy / forceDistance; |
| 175 | |
| 176 | // Calculate how many force points to create |
| 177 | const numPoints = Math.floor(forceDistance / FORCE_SPACING); |
| 178 | |
| 179 | // Calculate movement speed based on the recent movement |
| 180 | const movementSpeed = distMoved; // Simple approximation of speed |
| 181 | |
| 182 | // Scale force strength based on movement speed |
| 183 | let speedFactor; |
| 184 | if (movementSpeed <= MIN_SPEED_THRESHOLD) { |
| 185 | speedFactor = MIN_FORCE_STRENGTH; |
| 186 | } else if (movementSpeed >= MAX_SPEED_THRESHOLD) { |
| 187 | speedFactor = MAX_FORCE_STRENGTH; |
| 188 | } else { |
| 189 | // Linear interpolation between min and max |
| 190 | const t = (movementSpeed - MIN_SPEED_THRESHOLD) |
| 191 | / (MAX_SPEED_THRESHOLD - MIN_SPEED_THRESHOLD); |
| 192 | speedFactor = MIN_FORCE_STRENGTH |
| 193 | + t * (MAX_FORCE_STRENGTH - MIN_FORCE_STRENGTH); |
| 194 | } |
| 195 | |
| 196 | // Store current force position to update incrementally |
| 197 | let currentForceX = lastForcePosition.x; |
| 198 | let currentForceY = lastForcePosition.y; |
| 199 | |
| 200 | // Create evenly spaced force points along the path from last force to current mouse |
| 201 | for (let i = 0; i < numPoints; i++) { |
| 202 | // Calculate position for this force point |
| 203 | const t = (i + 1) / numPoints; |
| 204 | const fx = lastForcePosition.x + forceDx * t; |
| 205 | const fy = lastForcePosition.y + forceDy * t; |
| 206 | |
| 207 | // Create force at this position with the direction vector |
| 208 | createForce(fx, fy, dirX, dirY, speedFactor); |
| 209 | |
| 210 | // Update the last force position to this new force |
| 211 | currentForceX = fx; |
| 212 | currentForceY = fy; |
| 213 | } |
| 214 | |
| 215 | // Update the last force position |
| 216 | lastForcePosition = { x: currentForceX, y: currentForceY }; |
| 217 | } |
| 218 | |
| 219 | // Always update the last mouse position |
| 220 | lastMousePosition = { x: mouseX, y: mouseY }; |
| 221 | } |
| 222 | |
| 223 | // Create a new force |
| 224 | function createForce( |
| 225 | x: number, |
| 226 | y: number, |
| 227 | dx: number, |
| 228 | dy: number, |
| 229 | strength = FORCE_STRENGTH, |
| 230 | ): void { |
| 231 | forces.push({ |
| 232 | x, |
| 233 | y, |
| 234 | dx, |
| 235 | dy, |
| 236 | strength, |
| 237 | radius: 1, |
| 238 | createdAt: Date.now(), |
| 239 | }); |
| 240 | } |
| 241 | |
| 242 | // Improved particle addition with fill strategy options |
| 243 | function addParticles(count: number, inNewAreaOnly: boolean = false): void { |
| 244 | // Determine available space |
| 245 | const minX = -OVERSCAN_PIXELS; |
| 246 | const maxX = width + OVERSCAN_PIXELS; |
| 247 | const minY = -OVERSCAN_PIXELS; |
| 248 | const maxY = height + OVERSCAN_PIXELS; |
| 249 | |
| 250 | // Use a grid system that guarantees uniform spacing of particles |
| 251 | const gridSpacing = REPULSION_RADIUS * 0.8; // Slightly less than repulsion radius |
| 252 | const gridWidth = Math.ceil((maxX - minX) / gridSpacing); |
| 253 | const gridHeight = Math.ceil((maxY - minY) / gridSpacing); |
| 254 | |
| 255 | // Track which grid cells are already occupied |
| 256 | const occupiedCells: Set<string> = new Set(); |
| 257 | |
| 258 | // Mark cells occupied by existing particles |
| 259 | for (const particle of particles) { |
| 260 | const cellX = Math.floor((particle.x - minX) / gridSpacing); |
| 261 | const cellY = Math.floor((particle.y - minY) / gridSpacing); |
| 262 | |
| 263 | // Ensure cell coordinates are within valid range |
| 264 | if (cellX >= 0 && cellX < gridWidth && cellY >= 0 && cellY < gridHeight) { |
| 265 | occupiedCells.add(`${cellX},${cellY}`); |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | // Create arrays of all cells and filter by placement strategy |
| 270 | const allGridCells: { x: number; y: number }[] = []; |
| 271 | |
| 272 | for (let cellY = 0; cellY < gridHeight; cellY++) { |
| 273 | for (let cellX = 0; cellX < gridWidth; cellX++) { |
| 274 | const cellKey = `${cellX},${cellY}`; |
| 275 | if (!occupiedCells.has(cellKey)) { |
| 276 | const posX = minX + (cellX + 0.5) * gridSpacing; |
| 277 | const posY = minY + (cellY + 0.5) * gridSpacing; |
| 278 | |
| 279 | // For new area only placement, filter to expanded areas |
| 280 | if (inNewAreaOnly && previousWidth > 0 && previousHeight > 0) { |
| 281 | const expandedRight = width > previousWidth; |
| 282 | const expandedBottom = height > previousHeight; |
| 283 | |
| 284 | const inNewRightArea = expandedRight && posX >= previousWidth |
| 285 | && posX <= width; |
| 286 | const inNewBottomArea = expandedBottom && posY >= previousHeight |
| 287 | && posY <= height; |
| 288 | |
| 289 | if (inNewRightArea || inNewBottomArea) { |
| 290 | allGridCells.push({ x: cellX, y: cellY }); |
| 291 | } |
| 292 | } else if (!inNewAreaOnly) { |
| 293 | // Standard placement - add all valid cells |
| 294 | allGridCells.push({ x: cellX, y: cellY }); |
| 295 | } |
| 296 | } |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | if (allGridCells.length == 0) { |
| 301 | throw new Error("No cells available to place particles"); |
| 302 | } |
| 303 | |
| 304 | // We now have all grid cells that match our placement criteria |
| 305 | |
| 306 | // If we need more particles than we have available cells, we need to adjust |
| 307 | // gridSpacing to fit more cells into the same space |
| 308 | if (count > allGridCells.length) { |
| 309 | // Retry with a smaller grid spacing |
| 310 | // Proportionally reduce the grid spacing to fit the required number of particles |
| 311 | const scaleFactor = Math.sqrt(allGridCells.length / count); |
| 312 | const newGridSpacing = gridSpacing * scaleFactor; |
| 313 | |
| 314 | // Clear particles and try again with new spacing |
| 315 | // This is a recursive call, but with adjusted parameters that will fit |
| 316 | return addParticlesWithCustomSpacing( |
| 317 | count, |
| 318 | inNewAreaOnly, |
| 319 | newGridSpacing, |
| 320 | ); |
| 321 | } |
| 322 | |
| 323 | // Shuffle the available cells for random selection |
| 324 | shuffleArray(allGridCells); |
| 325 | |
| 326 | // Take the number of cells we need |
| 327 | const cellsToUse = Math.min(count, allGridCells.length); |
| 328 | const selectedCells = allGridCells.slice(0, cellsToUse); |
| 329 | |
| 330 | // Create particles in selected cells |
| 331 | for (const cell of selectedCells) { |
| 332 | // Add jitter within the cell for natural look |
| 333 | const jitterX = (Math.random() - 0.5) * gridSpacing * 0.8; |
| 334 | const jitterY = (Math.random() - 0.5) * gridSpacing * 0.8; |
| 335 | |
| 336 | // Calculate final position |
| 337 | const x = minX + (cell.x + 0.5) * gridSpacing + jitterX; |
| 338 | const y = minY + (cell.y + 0.5) * gridSpacing + jitterY; |
| 339 | |
| 340 | // Create a particle at this position |
| 341 | particles.push(createParticle(x, y)); |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | // Helper function to add particles with custom grid spacing |
| 346 | function addParticlesWithCustomSpacing( |
| 347 | count: number, |
| 348 | inNewAreaOnly: boolean, |
| 349 | gridSpacing: number, |
| 350 | ): void { |
| 351 | if (gridSpacing == 0) throw new Error("Grid spacing is 0"); |
| 352 | // Determine available space |
| 353 | const minX = -OVERSCAN_PIXELS; |
| 354 | const maxX = width + OVERSCAN_PIXELS; |
| 355 | const minY = -OVERSCAN_PIXELS; |
| 356 | const maxY = height + OVERSCAN_PIXELS; |
| 357 | |
| 358 | // Create grid using the custom spacing |
| 359 | const gridWidth = Math.ceil((maxX - minX) / gridSpacing); |
| 360 | const gridHeight = Math.ceil((maxY - minY) / gridSpacing); |
| 361 | |
| 362 | // Track which grid cells are already occupied |
| 363 | const occupiedCells: Set<string> = new Set(); |
| 364 | |
| 365 | // Mark cells occupied by existing particles |
| 366 | for (const particle of particles) { |
| 367 | const cellX = Math.floor((particle.x - minX) / gridSpacing); |
| 368 | const cellY = Math.floor((particle.y - minY) / gridSpacing); |
| 369 | |
| 370 | // Ensure cell coordinates are within valid range |
| 371 | if (cellX >= 0 && cellX < gridWidth && cellY >= 0 && cellY < gridHeight) { |
| 372 | occupiedCells.add(`${cellX},${cellY}`); |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | // Create arrays of all cells and filter by placement strategy |
| 377 | const allGridCells: { x: number; y: number }[] = []; |
| 378 | |
| 379 | for (let cellY = 0; cellY < gridHeight; cellY++) { |
| 380 | for (let cellX = 0; cellX < gridWidth; cellX++) { |
| 381 | const cellKey = `${cellX},${cellY}`; |
| 382 | if (!occupiedCells.has(cellKey)) { |
| 383 | const posX = minX + (cellX + 0.5) * gridSpacing; |
| 384 | const posY = minY + (cellY + 0.5) * gridSpacing; |
| 385 | |
| 386 | // For new area only placement, filter to expanded areas |
| 387 | if (inNewAreaOnly && previousWidth > 0 && previousHeight > 0) { |
| 388 | const expandedRight = width > previousWidth; |
| 389 | const expandedBottom = height > previousHeight; |
| 390 | |
| 391 | const inNewRightArea = expandedRight && posX >= previousWidth |
| 392 | && posX <= width; |
| 393 | const inNewBottomArea = expandedBottom && posY >= previousHeight |
| 394 | && posY <= height; |
| 395 | |
| 396 | if (inNewRightArea || inNewBottomArea) { |
| 397 | allGridCells.push({ x: cellX, y: cellY }); |
| 398 | } |
| 399 | } else if (!inNewAreaOnly) { |
| 400 | // Standard placement - add all valid cells |
| 401 | allGridCells.push({ x: cellX, y: cellY }); |
| 402 | } |
| 403 | } |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | // Shuffle the available cells for random distribution |
| 408 | shuffleArray(allGridCells); |
| 409 | |
| 410 | // Take the number of cells we need (or all if we have fewer) |
| 411 | const cellsToUse = Math.min(count, allGridCells.length); |
| 412 | |
| 413 | // Create particles in selected cells |
| 414 | for (let i = 0; i < cellsToUse; i++) { |
| 415 | const cell = allGridCells[i]; |
| 416 | |
| 417 | // Add jitter within the cell |
| 418 | const jitterX = (Math.random() - 0.5) * gridSpacing * 0.8; |
| 419 | const jitterY = (Math.random() - 0.5) * gridSpacing * 0.8; |
| 420 | |
| 421 | // Calculate final position |
| 422 | const x = minX + (cell.x + 0.5) * gridSpacing + jitterX; |
| 423 | const y = minY + (cell.y + 0.5) * gridSpacing + jitterY; |
| 424 | |
| 425 | // Create a particle at this position |
| 426 | particles.push(createParticle(x, y)); |
| 427 | } |
| 428 | } |
| 429 | |
| 430 | // Utility to shuffle an array (Fisher-Yates algorithm) |
| 431 | function shuffleArray<T>(array: T[]): void { |
| 432 | for (let i = array.length - 1; i > 0; i--) { |
| 433 | const j = Math.floor(Math.random() * (i + 1)); |
| 434 | [array[i], array[j]] = [array[j], array[i]]; |
| 435 | } |
| 436 | } |
| 437 | |
| 438 | // Simplified createParticle function that just places at a specific position |
| 439 | function createParticle(x: number, y: number): Particle { |
| 440 | return { |
| 441 | x: x + (Math.random() * 4 - 2), |
| 442 | y: y + (Math.random() * 4 - 2), |
| 443 | vx: 0, |
| 444 | vy: 0, |
| 445 | charge: 0, |
| 446 | }; |
| 447 | } |
| 448 | |
| 449 | // Function to create a particle on one of the edges |
| 450 | function createParticleOnEdge(): Particle { |
| 451 | // Overscan bounds with fixed pixel size |
| 452 | const minX = -OVERSCAN_PIXELS; |
| 453 | const maxX = width + OVERSCAN_PIXELS; |
| 454 | const minY = -OVERSCAN_PIXELS; |
| 455 | const maxY = height + OVERSCAN_PIXELS; |
| 456 | |
| 457 | let x: number, y: number; |
| 458 | |
| 459 | // Place on one of the edges |
| 460 | const edge = Math.floor(Math.random() * 4); |
| 461 | switch (edge) { |
| 462 | case 0: // Top |
| 463 | x = minX + Math.random() * (maxX - minX); |
| 464 | y = minY; |
| 465 | break; |
| 466 | case 1: // Right |
| 467 | x = maxX; |
| 468 | y = minY + Math.random() * (maxY - minY); |
| 469 | break; |
| 470 | case 2: // Bottom |
| 471 | x = minX + Math.random() * (maxX - minX); |
| 472 | y = maxY; |
| 473 | break; |
| 474 | case 3: // Left |
| 475 | x = minX; |
| 476 | y = minY + Math.random() * (maxY - minY); |
| 477 | break; |
| 478 | default: |
| 479 | x = minX + Math.random() * (maxX - minX); |
| 480 | y = minY + Math.random() * (maxY - minY); |
| 481 | } |
| 482 | |
| 483 | return createParticle(x, y); |
| 484 | } |
| 485 | |
| 486 | // Spatial hashing functions |
| 487 | function getHashKey(x: number, y: number): string { |
| 488 | const cellX = Math.floor(x / CELL_SIZE); |
| 489 | const cellY = Math.floor(y / CELL_SIZE); |
| 490 | return `${cellX},${cellY}`; |
| 491 | } |
| 492 | |
| 493 | function addToSpatialHash(particle: Particle): void { |
| 494 | const key = getHashKey(particle.x, particle.y); |
| 495 | if (!spatialHash[key]) { |
| 496 | spatialHash[key] = []; |
| 497 | } |
| 498 | spatialHash[key].push(particle); |
| 499 | } |
| 500 | |
| 501 | function updateSpatialHash(): void { |
| 502 | // Clear previous hash |
| 503 | spatialHash = {}; |
| 504 | |
| 505 | // Add all particles to hash |
| 506 | for (const particle of particles) { |
| 507 | addToSpatialHash(particle); |
| 508 | } |
| 509 | } |
| 510 | |
| 511 | function getNearbyParticles( |
| 512 | x: number, |
| 513 | y: number, |
| 514 | radius: number, |
| 515 | ): Particle[] { |
| 516 | const result: Particle[] = []; |
| 517 | const cellRadius = Math.ceil(radius / CELL_SIZE); |
| 518 | |
| 519 | const centerCellX = Math.floor(x / CELL_SIZE); |
| 520 | const centerCellY = Math.floor(y / CELL_SIZE); |
| 521 | |
| 522 | for ( |
| 523 | let cellX = centerCellX - cellRadius; |
| 524 | cellX <= centerCellX + cellRadius; |
| 525 | cellX++ |
| 526 | ) { |
| 527 | for ( |
| 528 | let cellY = centerCellY - cellRadius; |
| 529 | cellY <= centerCellY + cellRadius; |
| 530 | cellY++ |
| 531 | ) { |
| 532 | const key = `${cellX},${cellY}`; |
| 533 | const cell = spatialHash[key]; |
| 534 | |
| 535 | if (cell) { |
| 536 | result.push(...cell); |
| 537 | } |
| 538 | } |
| 539 | } |
| 540 | |
| 541 | return result; |
| 542 | } |
| 543 | |
| 544 | // Main update function |
| 545 | function update(): void { |
| 546 | const now = Date.now(); |
| 547 | // Fixed pixel overscan |
| 548 | const minX = -OVERSCAN_PIXELS; |
| 549 | const maxX = width + OVERSCAN_PIXELS; |
| 550 | const minY = -OVERSCAN_PIXELS; |
| 551 | const maxY = height + OVERSCAN_PIXELS; |
| 552 | |
| 553 | // Update spatial hash |
| 554 | updateSpatialHash(); |
| 555 | |
| 556 | // Update forces and remove expired ones |
| 557 | if (forces.length > 40) { |
| 558 | forces = forces.slice(-40); |
| 559 | } |
| 560 | forces = forces.filter((force) => { |
| 561 | force.strength *= 0.95; |
| 562 | force.radius *= 0.95; |
| 563 | return force.strength > 0.001; |
| 564 | }); |
| 565 | |
| 566 | // Update particles |
| 567 | const newParticles: Particle[] = []; |
| 568 | |
| 569 | for (const particle of particles) { |
| 570 | // Apply forces |
| 571 | for (const force of forces) { |
| 572 | const dx = particle.x - force.x; |
| 573 | const dy = particle.y - force.y; |
| 574 | const distSq = dx * dx + dy * dy; |
| 575 | |
| 576 | const radius = force.radius * FORCE_RADIUS; |
| 577 | |
| 578 | if (distSq < radius * radius) { |
| 579 | const dist = Math.sqrt(distSq); |
| 580 | |
| 581 | // Exponential falloff - much more concentrated at center |
| 582 | // (1 - x/R)^n where n controls how sharp the falloff is |
| 583 | const normalizedDist = dist / radius; |
| 584 | const factor = Math.pow(1 - normalizedDist, FORCE_FALLOFF_EXPONENT); |
| 585 | |
| 586 | // Calculate force line projection for directional effect |
| 587 | // This makes particles along the force's path experience stronger effect |
| 588 | const dotProduct = (dx * -force.dx) + (dy * -force.dy); |
| 589 | const projectionFactor = Math.max(0, dotProduct / dist); |
| 590 | |
| 591 | // Apply the combined factors - stronger directional bias |
| 592 | const finalFactor = factor * force.strength |
| 593 | * (0.1 + 0.9 * projectionFactor); |
| 594 | |
| 595 | particle.vx += force.dx * finalFactor; |
| 596 | particle.vy += force.dy * finalFactor; |
| 597 | // charge for the first 100ms |
| 598 | if ((now - force.createdAt) < 100) { |
| 599 | particle.charge = Math.min( |
| 600 | 1, |
| 601 | particle.charge + (finalFactor * finalFactor) * 0.2, |
| 602 | ); |
| 603 | } |
| 604 | } |
| 605 | } |
| 606 | |
| 607 | // Apply repulsion from nearby particles |
| 608 | const nearby = getNearbyParticles( |
| 609 | particle.x, |
| 610 | particle.y, |
| 611 | REPULSION_RADIUS, |
| 612 | ); |
| 613 | |
| 614 | for (const other of nearby) { |
| 615 | if (other === particle) continue; |
| 616 | |
| 617 | const dx = particle.x - other.x; |
| 618 | const dy = particle.y - other.y; |
| 619 | const distSq = dx * dx + dy * dy; |
| 620 | |
| 621 | if (distSq < REPULSION_RADIUS * REPULSION_RADIUS && distSq > 0) { |
| 622 | const dist = Math.sqrt(distSq); |
| 623 | const factor = REPULSION_STRENGTH * (1 - dist / REPULSION_RADIUS); |
| 624 | |
| 625 | const fx = dx / dist * factor; |
| 626 | const fy = dy / dist * factor; |
| 627 | |
| 628 | particle.vx += fx; |
| 629 | particle.vy += fy; |
| 630 | } |
| 631 | } |
| 632 | |
| 633 | // Apply friction |
| 634 | particle.vx *= FRICTION; |
| 635 | particle.vy *= FRICTION; |
| 636 | |
| 637 | // Ensure minimum speed |
| 638 | const speed = Math.sqrt( |
| 639 | particle.vx * particle.vx + particle.vy * particle.vy, |
| 640 | ); |
| 641 | if (speed < MIN_SPEED && speed > 0) { |
| 642 | const scale = MIN_SPEED / speed; |
| 643 | particle.vx *= scale; |
| 644 | particle.vy *= scale; |
| 645 | } |
| 646 | |
| 647 | // Cap at maximum speed |
| 648 | if (speed > MAX_SPEED) { |
| 649 | const scale = MAX_SPEED / speed; |
| 650 | particle.vx *= scale; |
| 651 | particle.vy *= scale; |
| 652 | } |
| 653 | |
| 654 | // Update position |
| 655 | particle.x += particle.vx; |
| 656 | particle.y += particle.vy; |
| 657 | |
| 658 | // Decrease charge |
| 659 | particle.charge *= 0.99; |
| 660 | |
| 661 | // Check if particle is within extended bounds |
| 662 | if ( |
| 663 | particle.x >= minX && particle.x <= maxX |
| 664 | && particle.y >= minY && particle.y <= maxY |
| 665 | ) { |
| 666 | // If outside screen but within overscan, keep it if we need more particles |
| 667 | if ( |
| 668 | (particle.x < 0 || particle.x > width |
| 669 | || particle.y < 0 || particle.y > height) |
| 670 | && newParticles.length >= targetParticleCount |
| 671 | ) { |
| 672 | continue; |
| 673 | } |
| 674 | |
| 675 | newParticles.push(particle); |
| 676 | } else { |
| 677 | // Out of bounds, respawn if needed |
| 678 | if (newParticles.length < targetParticleCount) { |
| 679 | newParticles.push(createParticleOnEdge()); |
| 680 | } |
| 681 | } |
| 682 | } |
| 683 | |
| 684 | // Add more particles if needed |
| 685 | while (newParticles.length < targetParticleCount) { |
| 686 | newParticles.push(createParticleOnEdge()); |
| 687 | } |
| 688 | |
| 689 | particles = newParticles; |
| 690 | } |
| 691 | |
| 692 | // Render function |
| 693 | const mul = isStandalone ? 0.9 : 0.5; |
| 694 | const add = isStandalone ? 0.1 : 0.03; |
| 695 | function render(): void { |
| 696 | if (!ctx) return; |
| 697 | |
| 698 | // Clear canvas |
| 699 | ctx.clearRect(0, 0, width, height); |
| 700 | |
| 701 | // Draw particles |
| 702 | for (const particle of particles) { |
| 703 | // Only draw if within canvas bounds (plus a small margin) |
| 704 | if ( |
| 705 | particle.x >= -PARTICLE_RADIUS |
| 706 | && particle.x <= width + PARTICLE_RADIUS |
| 707 | && particle.y >= -PARTICLE_RADIUS && particle.y <= height + PARTICLE_RADIUS |
| 708 | ) { |
| 709 | ctx.beginPath(); |
| 710 | ctx.arc(particle.x, particle.y, PARTICLE_RADIUS, 0, Math.PI * 2); |
| 711 | |
| 712 | // Color based on charge |
| 713 | ctx.fillStyle = "#FFCB1F"; |
| 714 | ctx.globalAlpha = (particle.charge * mul + add) * globalOpacity; |
| 715 | ctx.fill(); |
| 716 | } |
| 717 | } |
| 718 | |
| 719 | // // Debug: Draw forces and falloff visualization |
| 720 | // if (ctx) { |
| 721 | // for (const force of forces) { |
| 722 | // const R = force.radius * FORCE_RADIUS; |
| 723 | |
| 724 | // // Draw force point |
| 725 | // ctx.beginPath(); |
| 726 | // ctx.arc(force.x, force.y, 5, 0, Math.PI * 2); |
| 727 | // ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'; |
| 728 | // ctx.fill(); |
| 729 | |
| 730 | // // Draw force direction |
| 731 | // ctx.beginPath(); |
| 732 | // ctx.moveTo(force.x, force.y); |
| 733 | // ctx.lineTo(force.x + force.dx * 20, force.y + force.dy * 20); |
| 734 | // ctx.strokeStyle = 'red'; |
| 735 | // ctx.stroke(); |
| 736 | |
| 737 | // // Visualize the falloff curve with rings |
| 738 | // for (let i = 0; i <= 10; i++) { |
| 739 | // const radius = (R * i) / 10; |
| 740 | // const normalizedDist = radius / R; |
| 741 | // const intensity = Math.pow(1 - normalizedDist, FORCE_FALLOFF_EXPONENT); |
| 742 | |
| 743 | // ctx.beginPath(); |
| 744 | // ctx.arc(force.x, force.y, radius, 0, Math.PI * 2); |
| 745 | // ctx.strokeStyle = `rgba(255, 0, 0, ${intensity * 0.2})`; |
| 746 | // ctx.stroke(); |
| 747 | // } |
| 748 | // } |
| 749 | // } |
| 750 | } |
| 751 | |
| 752 | // Animation loop |
| 753 | let r = Math.random(); |
| 754 | function animate(): void { |
| 755 | globalOpacity = Math.min(1, globalOpacity + 0.03); |
| 756 | update(); |
| 757 | render(); |
| 758 | |
| 759 | if (isRunning) { |
| 760 | animationId = requestAnimationFrame(animate); |
| 761 | } |
| 762 | } |
| 763 | |
| 764 | // Start/stop functions |
| 765 | function start(): void { |
| 766 | if (isRunning) return; |
| 767 | |
| 768 | // Calculate target particle count based on canvas size |
| 769 | targetParticleCount = Math.floor(width * height * PARTICLE_DENSITY); |
| 770 | |
| 771 | // Clear any existing particles and create new ones with proper spacing |
| 772 | particles = []; |
| 773 | addParticles(targetParticleCount); |
| 774 | |
| 775 | isRunning = true; |
| 776 | animate(); |
| 777 | } |
| 778 | |
| 779 | function stop(): void { |
| 780 | isRunning = false; |
| 781 | |
| 782 | if (animationId !== null) { |
| 783 | cancelAnimationFrame(animationId); |
| 784 | animationId = null; |
| 785 | } |
| 786 | } |
| 787 | |
| 788 | init(); |
| 789 | return cleanup; |
| 790 | }; |