Make sick emoji confetti a fullscreen downward spew with morph.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user