/** * Old-school fullscreen firework fountain — no physics engine. * Position/velocity arrays, gravity, floor bounce, settle into a pile. * Same vibe as late-90s/MSN page effects, just with emoji. */ import { BurstPoint } from './burstOrigin'; import { pickParticleEmoji, sampleBurstAngle, type EmojiBurstProfile, } from './emojiParticleProfiles'; const EMOJI_CACHE_PX = 40; const EMOJI_CACHE_SCALE = 2; const SPAWN_WINDOW_MS = 2200; const SETTLE_HOLD_MS = 7800; const FADE_MS = 800; export const FIREWORK_TOTAL_MS = SETTLE_HOLD_MS + FADE_MS; const SPARKLE_IN_MS = 120; const MAX_SPAWNS_PER_FRAME = 6; const FLOOR_PAD = 8; const BOUNCE = 0.38; const DRAG = 0.992; const SETTLE_SPEED = 55; const PILE_CELL = 22; type SpawnItem = { atMs: number; emoji: string; fontSize: number; }; export type FireworkParticle = { x: number; y: number; vx: number; vy: number; spin: number; angle: number; emoji: string; emojiCanvas: HTMLCanvasElement; drawSize: number; halfSize: number; bornAt: number; settled: boolean; }; type HeroFace = { emoji: string; emojiCanvas: HTMLCanvasElement; /** Target CSS pixel size of the glyph (matches jumbo font-size). */ drawSize: number; halfSize: number; /** canvasPx / fontPx — draw box is drawSize * canvasScale so the glyph isn't cropped. */ canvasScale: number; morphed: boolean; }; export type FireworkSim = { particles: FireworkParticle[]; profile: EmojiBurstProfile; primaryEmoji: string; morphAtMs: number | null; /** When to drop the stand-in hero and reveal the real jumbo again. */ heroUntilMs: number | null; hero: HeroFace | null; width: number; height: number; origin: BurstPoint; startMs: number; spawnPlan: SpawnItem[]; spawnCursor: number; floorY: number; /** Column stack heights for the settled pile (in cells). */ pileCols: Uint16Array; pileCanvas: HTMLCanvasElement | null; pileCtx: CanvasRenderingContext2D | null; flyingCount: number; }; const emojiCanvasCache = new Map(); function lerp(min: number, max: number): number { return min + Math.random() * (max - min); } const HERO_PAD_RATIO = 0.24; type EmojiBitmap = { canvas: HTMLCanvasElement; /** Multiply CSS glyph size by this when drawImage'ing to keep padding uncropped. */ canvasScale: number; }; function getEmojiCanvas(emoji: string, tintColor?: string, cssPx?: number): HTMLCanvasElement { return getEmojiBitmap(emoji, tintColor, cssPx, 0).canvas; } function getEmojiBitmap( emoji: string, tintColor: string | undefined, cssPx: number | undefined, padRatio: number ): EmojiBitmap { const targetCss = Math.max(16, Math.round(cssPx ?? EMOJI_CACHE_PX)); const dpr = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 2) : 1; const fontPx = Math.round(targetCss * dpr); const pad = Math.ceil(fontPx * padRatio); const size = fontPx + pad * 2; const cacheKey = `${emoji}|px:${fontPx}|pad:${pad}|tint:${tintColor ?? ''}`; const cached = emojiCanvasCache.get(cacheKey); if (cached) { return { canvas: cached, canvasScale: size / fontPx }; } const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); if (ctx) { ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.imageSmoothingEnabled = false; ctx.font = `${fontPx}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; // Slight optical nudge — color emoji fonts sit high in the em box. ctx.fillText(emoji, size / 2, size / 2 + fontPx * 0.06); if (tintColor) { ctx.globalCompositeOperation = 'source-atop'; ctx.fillStyle = tintColor; ctx.fillRect(0, 0, size, size); ctx.globalCompositeOperation = 'source-over'; } } emojiCanvasCache.set(cacheKey, canvas); return { canvas, canvasScale: size / Math.max(fontPx, 1) }; } function setHeroEmoji(hero: HeroFace, emoji: string) { const bitmap = getEmojiBitmap(emoji, undefined, hero.drawSize, HERO_PAD_RATIO); hero.emoji = emoji; hero.emojiCanvas = bitmap.canvas; hero.canvasScale = bitmap.canvasScale; } function setHeroSize(hero: HeroFace, cssPx: number) { if (!(cssPx > 0)) return; if (Math.abs(cssPx - hero.drawSize) < 0.5) return; hero.drawSize = cssPx; hero.halfSize = cssPx / 2; const bitmap = getEmojiBitmap(hero.emoji, undefined, cssPx, HERO_PAD_RATIO); hero.emojiCanvas = bitmap.canvas; hero.canvasScale = bitmap.canvasScale; } function particleTint(profile: EmojiBurstProfile, emoji: string): string | undefined { if (!profile.tintColor || !profile.tintEmojis?.length) return undefined; return profile.tintEmojis.includes(emoji) ? profile.tintColor : undefined; } /** Spew particles for morph bursts — never the morphTo face (that stays on the hero). */ function pickSpewEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string { const morphTo = profile.morphTo; const companions = (profile.companions ?? []).filter((e) => e !== morphTo); if (morphTo && companions.length) { return companions[Math.floor(Math.random() * companions.length)] ?? primaryEmoji; } const picked = pickParticleEmoji(profile, primaryEmoji); return picked === morphTo ? primaryEmoji : picked; } function buildSpawnPlan( profile: EmojiBurstProfile, primaryEmoji: string, startMs: number ): SpawnItem[] { // Anchored morphing hero is drawn separately — skip a flying hero twin. const plan: SpawnItem[] = profile.morphTo ? [] : [ { atMs: startMs, emoji: primaryEmoji, fontSize: lerp(profile.heroFontSizeMin, profile.heroFontSizeMax), }, ]; const count = profile.particleCount; for (let i = 0; i < count; i += 1) { const slot = (i + 0.5) / count; const eased = slot * slot; plan.push({ atMs: startMs + 80 + eased * SPAWN_WINDOW_MS + (Math.random() - 0.5) * 70, emoji: pickSpewEmoji(profile, primaryEmoji), fontSize: lerp(profile.fontSizeMin, profile.fontSizeMax), }); } plan.sort((a, b) => a.atMs - b.atMs); return plan; } function applyMorph(sim: FireworkSim, now: number) { const to = sim.profile.morphTo; if (!to || sim.morphAtMs === null || now < sim.morphAtMs) return; if (sim.hero?.morphed) return; // Only the stand-in face morphs — spray stays droplets / companions. if (sim.hero) { setHeroEmoji(sim.hero, to); sim.hero.morphed = true; } } function ensurePile(sim: FireworkSim): CanvasRenderingContext2D { if (sim.pileCanvas && sim.pileCtx) return sim.pileCtx; const canvas = document.createElement('canvas'); canvas.width = sim.width; canvas.height = sim.height; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('firework pile unsupported'); ctx.imageSmoothingEnabled = false; sim.pileCanvas = canvas; sim.pileCtx = ctx; return ctx; } function stampSettled(sim: FireworkSim, p: FireworkParticle) { const ctx = ensurePile(sim); const rad = (p.angle * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); ctx.setTransform(cos, sin, -sin, cos, p.x, p.y); ctx.globalAlpha = 1; ctx.drawImage(p.emojiCanvas, -p.halfSize, -p.halfSize, p.drawSize, p.drawSize); ctx.setTransform(1, 0, 0, 1, 0, 0); } function spawnOne(sim: FireworkSim, item: SpawnItem, now: number) { const { profile, origin } = sim; const jitter = Math.max(4, (origin.maskRadius ?? 28) * 0.25); const directed = profile.angleSpread !== undefined; const angle = directed ? sampleBurstAngle(profile) : Math.random() * Math.PI * 2; const speed = lerp(profile.speedMin, profile.speedMax); const launchUp = lerp(profile.launchUpMin, profile.launchUpMax); const drawSize = item.fontSize * EMOJI_CACHE_SCALE * 0.85; const emoji = item.emoji; // Directed cones (e.g. throw-up) push along the sampled angle. // Undirected fireworks keep the classic upward launch kick. let vx = Math.cos(angle) * speed + (Math.random() - 0.5) * 40; let vy = Math.sin(angle) * speed; if (directed) { const bias = profile.angleBias ?? angle; vx += Math.cos(bias) * launchUp * 0.25; vy += Math.sin(bias) * launchUp; } else { vy -= launchUp; } const tint = particleTint(profile, emoji); sim.particles.push({ x: origin.x + (Math.random() - 0.5) * jitter, y: origin.y + (Math.random() - 0.5) * jitter, vx, vy, spin: lerp(profile.spinMin, profile.spinMax) * 0.35, angle: (Math.random() - 0.5) * 40, emoji, emojiCanvas: getEmojiCanvas(emoji, tint), drawSize, halfSize: drawSize / 2, bornAt: now, settled: false, }); sim.flyingCount += 1; } function settleParticle(sim: FireworkSim, p: FireworkParticle) { const cols = sim.pileCols.length; let col = Math.floor(p.x / PILE_CELL); if (col < 0) col = 0; if (col >= cols) col = cols - 1; // Prefer the intended column; spill to neighbors if that stack is already tall. let bestCol = col; let bestH = sim.pileCols[col]; for (const delta of [0, -1, 1, -2, 2]) { const c = col + delta; if (c < 0 || c >= cols) continue; if (sim.pileCols[c] < bestH) { bestH = sim.pileCols[c]; bestCol = c; } } const stack = sim.pileCols[bestCol]; sim.pileCols[bestCol] = stack + 1; p.x = bestCol * PILE_CELL + PILE_CELL * 0.5 + (Math.random() - 0.5) * 6; p.y = sim.floorY - stack * (PILE_CELL * 0.72) - p.halfSize; p.vx = 0; p.vy = 0; p.spin = 0; p.settled = true; sim.flyingCount = Math.max(0, sim.flyingCount - 1); stampSettled(sim, p); } export function createFireworkSim( widthPx: number, heightPx: number, originPx: BurstPoint, primaryEmoji: string, profile: EmojiBurstProfile, startMs = performance.now(), /** Pixel size of the source jumbo emoji — hero face matches this when set. */ heroSizePx?: number ): FireworkSim { const colCount = Math.max(8, Math.ceil(widthPx / PILE_CELL)); const fallbackHero = lerp(profile.heroFontSizeMin, profile.heroFontSizeMax) * EMOJI_CACHE_SCALE * 0.95; const heroSize = heroSizePx && heroSizePx > 0 ? heroSizePx : fallbackHero; const hero: HeroFace | null = profile.morphTo ? (() => { const bitmap = getEmojiBitmap(primaryEmoji, undefined, heroSize, HERO_PAD_RATIO); return { emoji: primaryEmoji, emojiCanvas: bitmap.canvas, drawSize: heroSize, halfSize: heroSize / 2, canvasScale: bitmap.canvasScale, morphed: false, }; })() : null; const spawnPlan = buildSpawnPlan(profile, primaryEmoji, startMs); // Keep the spewing stand-in up for the whole fountain — not just a short flash. const lastSpawnAt = spawnPlan.length > 0 ? spawnPlan[spawnPlan.length - 1].atMs : startMs; const heroUntilMs = profile.morphTo ? profile.heroDurationMs != null ? startMs + profile.heroDurationMs : lastSpawnAt + 120 : null; return { particles: [], profile, primaryEmoji, morphAtMs: profile.morphTo ? startMs + (profile.morphAfterMs ?? 220) : null, heroUntilMs, hero, width: widthPx, height: heightPx, origin: originPx, startMs, spawnPlan, spawnCursor: 0, floorY: heightPx - FLOOR_PAD, pileCols: new Uint16Array(colCount), pileCanvas: null, pileCtx: null, flyingCount: 0, }; } /** Keep the stand-in face glued to the live jumbo metrics. */ export function syncFireworkHero( sim: FireworkSim, metrics: { x: number; y: number; size: number } | null ) { if (!sim.hero || !metrics) return; sim.origin.x = metrics.x; sim.origin.y = metrics.y; setHeroSize(sim.hero, metrics.size); } /** @returns false when the burst should be removed. */ export function stepFireworkSim(sim: FireworkSim, now: number, dtSeconds: number): boolean { if (now - sim.startMs > FIREWORK_TOTAL_MS) return false; applyMorph(sim, now); if (sim.hero && sim.heroUntilMs !== null && now >= sim.heroUntilMs) { sim.hero = null; } const dt = Math.min(dtSeconds, 0.05); const g = sim.profile.gravity; let spawned = 0; while ( spawned < MAX_SPAWNS_PER_FRAME && sim.spawnCursor < sim.spawnPlan.length && sim.spawnPlan[sim.spawnCursor].atMs <= now ) { spawnOne(sim, sim.spawnPlan[sim.spawnCursor], now); sim.spawnCursor += 1; spawned += 1; } const floor = sim.floorY; const left = 4; const right = sim.width - 4; for (let i = 0; i < sim.particles.length; i += 1) { const p = sim.particles[i]; if (p.settled) continue; p.vy += g * dt; p.vx *= DRAG; p.vy *= DRAG; p.x += p.vx * dt; p.y += p.vy * dt; p.angle += p.spin * dt; if (p.x < left) { p.x = left; p.vx = Math.abs(p.vx) * BOUNCE; } else if (p.x > right) { p.x = right; p.vx = -Math.abs(p.vx) * BOUNCE; } if (p.y >= floor - p.halfSize) { p.y = floor - p.halfSize; const speed = Math.hypot(p.vx, p.vy); if (speed < SETTLE_SPEED || Math.abs(p.vy) < SETTLE_SPEED * 0.55) { settleParticle(sim, p); } else { p.vy = -Math.abs(p.vy) * BOUNCE; p.vx *= 0.85; p.spin *= 0.7; } } } return true; } export function drawFireworkSim( context: CanvasRenderingContext2D, sim: FireworkSim, now: number ) { const elapsed = now - sim.startMs; let fade = 1; if (elapsed >= SETTLE_HOLD_MS) { fade = 1 - Math.min(1, (elapsed - SETTLE_HOLD_MS) / FADE_MS); } context.setTransform(1, 0, 0, 1, 0, 0); context.imageSmoothingEnabled = false; if (sim.pileCanvas) { context.globalAlpha = fade; context.drawImage(sim.pileCanvas, 0, 0); } // Anchored face at the spew origin — morphs 🤢 → 🤮 mid-throw, then drops away. if (sim.hero && fade > 0.02) { const hero = sim.hero; const heaveAmp = Math.max(1.5, hero.drawSize * 0.02); const heave = Math.sin(elapsed / 70) * (hero.morphed ? heaveAmp * 1.25 : heaveAmp); // Scale includes bitmap padding so the glyph matches jumbo size without clipping. const box = hero.drawSize * hero.canvasScale; const half = box / 2; context.globalAlpha = fade; context.setTransform(1, 0, 0, 1, sim.origin.x, sim.origin.y + heave); context.drawImage(hero.emojiCanvas, -half, -half, box, box); context.setTransform(1, 0, 0, 1, 0, 0); } for (let i = 0; i < sim.particles.length; i += 1) { const p = sim.particles[i]; if (p.settled) continue; const ageMs = now - p.bornAt; if (ageMs < 0) continue; let opacity = fade; let scale = 1; if (ageMs < SPARKLE_IN_MS) { const t = ageMs / SPARKLE_IN_MS; const appear = t * t * (3 - 2 * t); scale = 0.5 + 0.65 * appear; opacity *= appear; } if (opacity <= 0.02) continue; const rad = (p.angle * Math.PI) / 180; const size = p.drawSize * scale; const half = size / 2; const cos = Math.cos(rad); const sin = Math.sin(rad); context.globalAlpha = opacity; context.setTransform(cos, sin, -sin, cos, p.x, p.y); context.drawImage(p.emojiCanvas, -half, -half, size, size); } context.globalAlpha = 1; context.setTransform(1, 0, 0, 1, 0, 0); } export function getFireworkDpr(): number { return 1; }