/** * Particle Effects System * Manages blood spray, eating crumbs, mating hearts, floating dandelion seeds, and splash ripples. */ export class ParticleSystem { constructor() { this.particles = []; } reset() { this.particles = []; } update(dt, terrain) { for (let i = this.particles.length - 1; i >= 0; i--) { const p = this.particles[i]; p.life -= dt; if (p.life <= 0) { // If blood particle landed, paint small splatter on terrain if (p.type === 'blood' && p.paintOnDeath && terrain) { terrain.addBloodSplotch(p.x, p.y, p.radius || 2.5); } // If seed particle landed, signal germination callback if (p.type === 'seed' && p.onLand) { p.onLand(p.x, p.y); } this.particles.splice(i, 1); continue; } // Physics integration p.x += p.vx * dt; p.y += p.vy * dt; // Apply drag / gravity based on particle type if (p.drag) { p.vx *= Math.pow(p.drag, dt * 60); p.vy *= Math.pow(p.drag, dt * 60); } if (p.gravity) { p.vy += p.gravity * dt; } // Custom motion if (p.type === 'seed') { p.sway += dt * 4; p.x += Math.sin(p.sway) * 12 * dt; } else if (p.type === 'heart') { p.sway += dt * 3; p.x += Math.sin(p.sway) * 8 * dt; } } } draw(ctx) { for (let i = 0; i < this.particles.length; i++) { const p = this.particles[i]; const alpha = Math.max(0, Math.min(1, p.life / p.maxLife)); ctx.save(); ctx.globalAlpha = alpha; switch (p.type) { case 'blood': { ctx.fillStyle = p.color || '#C91818'; const size = Math.max(1, Math.round(p.size)); ctx.fillRect(Math.round(p.x - size / 2), Math.round(p.y - size / 2), size, size); break; } case 'crumb': { ctx.fillStyle = p.color || '#57B547'; const size = Math.max(1, Math.round(p.size)); ctx.fillRect(Math.round(p.x - size / 2), Math.round(p.y - size / 2), size, size); break; } case 'heart': { // Pixelated little heart ctx.fillStyle = '#FF3366'; const hx = Math.round(p.x); const hy = Math.round(p.y); const s = p.size || 2; // Row 0 ctx.fillRect(hx - s * 2, hy - s, s, s); ctx.fillRect(hx - s, hy - s * 2, s, s); ctx.fillRect(hx + s, hy - s * 2, s, s); ctx.fillRect(hx + s * 2, hy - s, s, s); // Row 1 ctx.fillRect(hx - s * 2, hy, s * 5, s); // Row 2 ctx.fillRect(hx - s, hy + s, s * 3, s); // Row 3 ctx.fillRect(hx, hy + s * 2, s, s); break; } case 'seed': { // Dandelion seed puff ctx.fillStyle = '#FFFFFF'; ctx.fillRect(Math.round(p.x), Math.round(p.y), 3, 3); ctx.fillStyle = '#C2B280'; ctx.fillRect(Math.round(p.x + 1), Math.round(p.y + 3), 1, 3); break; } case 'sparkle': { ctx.fillStyle = p.color || '#FFD700'; const s = p.size || 3; ctx.fillRect(Math.round(p.x - s / 2), Math.round(p.y), s, 1); ctx.fillRect(Math.round(p.x), Math.round(p.y - s / 2), 1, s); break; } case 'splash': { // Expanding ripple const progress = 1 - (p.life / p.maxLife); ctx.strokeStyle = '#8ED6FF'; ctx.lineWidth = 1; ctx.strokeRect( Math.round(p.x - p.radius * progress), Math.round(p.y - p.radius * progress * 0.5), Math.round(p.radius * progress * 2), Math.round(p.radius * progress) ); break; } } ctx.restore(); } } // --- Emitter Methods --- emitBloodSpray(x, y, angle, intensity = 14) { const baseColors = ['#D41414', '#A80808', '#850000', '#FF3636']; for (let i = 0; i < intensity; i++) { const spread = (Math.random() - 0.5) * 1.2; const speed = 70 + Math.random() * 140; const particleAngle = angle + spread; const life = 0.35 + Math.random() * 0.45; this.particles.push({ type: 'blood', x, y, vx: Math.cos(particleAngle) * speed, vy: Math.sin(particleAngle) * speed, drag: 0.88, gravity: 120, size: 2 + Math.floor(Math.random() * 3), color: baseColors[Math.floor(Math.random() * baseColors.length)], life, maxLife: life, paintOnDeath: Math.random() < 0.75, radius: 3 + Math.random() * 4, }); } } emitEatingCrumbs(x, y, color = '#5CB85C', count = 5) { for (let i = 0; i < count; i++) { const speed = 20 + Math.random() * 40; const angle = Math.random() * Math.PI * 2; const life = 0.3 + Math.random() * 0.3; this.particles.push({ type: 'crumb', x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 15, drag: 0.85, gravity: 80, size: 2, color, life, maxLife: life, }); } } emitHeart(x, y) { const life = 1.2; this.particles.push({ type: 'heart', x, y: y - 8, vx: (Math.random() - 0.5) * 15, vy: -25 - Math.random() * 20, sway: Math.random() * Math.PI, drag: 0.96, size: 2, life, maxLife: life, }); } emitSeed(x, y, targetX, targetY, onLand) { const dx = targetX - x; const dy = targetY - y; const dist = Math.hypot(dx, dy); const speed = 35 + Math.random() * 25; const life = Math.max(1.2, dist / speed); this.particles.push({ type: 'seed', x, y, vx: (dx / dist) * speed, vy: (dy / dist) * speed, sway: Math.random() * 6, life, maxLife: life, onLand, }); } emitGrowthSparkles(x, y, count = 10) { const colors = ['#FFDF00', '#FFFA8A', '#00FFFF', '#FF99EE']; for (let i = 0; i < count; i++) { const angle = Math.random() * Math.PI * 2; const speed = 30 + Math.random() * 50; const life = 0.5 + Math.random() * 0.4; this.particles.push({ type: 'sparkle', x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, drag: 0.9, size: 3 + Math.random() * 3, color: colors[Math.floor(Math.random() * colors.length)], life, maxLife: life, }); } } emitSplash(x, y) { const life = 0.25; this.particles.push({ type: 'splash', x, y, vx: 0, vy: 0, radius: 4 + Math.random() * 4, life, maxLife: life, }); } }