import { Application, Container, Graphics, Ticker } from 'pixi.js'; // ============================================================================ // Constants // ============================================================================ const COLORS = { gold: 0xf0c239, goldLight: 0xffd700, goldDark: 0xd4a84b, red: 0xc21f30, redLight: 0xe63946, }; // Easing functions for smooth animation const Easing = { // Smooth acceleration then deceleration easeInOutCubic: (t: number): number => { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }, // Fast start, slow end easeOutQuart: (t: number): number => { return 1 - Math.pow(1 - t, 4); }, // Slow start, fast end easeInQuad: (t: number): number => { return t * t; }, // Bounce at end easeOutBack: (t: number): number => { const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); }, }; // ============================================================================ // Types // ============================================================================ interface VoteParticle { x: number; y: number; targetX: number; targetY: number; startX: number; startY: number; controlX: number; controlY: number; progress: number; speed: number; size: number; alpha: number; glowSize: number; trailPoints: Array<{ x: number; y: number; alpha: number }>; phase: 'flying' | 'impact' | 'done'; impactTime: number; } interface ImpactEffect { x: number; y: number; particles: Array<{ angle: number; speed: number; distance: number; size: number; alpha: number; }>; progress: number; phase: 'burst' | 'fade'; } interface TargetGlow { x: number; y: number; intensity: number; pulsePhase: number; baseIntensity: number; } export interface ProgramTarget { id: string; x: number; y: number; name: string; } // ============================================================================ // VoteParticleSystem Class // ============================================================================ export class VoteParticleSystem { private app: Application; private container: Container; private particleLayer: Graphics; private glowLayer: Graphics; private impactLayer: Graphics; private particles: VoteParticle[] = []; private impacts: ImpactEffect[] = []; private targetGlows: Map = new Map(); private programTargets: Map = new Map(); private isDestroyed = false; private time = 0; // Configuration private readonly MAX_PARTICLES = 200; private readonly TRAIL_LENGTH = 12; private readonly PARTICLE_DURATION = 1500; // ms private readonly IMPACT_DURATION = 600; // ms constructor() { this.app = new Application(); this.container = new Container(); this.particleLayer = new Graphics(); this.glowLayer = new Graphics(); this.impactLayer = new Graphics(); } async init(canvas: HTMLCanvasElement): Promise { await this.app.init({ canvas, width: window.innerWidth, height: window.innerHeight, backgroundAlpha: 0, antialias: true, resolution: window.devicePixelRatio || 1, autoDensity: true, }); // Layer order: glow (back) -> particles -> impacts (front) this.container.addChild(this.glowLayer); this.container.addChild(this.particleLayer); this.container.addChild(this.impactLayer); this.app.stage.addChild(this.container); // Start animation loop this.app.ticker.add(this.update.bind(this)); // Handle resize window.addEventListener('resize', this.handleResize.bind(this)); } // ============================================================================ // Public API // ============================================================================ /** * Register a program target position for vote particles to fly towards */ registerTarget(target: ProgramTarget): void { this.programTargets.set(target.id, target); // Initialize glow state if (!this.targetGlows.has(target.id)) { this.targetGlows.set(target.id, { x: target.x, y: target.y, intensity: 0, pulsePhase: Math.random() * Math.PI * 2, baseIntensity: 0, }); } } /** * Update target position (for responsive layouts) */ updateTargetPosition(id: string, x: number, y: number): void { const target = this.programTargets.get(id); if (target) { target.x = x; target.y = y; } const glow = this.targetGlows.get(id); if (glow) { glow.x = x; glow.y = y; } } /** * Spawn a vote particle flying towards a program target */ spawnVoteParticle(targetId: string): void { if (this.isDestroyed) return; if (this.particles.length >= this.MAX_PARTICLES) return; const target = this.programTargets.get(targetId); if (!target) { console.warn(`[VoteParticle] Unknown target: ${targetId}`); return; } // Spawn from bottom edge with random horizontal position const screenWidth = this.app.screen.width; const screenHeight = this.app.screen.height; const startX = Math.random() * screenWidth; const startY = screenHeight + 20; // Calculate bezier control point for curved trajectory // Control point is offset to create an arc const midX = (startX + target.x) / 2; const midY = (startY + target.y) / 2; const perpX = -(target.y - startY); const perpY = target.x - startX; const perpLen = Math.sqrt(perpX * perpX + perpY * perpY); const curveAmount = (Math.random() - 0.5) * 300; // Random curve direction const controlX = midX + (perpX / perpLen) * curveAmount; const controlY = midY + (perpY / perpLen) * curveAmount; const particle: VoteParticle = { x: startX, y: startY, targetX: target.x, targetY: target.y, startX, startY, controlX, controlY, progress: 0, speed: 0.8 + Math.random() * 0.4, // Vary speed slightly size: 4 + Math.random() * 3, alpha: 1, glowSize: 15 + Math.random() * 10, trailPoints: [], phase: 'flying', impactTime: 0, }; this.particles.push(particle); } /** * Spawn multiple particles at once (for burst effect) */ spawnVoteParticleBurst(targetId: string, count: number = 3): void { for (let i = 0; i < count; i++) { setTimeout(() => { this.spawnVoteParticle(targetId); }, i * 50); // Stagger spawns } } /** * Increase heat/glow intensity for a target */ addHeat(targetId: string, amount: number = 0.3): void { const glow = this.targetGlows.get(targetId); if (glow) { glow.baseIntensity = Math.min(1, glow.baseIntensity + amount); glow.intensity = Math.min(1.5, glow.intensity + amount * 2); // Pulse effect } } // ============================================================================ // Animation Loop // ============================================================================ private update(ticker: Ticker): void { if (this.isDestroyed) return; this.time += ticker.deltaMS; // Clear all layers this.particleLayer.clear(); this.glowLayer.clear(); this.impactLayer.clear(); // Update and draw target glows this.updateTargetGlows(ticker.deltaMS); // Update and draw particles this.updateParticles(ticker.deltaMS); // Update and draw impact effects this.updateImpacts(ticker.deltaMS); } private updateTargetGlows(deltaMs: number): void { this.targetGlows.forEach((glow, id) => { // Decay intensity over time glow.intensity = Math.max(glow.baseIntensity, glow.intensity - deltaMs * 0.002); glow.baseIntensity = Math.max(0, glow.baseIntensity - deltaMs * 0.0002); // Pulse animation glow.pulsePhase += deltaMs * 0.003; const pulse = Math.sin(glow.pulsePhase) * 0.1 + 1; if (glow.intensity > 0.01) { this.drawTargetGlow(glow, pulse); } }); } private drawTargetGlow(glow: TargetGlow, pulse: number): void { const intensity = glow.intensity * pulse; const maxRadius = 60 + intensity * 40; // Draw multiple concentric circles for glow effect for (let i = 5; i >= 0; i--) { const ratio = i / 5; const radius = maxRadius * (0.3 + ratio * 0.7); const alpha = intensity * (1 - ratio) * 0.4; this.glowLayer.circle(glow.x, glow.y, radius); this.glowLayer.fill({ color: COLORS.gold, alpha }); } // Inner bright core this.glowLayer.circle(glow.x, glow.y, 8 + intensity * 5); this.glowLayer.fill({ color: COLORS.goldLight, alpha: intensity * 0.8 }); } private updateParticles(deltaMs: number): void { this.particles = this.particles.filter((p) => { if (p.phase === 'flying') { // Update progress along bezier curve p.progress += (deltaMs / this.PARTICLE_DURATION) * p.speed; if (p.progress >= 1) { // Reached target - trigger impact p.phase = 'impact'; p.impactTime = 0; this.triggerImpact(p.targetX, p.targetY); // Find target and add heat for (const [id, target] of this.programTargets) { if (target.x === p.targetX && target.y === p.targetY) { this.addHeat(id); break; } } return false; } // Calculate position on quadratic bezier curve with easing const t = Easing.easeInOutCubic(p.progress); const invT = 1 - t; p.x = invT * invT * p.startX + 2 * invT * t * p.controlX + t * t * p.targetX; p.y = invT * invT * p.startY + 2 * invT * t * p.controlY + t * t * p.targetY; // Update trail p.trailPoints.unshift({ x: p.x, y: p.y, alpha: 1 }); if (p.trailPoints.length > this.TRAIL_LENGTH) { p.trailPoints.pop(); } // Fade trail points p.trailPoints.forEach((point, i) => { point.alpha = 1 - i / this.TRAIL_LENGTH; }); // Draw particle this.drawParticle(p); return true; } return false; }); } private drawParticle(p: VoteParticle): void { // Draw trail p.trailPoints.forEach((point, i) => { const size = p.size * (1 - i / this.TRAIL_LENGTH * 0.7); const alpha = point.alpha * 0.6; this.particleLayer.circle(point.x, point.y, size); this.particleLayer.fill({ color: COLORS.gold, alpha }); }); // Draw glow around particle for (let i = 3; i >= 0; i--) { const ratio = i / 3; const radius = p.glowSize * (0.3 + ratio * 0.7); const alpha = (1 - ratio) * 0.3 * p.alpha; this.particleLayer.circle(p.x, p.y, radius); this.particleLayer.fill({ color: COLORS.gold, alpha }); } // Draw bright core this.particleLayer.circle(p.x, p.y, p.size); this.particleLayer.fill({ color: COLORS.goldLight, alpha: p.alpha }); // Inner white hot center this.particleLayer.circle(p.x, p.y, p.size * 0.5); this.particleLayer.fill({ color: 0xffffff, alpha: p.alpha * 0.8 }); } private triggerImpact(x: number, y: number): void { const particleCount = 12 + Math.floor(Math.random() * 8); const impactParticles: ImpactEffect['particles'] = []; for (let i = 0; i < particleCount; i++) { const angle = (Math.PI * 2 * i) / particleCount + Math.random() * 0.3; impactParticles.push({ angle, speed: 2 + Math.random() * 3, distance: 0, size: 2 + Math.random() * 3, alpha: 1, }); } this.impacts.push({ x, y, particles: impactParticles, progress: 0, phase: 'burst', }); } private updateImpacts(deltaMs: number): void { this.impacts = this.impacts.filter((impact) => { impact.progress += deltaMs / this.IMPACT_DURATION; if (impact.progress >= 1) { return false; } // Update impact particles const t = Easing.easeOutQuart(impact.progress); impact.particles.forEach((p) => { p.distance = t * 50 * p.speed; p.alpha = 1 - Easing.easeInQuad(impact.progress); p.size = p.size * (1 + t * 0.5); }); // Draw impact this.drawImpact(impact); return true; }); } private drawImpact(impact: ImpactEffect): void { // Draw central flash const flashAlpha = (1 - impact.progress) * 0.8; const flashSize = 20 + impact.progress * 30; for (let i = 3; i >= 0; i--) { const ratio = i / 3; const radius = flashSize * (0.3 + ratio * 0.7); const alpha = flashAlpha * (1 - ratio); this.impactLayer.circle(impact.x, impact.y, radius); this.impactLayer.fill({ color: COLORS.goldLight, alpha }); } // Draw burst particles impact.particles.forEach((p) => { const px = impact.x + Math.cos(p.angle) * p.distance; const py = impact.y + Math.sin(p.angle) * p.distance; // Particle glow this.impactLayer.circle(px, py, p.size * 2); this.impactLayer.fill({ color: COLORS.gold, alpha: p.alpha * 0.3 }); // Particle core this.impactLayer.circle(px, py, p.size); this.impactLayer.fill({ color: COLORS.goldLight, alpha: p.alpha }); }); } // ============================================================================ // Lifecycle // ============================================================================ private handleResize(): void { if (this.isDestroyed) return; this.app.renderer.resize(window.innerWidth, window.innerHeight); } destroy(): void { this.isDestroyed = true; window.removeEventListener('resize', this.handleResize.bind(this)); this.particles = []; this.impacts = []; this.targetGlows.clear(); this.programTargets.clear(); this.app.destroy(true, { children: true }); } }