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.
392 lines
7.8 KiB
TypeScript
392 lines
7.8 KiB
TypeScript
export type BurstMotionStyle =
|
|
| 'explode'
|
|
| 'bounce'
|
|
| 'rise'
|
|
| 'sparkle'
|
|
| 'splash'
|
|
| 'spiral'
|
|
| 'punch'
|
|
| 'scatter'
|
|
| 'shower';
|
|
|
|
export type EmojiBurstProfile = {
|
|
style: BurstMotionStyle;
|
|
particleCount: number;
|
|
gravity: number;
|
|
drag: number;
|
|
speedMin: number;
|
|
speedMax: number;
|
|
launchUpMin: number;
|
|
launchUpMax: number;
|
|
spinMin: number;
|
|
spinMax: number;
|
|
fontSizeMin: number;
|
|
fontSizeMax: number;
|
|
heroFontSizeMin: number;
|
|
heroFontSizeMax: number;
|
|
companions?: string[];
|
|
companionChance?: number;
|
|
/** Bias burst angle in radians. 0 = right, -π/2 = up. */
|
|
angleBias?: number;
|
|
/** Limit spawn to a cone (radians). Omit for full 360°. */
|
|
angleSpread?: number;
|
|
twinkle?: boolean;
|
|
wobble?: boolean;
|
|
};
|
|
|
|
const DEFAULT_PROFILE: EmojiBurstProfile = {
|
|
style: 'explode',
|
|
particleCount: 36,
|
|
gravity: 400,
|
|
drag: 0.935,
|
|
speedMin: 240,
|
|
speedMax: 560,
|
|
launchUpMin: 40,
|
|
launchUpMax: 130,
|
|
spinMin: -180,
|
|
spinMax: 180,
|
|
fontSizeMin: 14,
|
|
fontSizeMax: 26,
|
|
heroFontSizeMin: 26,
|
|
heroFontSizeMax: 34,
|
|
};
|
|
|
|
const PROFILE_OVERRIDES: Record<string, Partial<EmojiBurstProfile>> = {
|
|
'😆': {
|
|
style: 'bounce',
|
|
particleCount: 28,
|
|
gravity: 520,
|
|
speedMin: 180,
|
|
speedMax: 360,
|
|
launchUpMin: 120,
|
|
launchUpMax: 220,
|
|
spinMin: -240,
|
|
spinMax: 240,
|
|
wobble: true,
|
|
},
|
|
'😂': {
|
|
style: 'bounce',
|
|
particleCount: 30,
|
|
gravity: 500,
|
|
speedMin: 160,
|
|
speedMax: 340,
|
|
launchUpMin: 110,
|
|
launchUpMax: 210,
|
|
spinMin: -220,
|
|
spinMax: 220,
|
|
wobble: true,
|
|
},
|
|
'🤣': {
|
|
style: 'bounce',
|
|
particleCount: 32,
|
|
gravity: 480,
|
|
speedMin: 200,
|
|
speedMax: 380,
|
|
launchUpMin: 130,
|
|
launchUpMax: 240,
|
|
wobble: true,
|
|
},
|
|
'🔥': {
|
|
style: 'rise',
|
|
particleCount: 24,
|
|
gravity: -120,
|
|
drag: 0.96,
|
|
speedMin: 80,
|
|
speedMax: 200,
|
|
launchUpMin: 60,
|
|
launchUpMax: 160,
|
|
spinMin: -90,
|
|
spinMax: 90,
|
|
fontSizeMin: 16,
|
|
fontSizeMax: 28,
|
|
twinkle: true,
|
|
wobble: true,
|
|
companions: ['✨'],
|
|
companionChance: 0.35,
|
|
},
|
|
'❤️': {
|
|
style: 'rise',
|
|
particleCount: 22,
|
|
gravity: -80,
|
|
drag: 0.97,
|
|
speedMin: 60,
|
|
speedMax: 150,
|
|
launchUpMin: 40,
|
|
launchUpMax: 110,
|
|
spinMin: -40,
|
|
spinMax: 40,
|
|
fontSizeMin: 14,
|
|
fontSizeMax: 24,
|
|
companions: ['💕', '💖'],
|
|
companionChance: 0.4,
|
|
},
|
|
'💕': {
|
|
style: 'rise',
|
|
particleCount: 20,
|
|
gravity: -70,
|
|
drag: 0.97,
|
|
speedMin: 50,
|
|
speedMax: 140,
|
|
launchUpMin: 35,
|
|
launchUpMax: 100,
|
|
spinMin: -35,
|
|
spinMax: 35,
|
|
},
|
|
'💖': {
|
|
style: 'rise',
|
|
particleCount: 20,
|
|
gravity: -90,
|
|
drag: 0.968,
|
|
speedMin: 55,
|
|
speedMax: 150,
|
|
launchUpMin: 45,
|
|
launchUpMax: 115,
|
|
twinkle: true,
|
|
},
|
|
'⭐': {
|
|
style: 'sparkle',
|
|
particleCount: 26,
|
|
gravity: 40,
|
|
drag: 0.94,
|
|
speedMin: 100,
|
|
speedMax: 260,
|
|
launchUpMin: 20,
|
|
launchUpMax: 80,
|
|
spinMin: -120,
|
|
spinMax: 120,
|
|
twinkle: true,
|
|
companions: ['✨'],
|
|
companionChance: 0.5,
|
|
},
|
|
'✨': {
|
|
style: 'sparkle',
|
|
particleCount: 30,
|
|
gravity: 30,
|
|
drag: 0.945,
|
|
speedMin: 90,
|
|
speedMax: 240,
|
|
launchUpMin: 15,
|
|
launchUpMax: 70,
|
|
spinMin: -200,
|
|
spinMax: 200,
|
|
twinkle: true,
|
|
},
|
|
'💀': {
|
|
style: 'scatter',
|
|
particleCount: 20,
|
|
gravity: 620,
|
|
drag: 0.92,
|
|
speedMin: 200,
|
|
speedMax: 420,
|
|
launchUpMin: 20,
|
|
launchUpMax: 90,
|
|
spinMin: -360,
|
|
spinMax: 360,
|
|
fontSizeMin: 16,
|
|
fontSizeMax: 28,
|
|
},
|
|
'🎉': {
|
|
style: 'shower',
|
|
particleCount: 40,
|
|
gravity: 280,
|
|
drag: 0.93,
|
|
speedMin: 200,
|
|
speedMax: 480,
|
|
launchUpMin: 80,
|
|
launchUpMax: 200,
|
|
companions: ['🎊', '✨', '🎈'],
|
|
companionChance: 0.45,
|
|
},
|
|
'🎊': {
|
|
style: 'shower',
|
|
particleCount: 38,
|
|
gravity: 260,
|
|
drag: 0.932,
|
|
speedMin: 190,
|
|
speedMax: 460,
|
|
launchUpMin: 70,
|
|
launchUpMax: 190,
|
|
companions: ['🎉', '✨'],
|
|
companionChance: 0.4,
|
|
},
|
|
'👏': {
|
|
style: 'punch',
|
|
particleCount: 18,
|
|
gravity: 320,
|
|
speedMin: 220,
|
|
speedMax: 400,
|
|
launchUpMin: 30,
|
|
launchUpMax: 100,
|
|
angleBias: -Math.PI / 2,
|
|
angleSpread: Math.PI * 0.85,
|
|
spinMin: -100,
|
|
spinMax: 100,
|
|
},
|
|
'👍': {
|
|
style: 'punch',
|
|
particleCount: 14,
|
|
gravity: 380,
|
|
speedMin: 260,
|
|
speedMax: 440,
|
|
launchUpMin: 100,
|
|
launchUpMax: 200,
|
|
angleBias: -Math.PI / 2,
|
|
angleSpread: Math.PI * 0.55,
|
|
spinMin: -60,
|
|
spinMax: 60,
|
|
},
|
|
'💯': {
|
|
style: 'punch',
|
|
particleCount: 16,
|
|
gravity: 300,
|
|
speedMin: 200,
|
|
speedMax: 380,
|
|
launchUpMin: 140,
|
|
launchUpMax: 240,
|
|
angleBias: -Math.PI / 2,
|
|
angleSpread: Math.PI * 0.45,
|
|
fontSizeMin: 16,
|
|
fontSizeMax: 30,
|
|
wobble: true,
|
|
},
|
|
'💦': {
|
|
style: 'splash',
|
|
particleCount: 32,
|
|
gravity: 540,
|
|
drag: 0.925,
|
|
speedMin: 280,
|
|
speedMax: 520,
|
|
launchUpMin: 160,
|
|
launchUpMax: 300,
|
|
spinMin: -140,
|
|
spinMax: 140,
|
|
angleBias: -Math.PI / 2,
|
|
angleSpread: Math.PI * 1.1,
|
|
},
|
|
'🌊': {
|
|
style: 'splash',
|
|
particleCount: 28,
|
|
gravity: 420,
|
|
drag: 0.93,
|
|
speedMin: 220,
|
|
speedMax: 460,
|
|
launchUpMin: 100,
|
|
launchUpMax: 220,
|
|
angleBias: -Math.PI / 2,
|
|
angleSpread: Math.PI,
|
|
companions: ['💧'],
|
|
companionChance: 0.3,
|
|
},
|
|
'⚡': {
|
|
style: 'scatter',
|
|
particleCount: 14,
|
|
gravity: 180,
|
|
drag: 0.88,
|
|
speedMin: 380,
|
|
speedMax: 680,
|
|
launchUpMin: 10,
|
|
launchUpMax: 60,
|
|
spinMin: -30,
|
|
spinMax: 30,
|
|
fontSizeMin: 18,
|
|
fontSizeMax: 32,
|
|
angleSpread: Math.PI * 1.2,
|
|
},
|
|
'🌀': {
|
|
style: 'spiral',
|
|
particleCount: 24,
|
|
gravity: 60,
|
|
drag: 0.96,
|
|
speedMin: 120,
|
|
speedMax: 260,
|
|
launchUpMin: 0,
|
|
launchUpMax: 40,
|
|
spinMin: -420,
|
|
spinMax: 420,
|
|
},
|
|
'🤯': {
|
|
style: 'explode',
|
|
particleCount: 34,
|
|
gravity: 360,
|
|
speedMin: 280,
|
|
speedMax: 580,
|
|
launchUpMin: 60,
|
|
launchUpMax: 160,
|
|
companions: ['💥', '✨', '⭐'],
|
|
companionChance: 0.5,
|
|
},
|
|
'😭': {
|
|
style: 'splash',
|
|
particleCount: 26,
|
|
gravity: 480,
|
|
drag: 0.94,
|
|
speedMin: 140,
|
|
speedMax: 300,
|
|
launchUpMin: 40,
|
|
launchUpMax: 120,
|
|
angleBias: Math.PI / 2,
|
|
angleSpread: Math.PI * 0.7,
|
|
companions: ['💧'],
|
|
companionChance: 0.55,
|
|
},
|
|
'🥳': {
|
|
style: 'shower',
|
|
particleCount: 36,
|
|
gravity: 250,
|
|
speedMin: 180,
|
|
speedMax: 440,
|
|
launchUpMin: 90,
|
|
launchUpMax: 210,
|
|
companions: ['🎉', '🎊', '🎈'],
|
|
companionChance: 0.4,
|
|
},
|
|
'🐱': {
|
|
style: 'bounce',
|
|
particleCount: 20,
|
|
gravity: 440,
|
|
speedMin: 150,
|
|
speedMax: 320,
|
|
launchUpMin: 90,
|
|
launchUpMax: 180,
|
|
spinMin: -160,
|
|
spinMax: 160,
|
|
},
|
|
'🐶': {
|
|
style: 'bounce',
|
|
particleCount: 20,
|
|
gravity: 460,
|
|
speedMin: 160,
|
|
speedMax: 330,
|
|
launchUpMin: 85,
|
|
launchUpMax: 175,
|
|
wobble: true,
|
|
},
|
|
};
|
|
|
|
export function getEmojiBurstProfile(emoji: string): EmojiBurstProfile {
|
|
const override = PROFILE_OVERRIDES[emoji];
|
|
if (!override) return DEFAULT_PROFILE;
|
|
return { ...DEFAULT_PROFILE, ...override };
|
|
}
|
|
|
|
export function pickParticleEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
|
|
if (!profile.companions?.length || !profile.companionChance) {
|
|
return primaryEmoji;
|
|
}
|
|
if (Math.random() >= profile.companionChance) {
|
|
return primaryEmoji;
|
|
}
|
|
const companion = profile.companions[Math.floor(Math.random() * profile.companions.length)];
|
|
return companion ?? primaryEmoji;
|
|
}
|
|
|
|
export function sampleBurstAngle(profile: EmojiBurstProfile): number {
|
|
if (profile.angleSpread === undefined) {
|
|
return Math.random() * Math.PI * 2;
|
|
}
|
|
|
|
const bias = profile.angleBias ?? -Math.PI / 2;
|
|
const halfSpread = profile.angleSpread / 2;
|
|
return bias + (Math.random() - 0.5) * profile.angleSpread * (0.6 + Math.random() * 0.4);
|
|
}
|