From 898d2174519222f7f009b01734d32f2239bca004 Mon Sep 17 00:00:00 2001 From: litruv Date: Sun, 9 Aug 2026 18:35:12 +1000 Subject: [PATCH] Make sick emoji confetti a fullscreen downward spew with morph. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤢 now fills the viewport, morphs to 🤮 while particles spawn, hides the jumbo stand-in cleanly, and keeps green droplets out of the face morph. --- .../EmojiConfettiBurstCanvas.tsx | 82 +++++-- .../emoji-confetti/emojiParticleProfiles.ts | 63 +++++ .../room/emoji-confetti/findJumboMount.ts | 54 +++++ .../emoji-confetti/fireworkParticleEngine.ts | 227 ++++++++++++++++-- 4 files changed, 387 insertions(+), 39 deletions(-) diff --git a/src/app/features/room/emoji-confetti/EmojiConfettiBurstCanvas.tsx b/src/app/features/room/emoji-confetti/EmojiConfettiBurstCanvas.tsx index 5f0ed5d..be51363 100644 --- a/src/app/features/room/emoji-confetti/EmojiConfettiBurstCanvas.tsx +++ b/src/app/features/room/emoji-confetti/EmojiConfettiBurstCanvas.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; import { EmojiConfettiBurst } from './types'; -import { findJumboEmojiElement, getLocalBurstCanvasSize } from './findJumboMount'; +import { findJumboEmojiElement, getLocalBurstCanvasSize, measureJumboGlyph, setJumboEmojiHidden } from './findJumboMount'; import { BurstParticle, drawBurstParticles, @@ -15,6 +15,7 @@ import { FireworkSim, getFireworkDpr, stepFireworkSim, + syncFireworkHero, } from './fireworkParticleEngine'; const MAX_DPR = 2; @@ -81,36 +82,85 @@ function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasPro }; const { width, height } = resize(); - const jumbo = findJumboEmojiElement(burst.targetEventId); - const jumboRect = jumbo?.getBoundingClientRect(); + const profile = getEmojiBurstProfile(primaryEmoji); + const glyph = measureJumboGlyph(burst.targetEventId); + const pinToJumbo = Boolean(profile.morphTo && glyph); + const origin = { - x: jumboRect ? jumboRect.left + jumboRect.width / 2 : burst.origin.x, - y: jumboRect ? jumboRect.top + jumboRect.height / 2 : burst.origin.y, - maskRadius: burst.origin.maskRadius ?? 36, + x: glyph ? glyph.x : burst.origin.x, + y: glyph ? glyph.y : burst.origin.y, + maskRadius: glyph ? glyph.size * 0.5 : burst.origin.maskRadius ?? 36, }; - // Clamp origin into the viewport so off-screen messages still blast on-screen. - origin.x = Math.min(width - 24, Math.max(24, origin.x)); - origin.y = Math.min(height - 24, Math.max(24, origin.y)); + // Keep morph heroes glued to the message emoji; only clamp undirected fireworks. + if (!pinToJumbo) { + origin.x = Math.min(width - 24, Math.max(24, origin.x)); + origin.y = Math.min(height - 24, Math.max(24, origin.y)); + } - const profile = getEmojiBurstProfile(primaryEmoji); - simRef.current = createFireworkSim(width, height, origin, primaryEmoji, profile); + simRef.current = createFireworkSim( + width, + height, + origin, + primaryEmoji, + profile, + performance.now(), + glyph?.size + ); + + const revealJumbo = () => { + setJumboEmojiHidden(burst.targetEventId, false); + }; + + const syncHero = () => { + const sim = simRef.current; + if (!sim) return; + if (!sim.hero) { + revealJumbo(); + return; + } + const metrics = measureJumboGlyph(burst.targetEventId); + syncFireworkHero(sim, metrics); + // Re-query every frame so React remounts still stay hidden. + setJumboEmojiHidden(burst.targetEventId, true); + }; const onResize = () => { - // Extra Things: match CSS size; sim keeps its launch-time dimensions. + // Extra Things: match CSS size; sim keeps its launch-time world size. resize(); + syncHero(); }; window.addEventListener('resize', onResize); + window.addEventListener('scroll', syncHero, true); + + const finish = () => { + revealJumbo(); + onCompleteRef.current(burst.id); + frameRef.current = null; + lastFrameTimeRef.current = null; + }; + + // Hide immediately before first paint of the stand-in. + if (pinToJumbo) { + setJumboEmojiHidden(burst.targetEventId, true); + } const tick = (now: number) => { const sim = simRef.current; if (!sim) return; + syncHero(); + const last = lastFrameTimeRef.current ?? now; const dtSeconds = Math.min((now - last) / 1000, 0.05); lastFrameTimeRef.current = now; const alive = stepFireworkSim(sim, now, dtSeconds); + // Hero may have just been dismissed — reveal real jumbo immediately. + if (!sim.hero) { + revealJumbo(); + } + context.setTransform(1, 0, 0, 1, 0, 0); context.clearRect(0, 0, canvas.width, canvas.height); @@ -120,9 +170,7 @@ function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasPro return; } - onCompleteRef.current(burst.id); - frameRef.current = null; - lastFrameTimeRef.current = null; + finish(); }; lastFrameTimeRef.current = performance.now(); @@ -130,11 +178,15 @@ function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasPro return () => { window.removeEventListener('resize', onResize); + window.removeEventListener('scroll', syncHero, true); + revealJumbo(); if (frameRef.current !== null) { cancelAnimationFrame(frameRef.current); frameRef.current = null; } simRef.current = null; + // Extra Things: allow Strict Mode remount to start a fresh burst. + spawnedRef.current = false; }; }, [burst.id, burst.origin.maskRadius, burst.origin.x, burst.origin.y, burst.targetEventId, primaryEmoji]); diff --git a/src/app/features/room/emoji-confetti/emojiParticleProfiles.ts b/src/app/features/room/emoji-confetti/emojiParticleProfiles.ts index ce25c0d..d59195b 100644 --- a/src/app/features/room/emoji-confetti/emojiParticleProfiles.ts +++ b/src/app/features/room/emoji-confetti/emojiParticleProfiles.ts @@ -35,6 +35,17 @@ export type EmojiBurstProfile = { wobble?: boolean; /** Full-viewport overlay (Box2D pile for fireworks). */ fullscreen?: boolean; + /** Soft color wash applied to matching particle glyphs (e.g. green spit). */ + tintColor?: string; + /** Emojis that receive `tintColor` when drawn. */ + tintEmojis?: string[]; + /** Swap the primary face to this emoji mid-burst (e.g. 🤢 → 🤮). */ + morphTo?: string; + /** Delay before `morphTo` kicks in. */ + morphAfterMs?: number; + /** How long the stand-in hero face stays before the real jumbo returns. + * Omit to keep it up until the last particle spawns. */ + heroDurationMs?: number; }; const DEFAULT_PROFILE: EmojiBurstProfile = { @@ -382,6 +393,58 @@ const PROFILE_OVERRIDES: Record> = { companionChance: 0.5, twinkle: true, }, + // Full-viewport spew — downward cone, green-tinted droplets, piles on the floor. + '🤢': { + style: 'firework', + fullscreen: true, + particleCount: 420, + gravity: 1200, + drag: 1, + speedMin: 380, + speedMax: 980, + launchUpMin: 0, + launchUpMax: 40, + spinMin: -560, + spinMax: 560, + fontSizeMin: 18, + fontSizeMax: 34, + heroFontSizeMin: 44, + heroFontSizeMax: 58, + // Downward throw-up cone (π/2 = down in canvas space). + angleBias: Math.PI / 2, + angleSpread: Math.PI * 1.05, + companions: ['💦', '💧', '💚'], + companionChance: 1, + tintColor: 'rgba(72, 190, 48, 0.72)', + tintEmojis: ['💦', '💧'], + morphTo: '🤮', + morphAfterMs: 220, + wobble: true, + }, + '🤮': { + style: 'firework', + fullscreen: true, + particleCount: 460, + gravity: 1250, + drag: 1, + speedMin: 420, + speedMax: 1050, + launchUpMin: 0, + launchUpMax: 30, + spinMin: -600, + spinMax: 600, + fontSizeMin: 18, + fontSizeMax: 36, + heroFontSizeMin: 46, + heroFontSizeMax: 60, + angleBias: Math.PI / 2, + angleSpread: Math.PI * 1.15, + companions: ['💦', '💧', '💚'], + companionChance: 1, + tintColor: 'rgba(72, 190, 48, 0.72)', + tintEmojis: ['💦', '💧'], + wobble: true, + }, '🐱': { style: 'bounce', particleCount: 20, diff --git a/src/app/features/room/emoji-confetti/findJumboMount.ts b/src/app/features/room/emoji-confetti/findJumboMount.ts index 6d7e8c9..dcdbc42 100644 --- a/src/app/features/room/emoji-confetti/findJumboMount.ts +++ b/src/app/features/room/emoji-confetti/findJumboMount.ts @@ -6,6 +6,60 @@ export function findJumboEmojiElement(targetEventId: string): HTMLElement | null return jumbo instanceof HTMLElement ? jumbo : null; } +export type JumboGlyphMetrics = { + /** Outer jumbo mount (message body) — hide/show this. */ + mount: HTMLElement; + /** Visual glyph center in viewport coords. */ + x: number; + y: number; + /** CSS px size matching the rendered emoji (usually computed font-size). */ + size: number; +}; + +/** + * Measure the on-screen jumbo glyph. Prefer computed font-size so the canvas + * stand-in matches unicode emoji; fall back to the glyph box for images. + */ +export function measureJumboGlyph(targetEventId: string): JumboGlyphMetrics | null { + const mount = findJumboEmojiElement(targetEventId); + if (!mount) return null; + + const glyph = + (mount.querySelector('[data-emoticon]') as HTMLElement | null) || + (mount.querySelector('img') as HTMLElement | null) || + mount; + + const rect = glyph.getBoundingClientRect(); + const fontSize = + parseFloat(getComputedStyle(glyph).fontSize) || + parseFloat(getComputedStyle(mount).fontSize) || + 0; + + const isImg = glyph instanceof HTMLImageElement || glyph.tagName === 'IMG'; + const size = isImg + ? Math.max(rect.width, rect.height) + : fontSize > 0 + ? fontSize + : Math.max(rect.width, rect.height); + + return { + mount, + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + size, + }; +} + +export function setJumboEmojiHidden(targetEventId: string, hidden: boolean) { + const mount = findJumboEmojiElement(targetEventId); + if (!mount) return; + if (hidden) { + mount.style.visibility = 'hidden'; + } else { + mount.style.removeProperty('visibility'); + } +} + export function getLocalBurstCanvasSize(maskRadius: number): number { return Math.max(300, Math.round(maskRadius * 6.5)); } diff --git a/src/app/features/room/emoji-confetti/fireworkParticleEngine.ts b/src/app/features/room/emoji-confetti/fireworkParticleEngine.ts index 44a0805..a6b7e52 100644 --- a/src/app/features/room/emoji-confetti/fireworkParticleEngine.ts +++ b/src/app/features/room/emoji-confetti/fireworkParticleEngine.ts @@ -4,7 +4,11 @@ * Same vibe as late-90s/MSN page effects, just with emoji. */ import { BurstPoint } from './burstOrigin'; -import { pickParticleEmoji, type EmojiBurstProfile } from './emojiParticleProfiles'; +import { + pickParticleEmoji, + sampleBurstAngle, + type EmojiBurstProfile, +} from './emojiParticleProfiles'; const EMOJI_CACHE_PX = 40; const EMOJI_CACHE_SCALE = 2; @@ -43,9 +47,25 @@ export type FireworkParticle = { 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; @@ -66,11 +86,35 @@ function lerp(min: number, max: number): number { return min + Math.random() * (max - min); } -function getEmojiCanvas(emoji: string): HTMLCanvasElement { - const cached = emojiCanvasCache.get(emoji); - if (cached) return cached; +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 size = EMOJI_CACHE_PX * EMOJI_CACHE_SCALE; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; @@ -79,11 +123,52 @@ function getEmojiCanvas(emoji: string): HTMLCanvasElement { ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.imageSmoothingEnabled = false; - ctx.font = `${EMOJI_CACHE_PX}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; - ctx.fillText(emoji, size / 2, size / 2); + 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(emoji, canvas); - return canvas; + 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( @@ -91,13 +176,16 @@ function buildSpawnPlan( primaryEmoji: string, startMs: number ): SpawnItem[] { - const plan: SpawnItem[] = [ - { - atMs: startMs, - emoji: primaryEmoji, - fontSize: lerp(profile.heroFontSizeMin, profile.heroFontSizeMax), - }, - ]; + // 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) { @@ -109,7 +197,7 @@ function buildSpawnPlan( 80 + eased * SPAWN_WINDOW_MS + (Math.random() - 0.5) * 70, - emoji: pickParticleEmoji(profile, primaryEmoji), + emoji: pickSpewEmoji(profile, primaryEmoji), fontSize: lerp(profile.fontSizeMin, profile.fontSizeMax), }); } @@ -118,6 +206,18 @@ function buildSpawnPlan( 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'); @@ -145,20 +245,36 @@ function stampSettled(sim: FireworkSim, p: FireworkParticle) { function spawnOne(sim: FireworkSim, item: SpawnItem, now: number) { const { profile, origin } = sim; const jitter = Math.max(4, (origin.maskRadius ?? 28) * 0.25); - const angle = Math.random() * Math.PI * 2; + 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: Math.cos(angle) * speed + (Math.random() - 0.5) * 40, - vy: Math.sin(angle) * speed - launchUp, + vx, + vy, spin: lerp(profile.spinMin, profile.spinMax) * 0.35, angle: (Math.random() - 0.5) * 40, - emoji: item.emoji, - emojiCanvas: getEmojiCanvas(item.emoji), + emoji, + emojiCanvas: getEmojiCanvas(emoji, tint), drawSize, halfSize: drawSize / 2, bornAt: now, @@ -203,17 +319,49 @@ export function createFireworkSim( originPx: BurstPoint, primaryEmoji: string, profile: EmojiBurstProfile, - startMs = performance.now() + 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: buildSpawnPlan(profile, primaryEmoji, startMs), + spawnPlan, spawnCursor: 0, floorY: heightPx - FLOOR_PAD, pileCols: new Uint16Array(colCount), @@ -223,10 +371,27 @@ export function createFireworkSim( }; } +/** 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; @@ -299,6 +464,20 @@ export function drawFireworkSim( 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;