Compare commits
1 Commits
e08b4ec22e
...
898d217451
| Author | SHA1 | Date | |
|---|---|---|---|
| 898d217451 |
@@ -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.
|
||||||
origin.x = Math.min(width - 24, Math.max(24, origin.x));
|
if (!pinToJumbo) {
|
||||||
origin.y = Math.min(height - 24, Math.max(24, origin.y));
|
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(
|
||||||
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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
emojiCanvasCache.set(cacheKey, canvas);
|
||||||
return 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,13 +176,16 @@ 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,
|
? []
|
||||||
emoji: primaryEmoji,
|
: [
|
||||||
fontSize: lerp(profile.heroFontSizeMin, profile.heroFontSizeMax),
|
{
|
||||||
},
|
atMs: startMs,
|
||||||
];
|
emoji: primaryEmoji,
|
||||||
|
fontSize: lerp(profile.heroFontSizeMin, profile.heroFontSizeMax),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const count = profile.particleCount;
|
const count = profile.particleCount;
|
||||||
for (let i = 0; i < count; i += 1) {
|
for (let i = 0; i < count; i += 1) {
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user