Make sick emoji confetti a fullscreen downward spew with morph.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s

🤢 now fills the viewport, morphs to 🤮 while particles spawn, hides the jumbo stand-in cleanly, and keeps green droplets out of the face morph.
This commit is contained in:
2026-08-09 18:35:12 +10:00
parent e08b4ec22e
commit 898d217451
4 changed files with 387 additions and 39 deletions

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useRef } from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { EmojiConfettiBurst } from './types'; import { EmojiConfettiBurst } from './types';
import { findJumboEmojiElement, getLocalBurstCanvasSize } from './findJumboMount'; import { findJumboEmojiElement, getLocalBurstCanvasSize, measureJumboGlyph, setJumboEmojiHidden } from './findJumboMount';
import { import {
BurstParticle, BurstParticle,
drawBurstParticles, drawBurstParticles,
@@ -15,6 +15,7 @@ import {
FireworkSim, FireworkSim,
getFireworkDpr, getFireworkDpr,
stepFireworkSim, stepFireworkSim,
syncFireworkHero,
} from './fireworkParticleEngine'; } from './fireworkParticleEngine';
const MAX_DPR = 2; const MAX_DPR = 2;
@@ -81,36 +82,85 @@ function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasPro
}; };
const { width, height } = resize(); const { width, height } = resize();
const jumbo = findJumboEmojiElement(burst.targetEventId); const profile = getEmojiBurstProfile(primaryEmoji);
const jumboRect = jumbo?.getBoundingClientRect(); const glyph = measureJumboGlyph(burst.targetEventId);
const pinToJumbo = Boolean(profile.morphTo && glyph);
const origin = { const origin = {
x: jumboRect ? jumboRect.left + jumboRect.width / 2 : burst.origin.x, x: glyph ? glyph.x : burst.origin.x,
y: jumboRect ? jumboRect.top + jumboRect.height / 2 : burst.origin.y, y: glyph ? glyph.y : burst.origin.y,
maskRadius: burst.origin.maskRadius ?? 36, maskRadius: glyph ? glyph.size * 0.5 : burst.origin.maskRadius ?? 36,
}; };
// Clamp origin into the viewport so off-screen messages still blast on-screen. // 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.x = Math.min(width - 24, Math.max(24, origin.x));
origin.y = Math.min(height - 24, Math.max(24, origin.y)); origin.y = Math.min(height - 24, Math.max(24, origin.y));
}
const profile = getEmojiBurstProfile(primaryEmoji); simRef.current = createFireworkSim(
simRef.current = createFireworkSim(width, height, origin, primaryEmoji, profile); 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 = () => { 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(); resize();
syncHero();
}; };
window.addEventListener('resize', onResize); 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 tick = (now: number) => {
const sim = simRef.current; const sim = simRef.current;
if (!sim) return; if (!sim) return;
syncHero();
const last = lastFrameTimeRef.current ?? now; const last = lastFrameTimeRef.current ?? now;
const dtSeconds = Math.min((now - last) / 1000, 0.05); const dtSeconds = Math.min((now - last) / 1000, 0.05);
lastFrameTimeRef.current = now; lastFrameTimeRef.current = now;
const alive = stepFireworkSim(sim, now, dtSeconds); 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.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height); context.clearRect(0, 0, canvas.width, canvas.height);
@@ -120,9 +170,7 @@ function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasPro
return; return;
} }
onCompleteRef.current(burst.id); finish();
frameRef.current = null;
lastFrameTimeRef.current = null;
}; };
lastFrameTimeRef.current = performance.now(); lastFrameTimeRef.current = performance.now();
@@ -130,11 +178,15 @@ function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasPro
return () => { return () => {
window.removeEventListener('resize', onResize); window.removeEventListener('resize', onResize);
window.removeEventListener('scroll', syncHero, true);
revealJumbo();
if (frameRef.current !== null) { if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current); cancelAnimationFrame(frameRef.current);
frameRef.current = null; frameRef.current = null;
} }
simRef.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]); }, [burst.id, burst.origin.maskRadius, burst.origin.x, burst.origin.y, burst.targetEventId, primaryEmoji]);

