import { BurstPoint } from './burstOrigin'; import { BurstMotionStyle, EmojiBurstProfile, getEmojiBurstProfile, pickParticleEmoji, sampleBurstAngle, } from './emojiParticleProfiles'; /** Particles spawn over this window. */ export const BURST_SPAWN_MS = 500; /** Global fade runs from 0.5s → 2s after burst start. */ export const BURST_FADE_START_MS = 500; export const BURST_FADE_END_MS = 2000; export const BURST_TOTAL_MS = BURST_FADE_END_MS; export type BurstParticle = { burstId: string; burstStartMs: number; emoji: string; fontSize: number; spawnX: number; spawnY: number; maskRadius: number; x: number; y: number; vx: number; vy: number; bornAt: number; rotation: number; spin: number; peakScale: number; style: BurstMotionStyle; gravity: number; drag: number; phase: number; twinkle?: boolean; wobble?: boolean; orbitRadius: number; orbitSpeed: number; orbitAngle: number; bounceDamping: number; bouncesLeft: number; }; const EMOJI_CACHE_PX = 64; const EMOJI_CACHE_SCALE = 2; const MAX_DPR = 2; const POP_IN_MS = 70; const emojiCanvasCache = new Map(); function easeOutBack(t: number): number { const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2; } function easeOutCubic(t: number): number { return 1 - (1 - t) ** 3; } function lerp(min: number, max: number): number { return min + Math.random() * (max - min); } function getEmojiCanvas(emoji: string): HTMLCanvasElement { const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); const cacheKey = `${emoji}:${dpr}`; const cached = emojiCanvasCache.get(cacheKey); if (cached) return cached; const fontSize = Math.ceil(EMOJI_CACHE_PX * dpr); const size = Math.ceil(fontSize * EMOJI_CACHE_SCALE); const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const context = canvas.getContext('2d'); if (context) { context.textAlign = 'center'; context.textBaseline = 'middle'; context.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; context.fillText(emoji, size / 2, size / 2); } emojiCanvasCache.set(cacheKey, canvas); return canvas; } function emergeOpacity( x: number, y: number, spawnX: number, spawnY: number, maskRadius: number ): number { const dist = Math.hypot(x - spawnX, y - spawnY); const inner = maskRadius * 0.55; const outer = maskRadius * 1.05; if (dist <= inner) return 0; if (dist >= outer) return 1; return (dist - inner) / (outer - inner); } function spawnParticle( particles: BurstParticle[], burstId: string, burstStartMs: number, point: BurstPoint, primaryEmoji: string, profile: EmojiBurstProfile, bornAt: number, options?: { hero?: boolean } ) { const hero = options?.hero ?? false; const angle = sampleBurstAngle(profile); const speed = hero ? lerp(profile.speedMin * 0.65, profile.speedMax * 0.55) : lerp(profile.speedMin, profile.speedMax); const launchUp = lerp(profile.launchUpMin, profile.launchUpMax); const maskRadius = point.maskRadius ?? 28; const jitter = hero ? 3 : 12; const emoji = pickParticleEmoji(profile, primaryEmoji); let vx = Math.cos(angle) * speed + (Math.random() - 0.5) * 60; let vy = Math.sin(angle) * speed - launchUp; if (profile.style === 'spiral') { vx *= 0.35; vy *= 0.35; } if (profile.style === 'sparkle') { vx *= 0.75; vy *= 0.75; } particles.push({ burstId, burstStartMs, emoji, fontSize: hero ? lerp(profile.heroFontSizeMin, profile.heroFontSizeMax) : lerp(profile.fontSizeMin, profile.fontSizeMax), spawnX: point.x, spawnY: point.y, maskRadius, x: point.x + (Math.random() - 0.5) * jitter, y: point.y + (Math.random() - 0.5) * jitter, vx, vy, bornAt, rotation: (Math.random() - 0.5) * 40, spin: lerp(profile.spinMin, profile.spinMax), peakScale: hero ? 1.1 + Math.random() * 0.2 : 0.9 + Math.random() * 0.4, style: profile.style, gravity: profile.gravity, drag: profile.drag, phase: Math.random() * Math.PI * 2, twinkle: profile.twinkle, wobble: profile.wobble, orbitRadius: hero ? 8 : 14 + Math.random() * 28, orbitSpeed: (Math.random() < 0.5 ? -1 : 1) * (1.8 + Math.random() * 2.4), orbitAngle: Math.random() * Math.PI * 2, bounceDamping: 0.42 + Math.random() * 0.18, bouncesLeft: profile.style === 'bounce' ? 2 : 0, }); } /** Fountain from behind the emoji for 0.5s, fade 0.5s→2s. */ export function spawnEmojiBurst( particles: BurstParticle[], burstId: string, point: BurstPoint, emoji: string, now = performance.now() ) { const profile = getEmojiBurstProfile(emoji); const burstStartMs = now; spawnParticle(particles, burstId, burstStartMs, point, emoji, profile, burstStartMs, { hero: true, }); const particleCount = profile.particleCount; for (let i = 0; i < particleCount; i += 1) { const slot = i / particleCount; const bornAt = burstStartMs + slot * BURST_SPAWN_MS + Math.random() * (BURST_SPAWN_MS / particleCount); spawnParticle(particles, burstId, burstStartMs, point, emoji, profile, bornAt); } } function stepSpiralParticle(particle: BurstParticle, dtSeconds: number) { particle.orbitRadius += 42 * dtSeconds; particle.orbitAngle += particle.orbitSpeed * dtSeconds; particle.x = particle.spawnX + Math.cos(particle.orbitAngle) * particle.orbitRadius; particle.y = particle.spawnY + Math.sin(particle.orbitAngle) * particle.orbitRadius * 0.65 + particle.vy * dtSeconds * 8; particle.vy += particle.gravity * dtSeconds * 0.35; particle.rotation += particle.spin * dtSeconds; } function stepBounceParticle(particle: BurstParticle, dtSeconds: number, dragFactor: number) { particle.vy += particle.gravity * dtSeconds; particle.vx *= dragFactor; particle.vy *= dragFactor; particle.x += particle.vx * dtSeconds; particle.y += particle.vy * dtSeconds; particle.rotation += particle.spin * dtSeconds; if (particle.bouncesLeft > 0 && particle.vy > 0 && particle.y >= particle.spawnY + 6) { particle.y = particle.spawnY + 6; particle.vy = -Math.abs(particle.vy) * particle.bounceDamping; particle.vx *= 0.82; particle.bouncesLeft -= 1; particle.spin += (Math.random() - 0.5) * 120; } } function stepDefaultParticle(particle: BurstParticle, dtSeconds: number, dragFactor: number) { particle.vy += particle.gravity * dtSeconds; particle.vx *= dragFactor; particle.vy *= dragFactor; particle.x += particle.vx * dtSeconds; particle.y += particle.vy * dtSeconds; particle.rotation += particle.spin * dtSeconds; } export function stepBurstParticles( particles: BurstParticle[], now: number, dtSeconds: number ): void { for (let i = particles.length - 1; i >= 0; i -= 1) { const particle = particles[i]; const burstElapsed = now - particle.burstStartMs; if (burstElapsed > BURST_TOTAL_MS) { particles[i] = particles[particles.length - 1]; particles.pop(); continue; } const ageMs = now - particle.bornAt; if (ageMs < 0) continue; const dragFactor = particle.drag ** (dtSeconds * 60); switch (particle.style) { case 'spiral': stepSpiralParticle(particle, dtSeconds); break; case 'bounce': stepBounceParticle(particle, dtSeconds, dragFactor); break; default: stepDefaultParticle(particle, dtSeconds, dragFactor); break; } particle.phase += dtSeconds * (particle.twinkle ? 9 : 5); } } type DrawState = { x: number; y: number; scale: number; opacity: number; rotation: number; }; function particleDrawState(particle: BurstParticle, now: number): DrawState | null { const burstElapsed = now - particle.burstStartMs; if (burstElapsed > BURST_TOTAL_MS) return null; const ageMs = now - particle.bornAt; if (ageMs < 0) return null; let scale = particle.peakScale; let opacity = emergeOpacity( particle.x, particle.y, particle.spawnX, particle.spawnY, particle.maskRadius ); if (opacity <= 0) return null; if (ageMs < POP_IN_MS) { const pop = easeOutBack(ageMs / POP_IN_MS); scale = 0.1 + pop * particle.peakScale; opacity *= pop; } if (particle.twinkle) { opacity *= 0.55 + 0.45 * Math.sin(particle.phase * 1.6); } if (particle.wobble) { scale *= 1 + 0.12 * Math.sin(particle.phase * 2.2); } if (particle.style === 'sparkle' && burstElapsed < BURST_FADE_START_MS) { scale *= 0.85 + 0.3 * Math.sin(particle.phase * 3); } if (burstElapsed >= BURST_FADE_START_MS) { const fadeT = (burstElapsed - BURST_FADE_START_MS) / (BURST_FADE_END_MS - BURST_FADE_START_MS); opacity *= 1 - easeOutCubic(Math.min(1, fadeT)); scale *= 1 - easeOutCubic(Math.min(1, fadeT)) * 0.2; } if (opacity <= 0.02) return null; return { x: particle.x, y: particle.y, scale, opacity, rotation: particle.rotation, }; } export function drawBurstParticles( context: CanvasRenderingContext2D, particles: BurstParticle[], dpr: number, now: number ) { for (const particle of particles) { const state = particleDrawState(particle, now); if (!state) continue; context.globalAlpha = state.opacity; const emojiCanvas = getEmojiCanvas(particle.emoji); const drawSize = particle.fontSize * state.scale * EMOJI_CACHE_SCALE; const halfSize = drawSize / 2; const radians = (state.rotation * Math.PI) / 180; const cos = Math.cos(radians) * dpr; const sin = Math.sin(radians) * dpr; context.setTransform(cos, sin, -sin, cos, state.x * dpr, state.y * dpr); context.drawImage(emojiCanvas, -halfSize, -halfSize, drawSize, drawSize); } context.globalAlpha = 1; context.setTransform(dpr, 0, 0, dpr, 0, 0); } export function hasBurstParticles(particles: BurstParticle[], burstId: string): boolean { return particles.some((particle) => particle.burstId === burstId); }