784 lines
24 KiB
TypeScript
784 lines
24 KiB
TypeScript
|
// Vibe coded.
|
||
|
(globalThis as any).canvas_2021 = function (canvas: HTMLCanvasElement) {
|
||
|
const isStandalone = canvas.getAttribute("data-standalone") === "true";
|
||
|
// Constants for simulation
|
||
|
const PARTICLE_RADIUS = 4.5;
|
||
|
const PARTICLE_DENSITY = 0.004; // Particles per pixel
|
||
|
const MIN_SPEED = 0.05;
|
||
|
const MAX_SPEED = 6.0;
|
||
|
const FRICTION = 0.96;
|
||
|
const REPULSION_STRENGTH = 0.1;
|
||
|
const REPULSION_RADIUS = 50;
|
||
|
const FORCE_RADIUS = 400; // Increased radius
|
||
|
const FORCE_STRENGTH = 0.25;
|
||
|
const FORCE_FALLOFF_EXPONENT = 3; // Higher value = sharper falloff
|
||
|
const FORCE_SPACING = 10; // Pixels between force points
|
||
|
const MIN_FORCE_STRENGTH = 0.05; // Minimum force strength for very slow movements
|
||
|
const MAX_FORCE_STRENGTH = 0.4; // Maximum force strength for fast movements
|
||
|
const MIN_SPEED_THRESHOLD = 1; // Movement speed (px/frame) that produces minimum force
|
||
|
const MAX_SPEED_THRESHOLD = 20; // Movement speed that produces maximum force
|
||
|
const OVERSCAN_PIXELS = 250;
|
||
|
const CELL_SIZE = REPULSION_RADIUS; // For spatial hashing
|
||
|
|
||
|
let globalOpacity = 0;
|
||
|
|
||
|
if (isStandalone) {
|
||
|
canvas.style.backgroundColor = "#301D02";
|
||
|
} else {
|
||
|
canvas.style.backgroundColor = "transparent";
|
||
|
}
|
||
|
|
||
|
// Interfaces
|
||
|
interface Particle {
|
||
|
x: number;
|
||
|
y: number;
|
||
|
vx: number;
|
||
|
vy: number;
|
||
|
charge: number; // 0 to 1, affecting color
|
||
|
}
|
||
|
|
||
|
interface Force {
|
||
|
x: number;
|
||
|
y: number;
|
||
|
dx: number;
|
||
|
dy: number;
|
||
|
strength: number;
|
||
|
radius: number;
|
||
|
createdAt: number;
|
||
|
}
|
||
|
|
||
|
interface SpatialHash {
|
||
|
[key: string]: Particle[];
|
||
|
}
|
||
|
|
||
|
// State
|
||
|
let first = true;
|
||
|
let particles: Particle[] = [];
|
||
|
let forces: Force[] = [];
|
||
|
let width = canvas.width;
|
||
|
let height = canvas.height;
|
||
|
let targetParticleCount = 0;
|
||
|
let spatialHash: SpatialHash = {};
|
||
|
let ctx: CanvasRenderingContext2D | null = null;
|
||
|
let animationId: number | null = null;
|
||
|
let isRunning = false;
|
||
|
|
||
|
// Mouse tracking
|
||
|
let lastMousePosition: { x: number; y: number } | null = null;
|
||
|
// Track position of the last created force
|
||
|
let lastForcePosition: { x: number; y: number } | null = null;
|
||
|
|
||
|
// Keep track of previous canvas dimensions for resize logic
|
||
|
let previousWidth = 0;
|
||
|
let previousHeight = 0;
|
||
|
|
||
|
// Initialize and cleanup
|
||
|
function init(): void {
|
||
|
ctx = canvas.getContext("2d");
|
||
|
if (!ctx) return;
|
||
|
|
||
|
// Set canvas to full size
|
||
|
resizeCanvas();
|
||
|
|
||
|
// Event listeners
|
||
|
globalThis.addEventListener("resize", resizeCanvas);
|
||
|
document.addEventListener("mousemove", handleMouseMove);
|
||
|
|
||
|
// Start animation immediately
|
||
|
start();
|
||
|
}
|
||
|
|
||
|
function cleanup(): void {
|
||
|
// Stop the animation
|
||
|
stop();
|
||
|
|
||
|
// Remove event listeners
|
||
|
globalThis.removeEventListener("resize", resizeCanvas);
|
||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||
|
|
||
|
// Clear arrays
|
||
|
particles = [];
|
||
|
forces = [];
|
||
|
spatialHash = {};
|
||
|
lastMousePosition = null;
|
||
|
lastForcePosition = null;
|
||
|
}
|
||
|
|
||
|
// Resize canvas and adjust particle count
|
||
|
function resizeCanvas(): void {
|
||
|
// Store previous dimensions
|
||
|
previousWidth = width;
|
||
|
previousHeight = height;
|
||
|
|
||
|
// Update to new dimensions
|
||
|
width = globalThis.innerWidth;
|
||
|
height = globalThis.innerHeight;
|
||
|
canvas.width = width;
|
||
|
canvas.height = height;
|
||
|
|
||
|
const oldTargetCount = targetParticleCount;
|
||
|
targetParticleCount = Math.floor(width * height * PARTICLE_DENSITY);
|
||
|
|
||
|
// Adjust particle count
|
||
|
if (targetParticleCount > oldTargetCount) {
|
||
|
// Add more particles if needed, but only in newly available space
|
||
|
addParticles(targetParticleCount - oldTargetCount, !first);
|
||
|
first = false;
|
||
|
}
|
||
|
// Note: Removal of excess particles happens naturally during update
|
||
|
}
|
||
|
|
||
|
// Handle mouse movement
|
||
|
function handleMouseMove(e: MouseEvent): void {
|
||
|
const rect = canvas.getBoundingClientRect();
|
||
|
const currentX = e.clientX - rect.left;
|
||
|
const currentY = e.clientY - rect.top;
|
||
|
|
||
|
// Initialize positions if this is the first movement
|
||
|
if (!lastMousePosition || !lastForcePosition) {
|
||
|
lastMousePosition = { x: currentX, y: currentY };
|
||
|
lastForcePosition = { x: currentX, y: currentY };
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Store current mouse position
|
||
|
const mouseX = currentX;
|
||
|
const mouseY = currentY;
|
||
|
|
||
|
// Calculate vector from last mouse position to current
|
||
|
const dx = mouseX - lastMousePosition.x;
|
||
|
const dy = mouseY - lastMousePosition.y;
|
||
|
const distMoved = Math.sqrt(dx * dx + dy * dy);
|
||
|
|
||
|
// Skip if essentially no movement (avoids numerical issues)
|
||
|
if (distMoved < 0.1) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Get the vector from the last force to the current mouse position
|
||
|
const forceDx = mouseX - lastForcePosition.x;
|
||
|
const forceDy = mouseY - lastForcePosition.y;
|
||
|
const forceDistance = Math.sqrt(forceDx * forceDx + forceDy * forceDy);
|
||
|
|
||
|
// Only create forces if we've moved far enough from the last force
|
||
|
if (forceDistance >= FORCE_SPACING) {
|
||
|
// Calculate the direction vector from last force to current mouse
|
||
|
let dirX = forceDx / forceDistance;
|
||
|
let dirY = forceDy / forceDistance;
|
||
|
|
||
|
// Calculate how many force points to create
|
||
|
const numPoints = Math.floor(forceDistance / FORCE_SPACING);
|
||
|
|
||
|
// Calculate movement speed based on the recent movement
|
||
|
const movementSpeed = distMoved; // Simple approximation of speed
|
||
|
|
||
|
// Scale force strength based on movement speed
|
||
|
let speedFactor;
|
||
|
if (movementSpeed <= MIN_SPEED_THRESHOLD) {
|
||
|
speedFactor = MIN_FORCE_STRENGTH;
|
||
|
} else if (movementSpeed >= MAX_SPEED_THRESHOLD) {
|
||
|
speedFactor = MAX_FORCE_STRENGTH;
|
||
|
} else {
|
||
|
// Linear interpolation between min and max
|
||
|
const t = (movementSpeed - MIN_SPEED_THRESHOLD) /
|
||
|
(MAX_SPEED_THRESHOLD - MIN_SPEED_THRESHOLD);
|
||
|
speedFactor = MIN_FORCE_STRENGTH +
|
||
|
t * (MAX_FORCE_STRENGTH - MIN_FORCE_STRENGTH);
|
||
|
}
|
||
|
|
||
|
// Store current force position to update incrementally
|
||
|
let currentForceX = lastForcePosition.x;
|
||
|
let currentForceY = lastForcePosition.y;
|
||
|
|
||
|
// Create evenly spaced force points along the path from last force to current mouse
|
||
|
for (let i = 0; i < numPoints; i++) {
|
||
|
// Calculate position for this force point
|
||
|
const t = (i + 1) / numPoints;
|
||
|
const fx = lastForcePosition.x + forceDx * t;
|
||
|
const fy = lastForcePosition.y + forceDy * t;
|
||
|
|
||
|
// Create force at this position with the direction vector
|
||
|
createForce(fx, fy, dirX, dirY, speedFactor);
|
||
|
|
||
|
// Update the last force position to this new force
|
||
|
currentForceX = fx;
|
||
|
currentForceY = fy;
|
||
|
}
|
||
|
|
||
|
// Update the last force position
|
||
|
lastForcePosition = { x: currentForceX, y: currentForceY };
|
||
|
}
|
||
|
|
||
|
// Always update the last mouse position
|
||
|
lastMousePosition = { x: mouseX, y: mouseY };
|
||
|
}
|
||
|
|
||
|
// Create a new force
|
||
|
function createForce(
|
||
|
x: number,
|
||
|
y: number,
|
||
|
dx: number,
|
||
|
dy: number,
|
||
|
strength = FORCE_STRENGTH,
|
||
|
): void {
|
||
|
forces.push({
|
||
|
x,
|
||
|
y,
|
||
|
dx,
|
||
|
dy,
|
||
|
strength,
|
||
|
radius: 1,
|
||
|
createdAt: Date.now(),
|
||
|
});
|
||
|
}
|
||
|
|
||
|
// Improved particle addition with fill strategy options
|
||
|
function addParticles(count: number, inNewAreaOnly: boolean = false): void {
|
||
|
// Determine available space
|
||
|
const minX = -OVERSCAN_PIXELS;
|
||
|
const maxX = width + OVERSCAN_PIXELS;
|
||
|
const minY = -OVERSCAN_PIXELS;
|
||
|
const maxY = height + OVERSCAN_PIXELS;
|
||
|
|
||
|
// Use a grid system that guarantees uniform spacing of particles
|
||
|
const gridSpacing = REPULSION_RADIUS * 0.8; // Slightly less than repulsion radius
|
||
|
const gridWidth = Math.ceil((maxX - minX) / gridSpacing);
|
||
|
const gridHeight = Math.ceil((maxY - minY) / gridSpacing);
|
||
|
|
||
|
// Track which grid cells are already occupied
|
||
|
const occupiedCells: Set<string> = new Set();
|
||
|
|
||
|
// Mark cells occupied by existing particles
|
||
|
for (const particle of particles) {
|
||
|
const cellX = Math.floor((particle.x - minX) / gridSpacing);
|
||
|
const cellY = Math.floor((particle.y - minY) / gridSpacing);
|
||
|
|
||
|
// Ensure cell coordinates are within valid range
|
||
|
if (cellX >= 0 && cellX < gridWidth && cellY >= 0 && cellY < gridHeight) {
|
||
|
occupiedCells.add(`${cellX},${cellY}`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Create arrays of all cells and filter by placement strategy
|
||
|
const allGridCells: { x: number; y: number }[] = [];
|
||
|
|
||
|
for (let cellY = 0; cellY < gridHeight; cellY++) {
|
||
|
for (let cellX = 0; cellX < gridWidth; cellX++) {
|
||
|
const cellKey = `${cellX},${cellY}`;
|
||
|
if (!occupiedCells.has(cellKey)) {
|
||
|
const posX = minX + (cellX + 0.5) * gridSpacing;
|
||
|
const posY = minY + (cellY + 0.5) * gridSpacing;
|
||
|
|
||
|
// For new area only placement, filter to expanded areas
|
||
|
if (inNewAreaOnly && previousWidth > 0 && previousHeight > 0) {
|
||
|
const expandedRight = width > previousWidth;
|
||
|
const expandedBottom = height > previousHeight;
|
||
|
|
||
|
const inNewRightArea = expandedRight && posX >= previousWidth &&
|
||
|
posX <= width;
|
||
|
const inNewBottomArea = expandedBottom && posY >= previousHeight &&
|
||
|
posY <= height;
|
||
|
|
||
|
if (inNewRightArea || inNewBottomArea) {
|
||
|
allGridCells.push({ x: cellX, y: cellY });
|
||
|
}
|
||
|
} else if (!inNewAreaOnly) {
|
||
|
// Standard placement - add all valid cells
|
||
|
allGridCells.push({ x: cellX, y: cellY });
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (allGridCells.length == 0) {
|
||
|
throw new Error("No cells available to place particles");
|
||
|
}
|
||
|
|
||
|
// We now have all grid cells that match our placement criteria
|
||
|
|
||
|
// If we need more particles than we have available cells, we need to adjust
|
||
|
// gridSpacing to fit more cells into the same space
|
||
|
if (count > allGridCells.length) {
|
||
|
// Retry with a smaller grid spacing
|
||
|
// Proportionally reduce the grid spacing to fit the required number of particles
|
||
|
const scaleFactor = Math.sqrt(allGridCells.length / count);
|
||
|
const newGridSpacing = gridSpacing * scaleFactor;
|
||
|
|
||
|
// Clear particles and try again with new spacing
|
||
|
// This is a recursive call, but with adjusted parameters that will fit
|
||
|
return addParticlesWithCustomSpacing(
|
||
|
count,
|
||
|
inNewAreaOnly,
|
||
|
newGridSpacing,
|
||
|
);
|
||
|
}
|
||
|
|
||
|
// Shuffle the available cells for random selection
|
||
|
shuffleArray(allGridCells);
|
||
|
|
||
|
// Take the number of cells we need
|
||
|
const cellsToUse = Math.min(count, allGridCells.length);
|
||
|
const selectedCells = allGridCells.slice(0, cellsToUse);
|
||
|
|
||
|
// Create particles in selected cells
|
||
|
for (const cell of selectedCells) {
|
||
|
// Add jitter within the cell for natural look
|
||
|
const jitterX = (Math.random() - 0.5) * gridSpacing * 0.8;
|
||
|
const jitterY = (Math.random() - 0.5) * gridSpacing * 0.8;
|
||
|
|
||
|
// Calculate final position
|
||
|
const x = minX + (cell.x + 0.5) * gridSpacing + jitterX;
|
||
|
const y = minY + (cell.y + 0.5) * gridSpacing + jitterY;
|
||
|
|
||
|
// Create a particle at this position
|
||
|
particles.push(createParticle(x, y));
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Helper function to add particles with custom grid spacing
|
||
|
function addParticlesWithCustomSpacing(
|
||
|
count: number,
|
||
|
inNewAreaOnly: boolean,
|
||
|
gridSpacing: number,
|
||
|
): void {
|
||
|
if (gridSpacing == 0) throw new Error("Grid spacing is 0");
|
||
|
// Determine available space
|
||
|
const minX = -OVERSCAN_PIXELS;
|
||
|
const maxX = width + OVERSCAN_PIXELS;
|
||
|
const minY = -OVERSCAN_PIXELS;
|
||
|
const maxY = height + OVERSCAN_PIXELS;
|
||
|
|
||
|
// Create grid using the custom spacing
|
||
|
const gridWidth = Math.ceil((maxX - minX) / gridSpacing);
|
||
|
const gridHeight = Math.ceil((maxY - minY) / gridSpacing);
|
||
|
|
||
|
// Track which grid cells are already occupied
|
||
|
const occupiedCells: Set<string> = new Set();
|
||
|
|
||
|
// Mark cells occupied by existing particles
|
||
|
for (const particle of particles) {
|
||
|
const cellX = Math.floor((particle.x - minX) / gridSpacing);
|
||
|
const cellY = Math.floor((particle.y - minY) / gridSpacing);
|
||
|
|
||
|
// Ensure cell coordinates are within valid range
|
||
|
if (cellX >= 0 && cellX < gridWidth && cellY >= 0 && cellY < gridHeight) {
|
||
|
occupiedCells.add(`${cellX},${cellY}`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Create arrays of all cells and filter by placement strategy
|
||
|
const allGridCells: { x: number; y: number }[] = [];
|
||
|
|
||
|
for (let cellY = 0; cellY < gridHeight; cellY++) {
|
||
|
for (let cellX = 0; cellX < gridWidth; cellX++) {
|
||
|
const cellKey = `${cellX},${cellY}`;
|
||
|
if (!occupiedCells.has(cellKey)) {
|
||
|
const posX = minX + (cellX + 0.5) * gridSpacing;
|
||
|
const posY = minY + (cellY + 0.5) * gridSpacing;
|
||
|
|
||
|
// For new area only placement, filter to expanded areas
|
||
|
if (inNewAreaOnly && previousWidth > 0 && previousHeight > 0) {
|
||
|
const expandedRight = width > previousWidth;
|
||
|
const expandedBottom = height > previousHeight;
|
||
|
|
||
|
const inNewRightArea = expandedRight && posX >= previousWidth &&
|
||
|
posX <= width;
|
||
|
const inNewBottomArea = expandedBottom && posY >= previousHeight &&
|
||
|
posY <= height;
|
||
|
|
||
|
if (inNewRightArea || inNewBottomArea) {
|
||
|
allGridCells.push({ x: cellX, y: cellY });
|
||
|
}
|
||
|
} else if (!inNewAreaOnly) {
|
||
|
// Standard placement - add all valid cells
|
||
|
allGridCells.push({ x: cellX, y: cellY });
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Shuffle the available cells for random distribution
|
||
|
shuffleArray(allGridCells);
|
||
|
|
||
|
// Take the number of cells we need (or all if we have fewer)
|
||
|
const cellsToUse = Math.min(count, allGridCells.length);
|
||
|
|
||
|
// Create particles in selected cells
|
||
|
for (let i = 0; i < cellsToUse; i++) {
|
||
|
const cell = allGridCells[i];
|
||
|
|
||
|
// Add jitter within the cell
|
||
|
const jitterX = (Math.random() - 0.5) * gridSpacing * 0.8;
|
||
|
const jitterY = (Math.random() - 0.5) * gridSpacing * 0.8;
|
||
|
|
||
|
// Calculate final position
|
||
|
const x = minX + (cell.x + 0.5) * gridSpacing + jitterX;
|
||
|
const y = minY + (cell.y + 0.5) * gridSpacing + jitterY;
|
||
|
|
||
|
// Create a particle at this position
|
||
|
particles.push(createParticle(x, y));
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Utility to shuffle an array (Fisher-Yates algorithm)
|
||
|
function shuffleArray<T>(array: T[]): void {
|
||
|
for (let i = array.length - 1; i > 0; i--) {
|
||
|
const j = Math.floor(Math.random() * (i + 1));
|
||
|
[array[i], array[j]] = [array[j], array[i]];
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Simplified createParticle function that just places at a specific position
|
||
|
function createParticle(x: number, y: number): Particle {
|
||
|
return {
|
||
|
x: x + (Math.random() * 4 - 2),
|
||
|
y: y + (Math.random() * 4 - 2),
|
||
|
vx: 0,
|
||
|
vy: 0,
|
||
|
charge: 0,
|
||
|
};
|
||
|
}
|
||
|
|
||
|
// Function to create a particle on one of the edges
|
||
|
function createParticleOnEdge(): Particle {
|
||
|
// Overscan bounds with fixed pixel size
|
||
|
const minX = -OVERSCAN_PIXELS;
|
||
|
const maxX = width + OVERSCAN_PIXELS;
|
||
|
const minY = -OVERSCAN_PIXELS;
|
||
|
const maxY = height + OVERSCAN_PIXELS;
|
||
|
|
||
|
let x: number, y: number;
|
||
|
|
||
|
// Place on one of the edges
|
||
|
const edge = Math.floor(Math.random() * 4);
|
||
|
switch (edge) {
|
||
|
case 0: // Top
|
||
|
x = minX + Math.random() * (maxX - minX);
|
||
|
y = minY;
|
||
|
break;
|
||
|
case 1: // Right
|
||
|
x = maxX;
|
||
|
y = minY + Math.random() * (maxY - minY);
|
||
|
break;
|
||
|
case 2: // Bottom
|
||
|
x = minX + Math.random() * (maxX - minX);
|
||
|
y = maxY;
|
||
|
break;
|
||
|
case 3: // Left
|
||
|
x = minX;
|
||
|
y = minY + Math.random() * (maxY - minY);
|
||
|
break;
|
||
|
default:
|
||
|
x = minX + Math.random() * (maxX - minX);
|
||
|
y = minY + Math.random() * (maxY - minY);
|
||
|
}
|
||
|
|
||
|
return createParticle(x, y);
|
||
|
}
|
||
|
|
||
|
// Spatial hashing functions
|
||
|
function getHashKey(x: number, y: number): string {
|
||
|
const cellX = Math.floor(x / CELL_SIZE);
|
||
|
const cellY = Math.floor(y / CELL_SIZE);
|
||
|
return `${cellX},${cellY}`;
|
||
|
}
|
||
|
|
||
|
function addToSpatialHash(particle: Particle): void {
|
||
|
const key = getHashKey(particle.x, particle.y);
|
||
|
if (!spatialHash[key]) {
|
||
|
spatialHash[key] = [];
|
||
|
}
|
||
|
spatialHash[key].push(particle);
|
||
|
}
|
||
|
|
||
|
function updateSpatialHash(): void {
|
||
|
// Clear previous hash
|
||
|
spatialHash = {};
|
||
|
|
||
|
// Add all particles to hash
|
||
|
for (const particle of particles) {
|
||
|
addToSpatialHash(particle);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function getNearbyParticles(
|
||
|
x: number,
|
||
|
y: number,
|
||
|
radius: number,
|
||
|
): Particle[] {
|
||
|
const result: Particle[] = [];
|
||
|
const cellRadius = Math.ceil(radius / CELL_SIZE);
|
||
|
|
||
|
const centerCellX = Math.floor(x / CELL_SIZE);
|
||
|
const centerCellY = Math.floor(y / CELL_SIZE);
|
||
|
|
||
|
for (
|
||
|
let cellX = centerCellX - cellRadius;
|
||
|
cellX <= centerCellX + cellRadius;
|
||
|
cellX++
|
||
|
) {
|
||
|
for (
|
||
|
let cellY = centerCellY - cellRadius;
|
||
|
cellY <= centerCellY + cellRadius;
|
||
|
cellY++
|
||
|
) {
|
||
|
const key = `${cellX},${cellY}`;
|
||
|
const cell = spatialHash[key];
|
||
|
|
||
|
if (cell) {
|
||
|
result.push(...cell);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return result;
|
||
|
}
|
||
|
|
||
|
// Main update function
|
||
|
function update(): void {
|
||
|
const now = Date.now();
|
||
|
// Fixed pixel overscan
|
||
|
const minX = -OVERSCAN_PIXELS;
|
||
|
const maxX = width + OVERSCAN_PIXELS;
|
||
|
const minY = -OVERSCAN_PIXELS;
|
||
|
const maxY = height + OVERSCAN_PIXELS;
|
||
|
|
||
|
// Update spatial hash
|
||
|
updateSpatialHash();
|
||
|
|
||
|
// Update forces and remove expired ones
|
||
|
if (forces.length > 40) {
|
||
|
forces = forces.slice(-40);
|
||
|
}
|
||
|
forces = forces.filter((force) => {
|
||
|
force.strength *= 0.95;
|
||
|
force.radius *= 0.95;
|
||
|
return force.strength > 0.001;
|
||
|
});
|
||
|
|
||
|
// Update particles
|
||
|
const newParticles: Particle[] = [];
|
||
|
|
||
|
for (const particle of particles) {
|
||
|
// Apply forces
|
||
|
for (const force of forces) {
|
||
|
const dx = particle.x - force.x;
|
||
|
const dy = particle.y - force.y;
|
||
|
const distSq = dx * dx + dy * dy;
|
||
|
|
||
|
const radius = force.radius * FORCE_RADIUS;
|
||
|
|
||
|
if (distSq < radius * radius) {
|
||
|
const dist = Math.sqrt(distSq);
|
||
|
|
||
|
// Exponential falloff - much more concentrated at center
|
||
|
// (1 - x/R)^n where n controls how sharp the falloff is
|
||
|
const normalizedDist = dist / radius;
|
||
|
const factor = Math.pow(1 - normalizedDist, FORCE_FALLOFF_EXPONENT);
|
||
|
|
||
|
// Calculate force line projection for directional effect
|
||
|
// This makes particles along the force's path experience stronger effect
|
||
|
const dotProduct = (dx * -force.dx) + (dy * -force.dy);
|
||
|
const projectionFactor = Math.max(0, dotProduct / dist);
|
||
|
|
||
|
// Apply the combined factors - stronger directional bias
|
||
|
const finalFactor = factor * force.strength *
|
||
|
(0.1 + 0.9 * projectionFactor);
|
||
|
|
||
|
particle.vx += force.dx * finalFactor;
|
||
|
particle.vy += force.dy * finalFactor;
|
||
|
// charge for the first 100ms
|
||
|
if ((now - force.createdAt) < 100) {
|
||
|
particle.charge = Math.min(
|
||
|
1,
|
||
|
particle.charge + (finalFactor * finalFactor) * 0.2,
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Apply repulsion from nearby particles
|
||
|
const nearby = getNearbyParticles(
|
||
|
particle.x,
|
||
|
particle.y,
|
||
|
REPULSION_RADIUS,
|
||
|
);
|
||
|
|
||
|
for (const other of nearby) {
|
||
|
if (other === particle) continue;
|
||
|
|
||
|
const dx = particle.x - other.x;
|
||
|
const dy = particle.y - other.y;
|
||
|
const distSq = dx * dx + dy * dy;
|
||
|
|
||
|
if (distSq < REPULSION_RADIUS * REPULSION_RADIUS && distSq > 0) {
|
||
|
const dist = Math.sqrt(distSq);
|
||
|
const factor = REPULSION_STRENGTH * (1 - dist / REPULSION_RADIUS);
|
||
|
|
||
|
const fx = dx / dist * factor;
|
||
|
const fy = dy / dist * factor;
|
||
|
|
||
|
particle.vx += fx;
|
||
|
particle.vy += fy;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Apply friction
|
||
|
particle.vx *= FRICTION;
|
||
|
particle.vy *= FRICTION;
|
||
|
|
||
|
// Ensure minimum speed
|
||
|
const speed = Math.sqrt(
|
||
|
particle.vx * particle.vx + particle.vy * particle.vy,
|
||
|
);
|
||
|
if (speed < MIN_SPEED && speed > 0) {
|
||
|
const scale = MIN_SPEED / speed;
|
||
|
particle.vx *= scale;
|
||
|
particle.vy *= scale;
|
||
|
}
|
||
|
|
||
|
// Cap at maximum speed
|
||
|
if (speed > MAX_SPEED) {
|
||
|
const scale = MAX_SPEED / speed;
|
||
|
particle.vx *= scale;
|
||
|
particle.vy *= scale;
|
||
|
}
|
||
|
|
||
|
// Update position
|
||
|
particle.x += particle.vx;
|
||
|
particle.y += particle.vy;
|
||
|
|
||
|
// Decrease charge
|
||
|
particle.charge *= 0.99;
|
||
|
|
||
|
// Check if particle is within extended bounds
|
||
|
if (
|
||
|
particle.x >= minX && particle.x <= maxX &&
|
||
|
particle.y >= minY && particle.y <= maxY
|
||
|
) {
|
||
|
// If outside screen but within overscan, keep it if we need more particles
|
||
|
if (
|
||
|
(particle.x < 0 || particle.x > width ||
|
||
|
particle.y < 0 || particle.y > height) &&
|
||
|
newParticles.length >= targetParticleCount
|
||
|
) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
newParticles.push(particle);
|
||
|
} else {
|
||
|
// Out of bounds, respawn if needed
|
||
|
if (newParticles.length < targetParticleCount) {
|
||
|
newParticles.push(createParticleOnEdge());
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Add more particles if needed
|
||
|
while (newParticles.length < targetParticleCount) {
|
||
|
newParticles.push(createParticleOnEdge());
|
||
|
}
|
||
|
|
||
|
particles = newParticles;
|
||
|
}
|
||
|
|
||
|
// Render function
|
||
|
const mul = isStandalone ? 0.9 : 0.5;
|
||
|
const add = isStandalone ? 0.1 : 0.03;
|
||
|
function render(): void {
|
||
|
if (!ctx) return;
|
||
|
|
||
|
// Clear canvas
|
||
|
ctx.clearRect(0, 0, width, height);
|
||
|
|
||
|
// Draw particles
|
||
|
for (const particle of particles) {
|
||
|
// Only draw if within canvas bounds (plus a small margin)
|
||
|
if (
|
||
|
particle.x >= -PARTICLE_RADIUS &&
|
||
|
particle.x <= width + PARTICLE_RADIUS &&
|
||
|
particle.y >= -PARTICLE_RADIUS && particle.y <= height + PARTICLE_RADIUS
|
||
|
) {
|
||
|
ctx.beginPath();
|
||
|
ctx.arc(particle.x, particle.y, PARTICLE_RADIUS, 0, Math.PI * 2);
|
||
|
|
||
|
// Color based on charge
|
||
|
ctx.fillStyle = "#FFCB1F";
|
||
|
ctx.globalAlpha = (particle.charge * mul + add) * globalOpacity;
|
||
|
ctx.fill();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// // Debug: Draw forces and falloff visualization
|
||
|
// if (ctx) {
|
||
|
// for (const force of forces) {
|
||
|
// const R = force.radius * FORCE_RADIUS;
|
||
|
|
||
|
// // Draw force point
|
||
|
// ctx.beginPath();
|
||
|
// ctx.arc(force.x, force.y, 5, 0, Math.PI * 2);
|
||
|
// ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
|
||
|
// ctx.fill();
|
||
|
|
||
|
// // Draw force direction
|
||
|
// ctx.beginPath();
|
||
|
// ctx.moveTo(force.x, force.y);
|
||
|
// ctx.lineTo(force.x + force.dx * 20, force.y + force.dy * 20);
|
||
|
// ctx.strokeStyle = 'red';
|
||
|
// ctx.stroke();
|
||
|
|
||
|
// // Visualize the falloff curve with rings
|
||
|
// for (let i = 0; i <= 10; i++) {
|
||
|
// const radius = (R * i) / 10;
|
||
|
// const normalizedDist = radius / R;
|
||
|
// const intensity = Math.pow(1 - normalizedDist, FORCE_FALLOFF_EXPONENT);
|
||
|
|
||
|
// ctx.beginPath();
|
||
|
// ctx.arc(force.x, force.y, radius, 0, Math.PI * 2);
|
||
|
// ctx.strokeStyle = `rgba(255, 0, 0, ${intensity * 0.2})`;
|
||
|
// ctx.stroke();
|
||
|
// }
|
||
|
// }
|
||
|
// }
|
||
|
}
|
||
|
|
||
|
// Animation loop
|
||
|
let r = Math.random();
|
||
|
function animate(): void {
|
||
|
globalOpacity = Math.min(1, globalOpacity + 0.03);
|
||
|
update();
|
||
|
render();
|
||
|
|
||
|
if (isRunning) {
|
||
|
animationId = requestAnimationFrame(animate);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Start/stop functions
|
||
|
function start(): void {
|
||
|
if (isRunning) return;
|
||
|
|
||
|
// Calculate target particle count based on canvas size
|
||
|
targetParticleCount = Math.floor(width * height * PARTICLE_DENSITY);
|
||
|
|
||
|
// Clear any existing particles and create new ones with proper spacing
|
||
|
particles = [];
|
||
|
addParticles(targetParticleCount);
|
||
|
|
||
|
isRunning = true;
|
||
|
animate();
|
||
|
}
|
||
|
|
||
|
function stop(): void {
|
||
|
isRunning = false;
|
||
|
|
||
|
if (animationId !== null) {
|
||
|
cancelAnimationFrame(animationId);
|
||
|
animationId = null;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
init();
|
||
|
return cleanup;
|
||
|
};
|