View File

@@ -35,6 +35,17 @@ export type EmojiBurstProfile = {
wobble?: boolean; wobble?: boolean;
/** Full-viewport overlay (Box2D pile for fireworks). */ /** Full-viewport overlay (Box2D pile for fireworks). */
fullscreen?: boolean; 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 = { const DEFAULT_PROFILE: EmojiBurstProfile = {
@@ -382,6 +393,58 @@ const PROFILE_OVERRIDES: Record<string, Partial<EmojiBurstProfile>> = {
companionChance: 0.5, companionChance: 0.5,
twinkle: true, 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', style: 'bounce',
particleCount: 20, particleCount: 20,

View File

@@ -6,6 +6,60 @@ export function findJumboEmojiElement(targetEventId: string): HTMLElement | null
return jumbo instanceof HTMLElement ? jumbo : 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 { export function getLocalBurstCanvasSize(maskRadius: number): number {
return Math.max(300, Math.round(maskRadius * 6.5)); return Math.max(300, Math.round(maskRadius * 6.5));
} }

View File

@@ -4,7 +4,11 @@
* Same vibe as late-90s/MSN page effects, just with emoji. * Same vibe as late-90s/MSN page effects, just with emoji.
*/ */
import { BurstPoint } from './burstOrigin'; 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_PX = 40;
const EMOJI_CACHE_SCALE = 2; const EMOJI_CACHE_SCALE = 2;
@@ -43,9 +47,25 @@ export type FireworkParticle = {
settled: boolean; 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 = { export type FireworkSim = {
particles: FireworkParticle[]; particles: FireworkParticle[];
profile: EmojiBurstProfile; 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; width: number;
height: number; height: number;
origin: BurstPoint; origin: BurstPoint;
@@ -66,11 +86,35 @@ function lerp(min: number, max: number): number {
return min + Math.random() * (max - min); return min + Math.random() * (max - min);
} }
function getEmojiCanvas(emoji: string): HTMLCanvasElement { const HERO_PAD_RATIO = 0.24;
const cached = emojiCanvasCache.get(emoji);
if (cached) return cached; 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'); const canvas = document.createElement('canvas');
canvas.width = size; canvas.width = size;
canvas.height = size; canvas.height = size;
@@ -79,11 +123,52 @@ function getEmojiCanvas(emoji: string): HTMLCanvasElement {
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.imageSmoothingEnabled = false; ctx.imageSmoothingEnabled = false;
ctx.font = `${EMOJI_CACHE_PX}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; ctx.font = `${fontPx}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
ctx.fillText(emoji, size / 2, size / 2); // 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( function buildSpawnPlan(
@@ -91,7 +176,10 @@ function buildSpawnPlan(
primaryEmoji: string, primaryEmoji: string,
startMs: number startMs: number
): SpawnItem[] { ): SpawnItem[] {
const plan: SpawnItem[] = [ // Anchored morphing hero is drawn separately — skip a flying hero twin.
const plan: SpawnItem[] = profile.morphTo
? []
: [
{ {
atMs: startMs, atMs: startMs,
emoji: primaryEmoji, emoji: primaryEmoji,
@@ -109,7 +197,7 @@ function buildSpawnPlan(
80 + 80 +
eased * SPAWN_WINDOW_MS + eased * SPAWN_WINDOW_MS +
(Math.random() - 0.5) * 70, (Math.random() - 0.5) * 70,
emoji: pickParticleEmoji(profile, primaryEmoji), emoji: pickSpewEmoji(profile, primaryEmoji),
fontSize: lerp(profile.fontSizeMin, profile.fontSizeMax), fontSize: lerp(profile.fontSizeMin, profile.fontSizeMax),
}); });
} }
@@ -118,6 +206,18 @@ function buildSpawnPlan(
return plan; 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 { function ensurePile(sim: FireworkSim): CanvasRenderingContext2D {
if (sim.pileCanvas && sim.pileCtx) return sim.pileCtx; if (sim.pileCanvas && sim.pileCtx) return sim.pileCtx;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@@ -145,20 +245,36 @@ function stampSettled(sim: FireworkSim, p: FireworkParticle) {
function spawnOne(sim: FireworkSim, item: SpawnItem, now: number) { function spawnOne(sim: FireworkSim, item: SpawnItem, now: number) {
const { profile, origin } = sim; const { profile, origin } = sim;
const jitter = Math.max(4, (origin.maskRadius ?? 28) * 0.25); 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 speed = lerp(profile.speedMin, profile.speedMax);
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax); const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
const drawSize = item.fontSize * EMOJI_CACHE_SCALE * 0.85; 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({ sim.particles.push({
x: origin.x + (Math.random() - 0.5) * jitter, x: origin.x + (Math.random() - 0.5) * jitter,
y: origin.y + (Math.random() - 0.5) * jitter, y: origin.y + (Math.random() - 0.5) * jitter,
vx: Math.cos(angle) * speed + (Math.random() - 0.5) * 40, vx,
vy: Math.sin(angle) * speed - launchUp, vy,
spin: lerp(profile.spinMin, profile.spinMax) * 0.35, spin: lerp(profile.spinMin, profile.spinMax) * 0.35,
angle: (Math.random() - 0.5) * 40, angle: (Math.random() - 0.5) * 40,
emoji: item.emoji, emoji,
emojiCanvas: getEmojiCanvas(item.emoji), emojiCanvas: getEmojiCanvas(emoji, tint),
drawSize, drawSize,
halfSize: drawSize / 2, halfSize: drawSize / 2,
bornAt: now, bornAt: now,
@@ -203,17 +319,49 @@ export function createFireworkSim(
originPx: BurstPoint, originPx: BurstPoint,
primaryEmoji: string, primaryEmoji: string,
profile: EmojiBurstProfile, profile: EmojiBurstProfile,
startMs = performance.now() startMs = performance.now(),
/** Pixel size of the source jumbo emoji — hero face matches this when set. */
heroSizePx?: number
): FireworkSim { ): FireworkSim {
const colCount = Math.max(8, Math.ceil(widthPx / PILE_CELL)); 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 { return {
particles: [], particles: [],
profile, profile,
primaryEmoji,
morphAtMs: profile.morphTo ? startMs + (profile.morphAfterMs ?? 220) : null,
heroUntilMs,
hero,
width: widthPx, width: widthPx,
height: heightPx, height: heightPx,
origin: originPx, origin: originPx,
startMs, startMs,
spawnPlan: buildSpawnPlan(profile, primaryEmoji, startMs), spawnPlan,
spawnCursor: 0, spawnCursor: 0,
floorY: heightPx - FLOOR_PAD, floorY: heightPx - FLOOR_PAD,
pileCols: new Uint16Array(colCount), 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. */ /** @returns false when the burst should be removed. */
export function stepFireworkSim(sim: FireworkSim, now: number, dtSeconds: number): boolean { export function stepFireworkSim(sim: FireworkSim, now: number, dtSeconds: number): boolean {
if (now - sim.startMs > FIREWORK_TOTAL_MS) return false; 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 dt = Math.min(dtSeconds, 0.05);
const g = sim.profile.gravity; const g = sim.profile.gravity;
@@ -299,6 +464,20 @@ export function drawFireworkSim(
context.drawImage(sim.pileCanvas, 0, 0); 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) { for (let i = 0; i < sim.particles.length; i += 1) {
const p = sim.particles[i]; const p = sim.particles[i];
if (p.settled) continue; if (p.settled) continue;