Support app.relay.emoji_confetti on jumbo emoji clicks with canvas physics, per-emoji particle profiles, and a fixed overlay that does not affect scroll. Also add a Vite playground for live component previews and theme hover preview.
358 lines
9.8 KiB
TypeScript
358 lines
9.8 KiB
TypeScript
import { BurstPoint } from './burstOrigin';
|
|
import {
|
|
BurstMotionStyle,
|
|
EmojiBurstProfile,
|
|
getEmojiBurstProfile,
|
|
pickParticleEmoji,
|
|
sampleBurstAngle,
|
|
} from './emojiParticleProfiles';
|
|
|
|
/** Particles spawn over this window. */
|
|
export const BURST_SPAWN_MS = 500;
|
|
/** Global fade runs from 0.5s → 2s after burst start. */
|
|
export const BURST_FADE_START_MS = 500;
|
|
export const BURST_FADE_END_MS = 2000;
|
|
export const BURST_TOTAL_MS = BURST_FADE_END_MS;
|
|
|
|
export type BurstParticle = {
|
|
burstId: string;
|
|
burstStartMs: number;
|
|
emoji: string;
|
|
fontSize: number;
|
|
spawnX: number;
|
|
spawnY: number;
|
|
maskRadius: number;
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
bornAt: number;
|
|
rotation: number;
|
|
spin: number;
|
|
peakScale: number;
|
|
style: BurstMotionStyle;
|
|
gravity: number;
|
|
drag: number;
|
|
phase: number;
|
|
twinkle?: boolean;
|
|
wobble?: boolean;
|
|
orbitRadius: number;
|
|
orbitSpeed: number;
|
|
orbitAngle: number;
|
|
bounceDamping: number;
|
|
bouncesLeft: number;
|
|
};
|
|
|
|
const EMOJI_CACHE_PX = 64;
|
|
const EMOJI_CACHE_SCALE = 2;
|
|
const MAX_DPR = 2;
|
|
const POP_IN_MS = 70;
|
|
|
|
const emojiCanvasCache = new Map<string, HTMLCanvasElement>();
|
|
|
|
function easeOutBack(t: number): number {
|
|
const c1 = 1.70158;
|
|
const c3 = c1 + 1;
|
|
return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2;
|
|
}
|
|
|
|
function easeOutCubic(t: number): number {
|
|
return 1 - (1 - t) ** 3;
|
|
}
|
|
|
|
function lerp(min: number, max: number): number {
|
|
return min + Math.random() * (max - min);
|
|
}
|
|
|
|
function getEmojiCanvas(emoji: string): HTMLCanvasElement {
|
|
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
|
const cacheKey = `${emoji}:${dpr}`;
|
|
const cached = emojiCanvasCache.get(cacheKey);
|
|
if (cached) return cached;
|
|
|
|
const fontSize = Math.ceil(EMOJI_CACHE_PX * dpr);
|
|
const size = Math.ceil(fontSize * EMOJI_CACHE_SCALE);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
|
|
const context = canvas.getContext('2d');
|
|
if (context) {
|
|
context.textAlign = 'center';
|
|
context.textBaseline = 'middle';
|
|
context.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
|
context.fillText(emoji, size / 2, size / 2);
|
|
}
|
|
|
|
emojiCanvasCache.set(cacheKey, canvas);
|
|
return canvas;
|
|
}
|
|
|
|
function emergeOpacity(
|
|
x: number,
|
|
y: number,
|
|
spawnX: number,
|
|
spawnY: number,
|
|
maskRadius: number
|
|
): number {
|
|
const dist = Math.hypot(x - spawnX, y - spawnY);
|
|
const inner = maskRadius * 0.55;
|
|
const outer = maskRadius * 1.05;
|
|
if (dist <= inner) return 0;
|
|
if (dist >= outer) return 1;
|
|
return (dist - inner) / (outer - inner);
|
|
}
|
|
|
|
function spawnParticle(
|
|
particles: BurstParticle[],
|
|
burstId: string,
|
|
burstStartMs: number,
|
|
point: BurstPoint,
|
|
primaryEmoji: string,
|
|
profile: EmojiBurstProfile,
|
|
bornAt: number,
|
|
options?: { hero?: boolean }
|
|
) {
|
|
const hero = options?.hero ?? false;
|
|
const angle = sampleBurstAngle(profile);
|
|
const speed = hero
|
|
? lerp(profile.speedMin * 0.65, profile.speedMax * 0.55)
|
|
: lerp(profile.speedMin, profile.speedMax);
|
|
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
|
|
const maskRadius = point.maskRadius ?? 28;
|
|
const jitter = hero ? 3 : 12;
|
|
const emoji = pickParticleEmoji(profile, primaryEmoji);
|
|
|
|
let vx = Math.cos(angle) * speed + (Math.random() - 0.5) * 60;
|
|
let vy = Math.sin(angle) * speed - launchUp;
|
|
|
|
if (profile.style === 'spiral') {
|
|
vx *= 0.35;
|
|
vy *= 0.35;
|
|
}
|
|
|
|
if (profile.style === 'sparkle') {
|
|
vx *= 0.75;
|
|
vy *= 0.75;
|
|
}
|
|
|
|
particles.push({
|
|
burstId,
|
|
burstStartMs,
|
|
emoji,
|
|
fontSize: hero
|
|
? lerp(profile.heroFontSizeMin, profile.heroFontSizeMax)
|
|
: lerp(profile.fontSizeMin, profile.fontSizeMax),
|
|
spawnX: point.x,
|
|
spawnY: point.y,
|
|
maskRadius,
|
|
x: point.x + (Math.random() - 0.5) * jitter,
|
|
y: point.y + (Math.random() - 0.5) * jitter,
|
|
vx,
|
|
vy,
|
|
bornAt,
|
|
rotation: (Math.random() - 0.5) * 40,
|
|
spin: lerp(profile.spinMin, profile.spinMax),
|
|
peakScale: hero ? 1.1 + Math.random() * 0.2 : 0.9 + Math.random() * 0.4,
|
|
style: profile.style,
|
|
gravity: profile.gravity,
|
|
drag: profile.drag,
|
|
phase: Math.random() * Math.PI * 2,
|
|
twinkle: profile.twinkle,
|
|
wobble: profile.wobble,
|
|
orbitRadius: hero ? 8 : 14 + Math.random() * 28,
|
|
orbitSpeed: (Math.random() < 0.5 ? -1 : 1) * (1.8 + Math.random() * 2.4),
|
|
orbitAngle: Math.random() * Math.PI * 2,
|
|
bounceDamping: 0.42 + Math.random() * 0.18,
|
|
bouncesLeft: profile.style === 'bounce' ? 2 : 0,
|
|
});
|
|
}
|
|
|
|
/** Fountain from behind the emoji for 0.5s, fade 0.5s→2s. */
|
|
export function spawnEmojiBurst(
|
|
particles: BurstParticle[],
|
|
burstId: string,
|
|
point: BurstPoint,
|
|
emoji: string,
|
|
now = performance.now()
|
|
) {
|
|
const profile = getEmojiBurstProfile(emoji);
|
|
const burstStartMs = now;
|
|
|
|
spawnParticle(particles, burstId, burstStartMs, point, emoji, profile, burstStartMs, {
|
|
hero: true,
|
|
});
|
|
|
|
const particleCount = profile.particleCount;
|
|
for (let i = 0; i < particleCount; i += 1) {
|
|
const slot = i / particleCount;
|
|
const bornAt =
|
|
burstStartMs + slot * BURST_SPAWN_MS + Math.random() * (BURST_SPAWN_MS / particleCount);
|
|
spawnParticle(particles, burstId, burstStartMs, point, emoji, profile, bornAt);
|
|
}
|
|
}
|
|
|
|
function stepSpiralParticle(particle: BurstParticle, dtSeconds: number) {
|
|
particle.orbitRadius += 42 * dtSeconds;
|
|
particle.orbitAngle += particle.orbitSpeed * dtSeconds;
|
|
particle.x = particle.spawnX + Math.cos(particle.orbitAngle) * particle.orbitRadius;
|
|
particle.y =
|
|
particle.spawnY + Math.sin(particle.orbitAngle) * particle.orbitRadius * 0.65 + particle.vy * dtSeconds * 8;
|
|
particle.vy += particle.gravity * dtSeconds * 0.35;
|
|
particle.rotation += particle.spin * dtSeconds;
|
|
}
|
|
|
|
function stepBounceParticle(particle: BurstParticle, dtSeconds: number, dragFactor: number) {
|
|
particle.vy += particle.gravity * dtSeconds;
|
|
particle.vx *= dragFactor;
|
|
particle.vy *= dragFactor;
|
|
particle.x += particle.vx * dtSeconds;
|
|
particle.y += particle.vy * dtSeconds;
|
|
particle.rotation += particle.spin * dtSeconds;
|
|
|
|
if (particle.bouncesLeft > 0 && particle.vy > 0 && particle.y >= particle.spawnY + 6) {
|
|
particle.y = particle.spawnY + 6;
|
|
particle.vy = -Math.abs(particle.vy) * particle.bounceDamping;
|
|
particle.vx *= 0.82;
|
|
particle.bouncesLeft -= 1;
|
|
particle.spin += (Math.random() - 0.5) * 120;
|
|
}
|
|
}
|
|
|
|
function stepDefaultParticle(particle: BurstParticle, dtSeconds: number, dragFactor: number) {
|
|
particle.vy += particle.gravity * dtSeconds;
|
|
particle.vx *= dragFactor;
|
|
particle.vy *= dragFactor;
|
|
particle.x += particle.vx * dtSeconds;
|
|
particle.y += particle.vy * dtSeconds;
|
|
particle.rotation += particle.spin * dtSeconds;
|
|
}
|
|
|
|
export function stepBurstParticles(
|
|
particles: BurstParticle[],
|
|
now: number,
|
|
dtSeconds: number
|
|
): void {
|
|
for (let i = particles.length - 1; i >= 0; i -= 1) {
|
|
const particle = particles[i];
|
|
const burstElapsed = now - particle.burstStartMs;
|
|
|
|
if (burstElapsed > BURST_TOTAL_MS) {
|
|
particles[i] = particles[particles.length - 1];
|
|
particles.pop();
|
|
continue;
|
|
}
|
|
|
|
const ageMs = now - particle.bornAt;
|
|
if (ageMs < 0) continue;
|
|
|
|
const dragFactor = particle.drag ** (dtSeconds * 60);
|
|
|
|
switch (particle.style) {
|
|
case 'spiral':
|
|
stepSpiralParticle(particle, dtSeconds);
|
|
break;
|
|
case 'bounce':
|
|
stepBounceParticle(particle, dtSeconds, dragFactor);
|
|
break;
|
|
default:
|
|
stepDefaultParticle(particle, dtSeconds, dragFactor);
|
|
break;
|
|
}
|
|
|
|
particle.phase += dtSeconds * (particle.twinkle ? 9 : 5);
|
|
}
|
|
}
|
|
|
|
type DrawState = {
|
|
x: number;
|
|
y: number;
|
|
scale: number;
|
|
opacity: number;
|
|
rotation: number;
|
|
};
|
|
|
|
function particleDrawState(particle: BurstParticle, now: number): DrawState | null {
|
|
const burstElapsed = now - particle.burstStartMs;
|
|
if (burstElapsed > BURST_TOTAL_MS) return null;
|
|
|
|
const ageMs = now - particle.bornAt;
|
|
if (ageMs < 0) return null;
|
|
|
|
let scale = particle.peakScale;
|
|
let opacity = emergeOpacity(
|
|
particle.x,
|
|
particle.y,
|
|
particle.spawnX,
|
|
particle.spawnY,
|
|
particle.maskRadius
|
|
);
|
|
|
|
if (opacity <= 0) return null;
|
|
|
|
if (ageMs < POP_IN_MS) {
|
|
const pop = easeOutBack(ageMs / POP_IN_MS);
|
|
scale = 0.1 + pop * particle.peakScale;
|
|
opacity *= pop;
|
|
}
|
|
|
|
if (particle.twinkle) {
|
|
opacity *= 0.55 + 0.45 * Math.sin(particle.phase * 1.6);
|
|
}
|
|
|
|
if (particle.wobble) {
|
|
scale *= 1 + 0.12 * Math.sin(particle.phase * 2.2);
|
|
}
|
|
|
|
if (particle.style === 'sparkle' && burstElapsed < BURST_FADE_START_MS) {
|
|
scale *= 0.85 + 0.3 * Math.sin(particle.phase * 3);
|
|
}
|
|
|
|
if (burstElapsed >= BURST_FADE_START_MS) {
|
|
const fadeT = (burstElapsed - BURST_FADE_START_MS) / (BURST_FADE_END_MS - BURST_FADE_START_MS);
|
|
opacity *= 1 - easeOutCubic(Math.min(1, fadeT));
|
|
scale *= 1 - easeOutCubic(Math.min(1, fadeT)) * 0.2;
|
|
}
|
|
|
|
if (opacity <= 0.02) return null;
|
|
|
|
return {
|
|
x: particle.x,
|
|
y: particle.y,
|
|
scale,
|
|
opacity,
|
|
rotation: particle.rotation,
|
|
};
|
|
}
|
|
|
|
export function drawBurstParticles(
|
|
context: CanvasRenderingContext2D,
|
|
particles: BurstParticle[],
|
|
dpr: number,
|
|
now: number
|
|
) {
|
|
for (const particle of particles) {
|
|
const state = particleDrawState(particle, now);
|
|
if (!state) continue;
|
|
|
|
context.globalAlpha = state.opacity;
|
|
|
|
const emojiCanvas = getEmojiCanvas(particle.emoji);
|
|
const drawSize = particle.fontSize * state.scale * EMOJI_CACHE_SCALE;
|
|
const halfSize = drawSize / 2;
|
|
const radians = (state.rotation * Math.PI) / 180;
|
|
const cos = Math.cos(radians) * dpr;
|
|
const sin = Math.sin(radians) * dpr;
|
|
|
|
context.setTransform(cos, sin, -sin, cos, state.x * dpr, state.y * dpr);
|
|
context.drawImage(emojiCanvas, -halfSize, -halfSize, drawSize, drawSize);
|
|
}
|
|
|
|
context.globalAlpha = 1;
|
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
}
|
|
|
|
export function hasBurstParticles(particles: BurstParticle[], burstId: string): boolean {
|
|
return particles.some((particle) => particle.burstId === burstId);
|
|
}
|