import type { AnimationEngine } from "@/lib/animations/types"; interface ConfettiParticle { x: number; y: number; vx: number; vy: number; r: number; color: [number, number, number]; baseColor: [number, number, number]; opacity: number; dop: number; elevation: number; targetElevation: number; staggerDelay: number; burst: boolean; } const BASE_CONFETTI = 385; const BASE_AREA = 1920 * 1080; const PI_2 = 2 * Math.PI; const TARGET_FPS = 60; const SPEED_FACTOR = 0.15; const STAGGER_INTERVAL = 12; const COLOR_LERP_SPEED = 0.02; const MOUSE_INFLUENCE_RADIUS = 150; const ELEVATION_FACTOR = 6; const ELEVATION_LERP_SPEED = 0.05; const COLOR_SHIFT_AMOUNT = 30; const SHADOW_OFFSET_RATIO = 1.1; function range(a: number, b: number): number { return (b - a) * Math.random() + a; } export class ConfettiEngine implements AnimationEngine { id = "confetti"; name = "Confetti"; private particles: ConfettiParticle[] = []; private palette: [number, number, number][] = []; private width = 0; private height = 0; private mouseX = -1000; private mouseY = -1000; private mouseXNorm = 0.5; private elapsed = 0; private exiting = false; init( width: number, height: number, palette: [number, number, number][], _bgColor: string ): void { this.width = width; this.height = height; this.palette = palette; this.elapsed = 0; this.mouseXNorm = 0.5; this.initParticles(); } beginExit(): void { if (this.exiting) return; this.exiting = true; // Stagger fade-out over 3 seconds for (let i = 0; i < this.particles.length; i++) { const p = this.particles[i]; p.staggerDelay = -1; // ensure visible // Random delay before fade starts, stored as negative dop const delay = Math.random() * 3000; setTimeout(() => { p.dop = -0.02; }, delay); } } isExitComplete(): boolean { if (!this.exiting) return false; for (let i = 0; i < this.particles.length; i++) { if (this.particles[i].opacity > 0.01) return false; } return true; } cleanup(): void { this.particles = []; } private randomColor(): [number, number, number] { return this.palette[Math.floor(Math.random() * this.palette.length)]; } private getParticleCount(): number { const area = this.width * this.height; return Math.max(20, Math.round(BASE_CONFETTI * (area / BASE_AREA))); } private initParticles(): void { this.particles = []; const count = this.getParticleCount(); for (let i = 0; i < count; i++) { const baseColor = this.randomColor(); const r = ~~range(3, 8); this.particles.push({ x: range(-r * 2, this.width - r * 2), y: range(-20, this.height - r * 2), vx: (range(0, 2) + 8 * 0.5 - 5) * SPEED_FACTOR, vy: (0.7 * r + range(-1, 1)) * SPEED_FACTOR, r, color: [...baseColor], baseColor, opacity: 0, dop: 0.03 * range(1, 4) * SPEED_FACTOR, elevation: 0, targetElevation: 0, staggerDelay: i * STAGGER_INTERVAL + range(0, STAGGER_INTERVAL), burst: false, }); } } private replaceParticle(p: ConfettiParticle): void { p.opacity = 0; p.dop = 0.03 * range(1, 4) * SPEED_FACTOR; p.x = range(-p.r * 2, this.width - p.r * 2); p.y = range(-20, -p.r * 2); p.vx = (range(0, 2) + 8 * this.mouseXNorm - 5) * SPEED_FACTOR; p.vy = (0.7 * p.r + range(-1, 1)) * SPEED_FACTOR; p.elevation = 0; p.targetElevation = 0; p.baseColor = this.randomColor(); p.color = [...p.baseColor]; p.burst = false; } update(deltaTime: number): void { const dt = deltaTime / (1000 / TARGET_FPS); this.elapsed += deltaTime; const mouseX = this.mouseX; const mouseY = this.mouseY; for (let i = 0; i < this.particles.length; i++) { const p = this.particles[i]; // Stagger gate if (p.staggerDelay >= 0) { if (this.elapsed >= p.staggerDelay) { p.staggerDelay = -1; } else { continue; } } // Gravity (capped so falling particles don't accelerate) const maxVy = (0.7 * p.r + 1) * SPEED_FACTOR; if (p.vy < maxVy) { p.vy = Math.min(p.vy + 0.02 * dt, maxVy); } // Position update p.x += p.vx * dt; p.y += p.vy * dt; // Fade in, or fade out during exit if (this.exiting && p.dop < 0) { p.opacity += p.dop * dt; if (p.opacity < 0) p.opacity = 0; } else if (p.opacity < 1) { p.opacity += Math.abs(p.dop) * dt; if (p.opacity > 1) p.opacity = 1; } // Past the bottom: burst particles removed, base particles recycle (or remove during exit) if (p.y > this.height + p.r) { if (p.burst || this.exiting) { this.particles.splice(i, 1); i--; } else { this.replaceParticle(p); } continue; } // Horizontal wrap const xmax = this.width - p.r; if (p.x < 0 || p.x > xmax) { p.x = ((p.x % xmax) + xmax) % xmax; } // Mouse proximity elevation const dx = p.x - mouseX; const dy = p.y - mouseY; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < MOUSE_INFLUENCE_RADIUS && p.opacity > 0.1) { const influenceFactor = Math.cos( (dist / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2) ); p.targetElevation = ELEVATION_FACTOR * influenceFactor * influenceFactor; const shift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5; p.color = [ Math.min(255, Math.max(0, p.baseColor[0] + shift)), Math.min(255, Math.max(0, p.baseColor[1] + shift)), Math.min(255, Math.max(0, p.baseColor[2] + shift)), ]; } else { p.targetElevation = 0; p.color[0] += (p.baseColor[0] - p.color[0]) * 0.1; p.color[1] += (p.baseColor[1] - p.color[1]) * 0.1; p.color[2] += (p.baseColor[2] - p.color[2]) * 0.1; } // Elevation lerp p.elevation += (p.targetElevation - p.elevation) * ELEVATION_LERP_SPEED * dt; } } render( ctx: CanvasRenderingContext2D, _width: number, _height: number ): void { for (let i = 0; i < this.particles.length; i++) { const p = this.particles[i]; if (p.opacity <= 0.01 || p.staggerDelay >= 0) continue; const drawX = ~~p.x; const drawY = ~~p.y - p.elevation; const [r, g, b] = p.color; // Shadow if (p.elevation > 0.5) { const shadowAlpha = 0.2 * (p.elevation / ELEVATION_FACTOR) * p.opacity; ctx.globalAlpha = shadowAlpha; ctx.fillStyle = "rgb(0,0,0)"; ctx.shadowBlur = 2; ctx.shadowColor = "rgba(0,0,0,0.1)"; ctx.beginPath(); ctx.arc( drawX, drawY + p.elevation * SHADOW_OFFSET_RATIO, p.r, 0, PI_2 ); ctx.fill(); ctx.shadowBlur = 0; ctx.shadowColor = "transparent"; } // Main circle ctx.globalAlpha = p.opacity; ctx.fillStyle = `rgb(${r},${g},${b})`; ctx.beginPath(); ctx.arc(drawX, drawY, p.r, 0, PI_2); ctx.fill(); // Highlight on elevated particles if (p.elevation > 0.5) { const highlightAlpha = 0.1 * (p.elevation / ELEVATION_FACTOR) * p.opacity; ctx.globalAlpha = highlightAlpha; ctx.fillStyle = "rgb(255,255,255)"; ctx.beginPath(); ctx.arc(drawX, drawY, p.r, Math.PI, 0); ctx.fill(); } } ctx.globalAlpha = 1; } handleResize(width: number, height: number): void { this.width = width; this.height = height; this.elapsed = 0; this.initParticles(); } handleMouseMove(x: number, y: number, _isDown: boolean): void { this.mouseX = x; this.mouseY = y; if (this.width > 0) { this.mouseXNorm = Math.max(0, Math.min(1, x / this.width)); } } handleMouseDown(x: number, y: number): void { const count = 12; for (let i = 0; i < count; i++) { const baseColor = this.randomColor(); const r = ~~range(3, 8); const angle = (i / count) * PI_2 + range(-0.3, 0.3); const speed = range(0.3, 1.2); this.particles.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, r, color: [...baseColor], baseColor, opacity: 1, dop: this.exiting ? -0.02 : 0, elevation: 0, targetElevation: 0, staggerDelay: -1, burst: true, }); } } handleMouseUp(): void {} handleMouseLeave(): void { this.mouseX = -1000; this.mouseY = -1000; this.mouseXNorm = 0.5; } updatePalette(palette: [number, number, number][], _bgColor: string): void { this.palette = palette; for (let i = 0; i < this.particles.length; i++) { this.particles[i].baseColor = palette[i % palette.length]; } } }