Add fullscreen firework emoji confetti and fix Linux URL paste.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Sparkle 🎆/🎇 from the jumbo emoji with a lightweight particle fountain that piles on the floor. Prefer text/plain over sticky clipboard images on Linux so address-bar URLs do not also attach a leftover bitmap.
This commit is contained in:
336
src/app/features/room/emoji-confetti/fireworkParticleEngine.ts
Normal file
336
src/app/features/room/emoji-confetti/fireworkParticleEngine.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Old-school fullscreen firework fountain — no physics engine.
|
||||
* Position/velocity arrays, gravity, floor bounce, settle into a pile.
|
||||
* Same vibe as late-90s/MSN page effects, just with emoji.
|
||||
*/
|
||||
import { BurstPoint } from './burstOrigin';
|
||||
import { pickParticleEmoji, type EmojiBurstProfile } from './emojiParticleProfiles';
|
||||
|
||||
const EMOJI_CACHE_PX = 40;
|
||||
const EMOJI_CACHE_SCALE = 2;
|
||||
|
||||
const SPAWN_WINDOW_MS = 2200;
|
||||
const SETTLE_HOLD_MS = 7800;
|
||||
const FADE_MS = 800;
|
||||
export const FIREWORK_TOTAL_MS = SETTLE_HOLD_MS + FADE_MS;
|
||||
|
||||
const SPARKLE_IN_MS = 120;
|
||||
const MAX_SPAWNS_PER_FRAME = 6;
|
||||
const FLOOR_PAD = 8;
|
||||
const BOUNCE = 0.38;
|
||||
const DRAG = 0.992;
|
||||
const SETTLE_SPEED = 55;
|
||||
const PILE_CELL = 22;
|
||||
|
||||
type SpawnItem = {
|
||||
atMs: number;
|
||||
emoji: string;
|
||||
fontSize: number;
|
||||
};
|
||||
|
||||
export type FireworkParticle = {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
spin: number;
|
||||
angle: number;
|
||||
emoji: string;
|
||||
emojiCanvas: HTMLCanvasElement;
|
||||
drawSize: number;
|
||||
halfSize: number;
|
||||
bornAt: number;
|
||||
settled: boolean;
|
||||
};
|
||||
|
||||
export type FireworkSim = {
|
||||
particles: FireworkParticle[];
|
||||
profile: EmojiBurstProfile;
|
||||
width: number;
|
||||
height: number;
|
||||
origin: BurstPoint;
|
||||
startMs: number;
|
||||
spawnPlan: SpawnItem[];
|
||||
spawnCursor: number;
|
||||
floorY: number;
|
||||
/** Column stack heights for the settled pile (in cells). */
|
||||
pileCols: Uint16Array;
|
||||
pileCanvas: HTMLCanvasElement | null;
|
||||
pileCtx: CanvasRenderingContext2D | null;
|
||||
flyingCount: number;
|
||||
};
|
||||
|
||||
const emojiCanvasCache = new Map<string, HTMLCanvasElement>();
|
||||
|
||||
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 size = EMOJI_CACHE_PX * EMOJI_CACHE_SCALE;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
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);
|
||||
}
|
||||
emojiCanvasCache.set(emoji, canvas);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function buildSpawnPlan(
|
||||
profile: EmojiBurstProfile,
|
||||
primaryEmoji: string,
|
||||
startMs: number
|
||||
): SpawnItem[] {
|
||||
const plan: SpawnItem[] = [
|
||||
{
|
||||
atMs: startMs,
|
||||
emoji: primaryEmoji,
|
||||
fontSize: lerp(profile.heroFontSizeMin, profile.heroFontSizeMax),
|
||||
},
|
||||
];
|
||||
|
||||
const count = profile.particleCount;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const slot = (i + 0.5) / count;
|
||||
const eased = slot * slot;
|
||||
plan.push({
|
||||
atMs:
|
||||
startMs +
|
||||
80 +
|
||||
eased * SPAWN_WINDOW_MS +
|
||||
(Math.random() - 0.5) * 70,
|
||||
emoji: pickParticleEmoji(profile, primaryEmoji),
|
||||
fontSize: lerp(profile.fontSizeMin, profile.fontSizeMax),
|
||||
});
|
||||
}
|
||||
|
||||
plan.sort((a, b) => a.atMs - b.atMs);
|
||||
return plan;
|
||||
}
|
||||
|
||||
function ensurePile(sim: FireworkSim): CanvasRenderingContext2D {
|
||||
if (sim.pileCanvas && sim.pileCtx) return sim.pileCtx;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = sim.width;
|
||||
canvas.height = sim.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('firework pile unsupported');
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
sim.pileCanvas = canvas;
|
||||
sim.pileCtx = ctx;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function stampSettled(sim: FireworkSim, p: FireworkParticle) {
|
||||
const ctx = ensurePile(sim);
|
||||
const rad = (p.angle * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
ctx.setTransform(cos, sin, -sin, cos, p.x, p.y);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.drawImage(p.emojiCanvas, -p.halfSize, -p.halfSize, p.drawSize, p.drawSize);
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
}
|
||||
|
||||
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 speed = lerp(profile.speedMin, profile.speedMax);
|
||||
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
|
||||
const drawSize = item.fontSize * EMOJI_CACHE_SCALE * 0.85;
|
||||
|
||||
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,
|
||||
spin: lerp(profile.spinMin, profile.spinMax) * 0.35,
|
||||
angle: (Math.random() - 0.5) * 40,
|
||||
emoji: item.emoji,
|
||||
emojiCanvas: getEmojiCanvas(item.emoji),
|
||||
drawSize,
|
||||
halfSize: drawSize / 2,
|
||||
bornAt: now,
|
||||
settled: false,
|
||||
});
|
||||
sim.flyingCount += 1;
|
||||
}
|
||||
|
||||
function settleParticle(sim: FireworkSim, p: FireworkParticle) {
|
||||
const cols = sim.pileCols.length;
|
||||
let col = Math.floor(p.x / PILE_CELL);
|
||||
if (col < 0) col = 0;
|
||||
if (col >= cols) col = cols - 1;
|
||||
|
||||
// Prefer the intended column; spill to neighbors if that stack is already tall.
|
||||
let bestCol = col;
|
||||
let bestH = sim.pileCols[col];
|
||||
for (const delta of [0, -1, 1, -2, 2]) {
|
||||
const c = col + delta;
|
||||
if (c < 0 || c >= cols) continue;
|
||||
if (sim.pileCols[c] < bestH) {
|
||||
bestH = sim.pileCols[c];
|
||||
bestCol = c;
|
||||
}
|
||||
}
|
||||
|
||||
const stack = sim.pileCols[bestCol];
|
||||
sim.pileCols[bestCol] = stack + 1;
|
||||
p.x = bestCol * PILE_CELL + PILE_CELL * 0.5 + (Math.random() - 0.5) * 6;
|
||||
p.y = sim.floorY - stack * (PILE_CELL * 0.72) - p.halfSize;
|
||||
p.vx = 0;
|
||||
p.vy = 0;
|
||||
p.spin = 0;
|
||||
p.settled = true;
|
||||
sim.flyingCount = Math.max(0, sim.flyingCount - 1);
|
||||
stampSettled(sim, p);
|
||||
}
|
||||
|
||||
export function createFireworkSim(
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
originPx: BurstPoint,
|
||||
primaryEmoji: string,
|
||||
profile: EmojiBurstProfile,
|
||||
startMs = performance.now()
|
||||
): FireworkSim {
|
||||
const colCount = Math.max(8, Math.ceil(widthPx / PILE_CELL));
|
||||
return {
|
||||
particles: [],
|
||||
profile,
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
origin: originPx,
|
||||
startMs,
|
||||
spawnPlan: buildSpawnPlan(profile, primaryEmoji, startMs),
|
||||
spawnCursor: 0,
|
||||
floorY: heightPx - FLOOR_PAD,
|
||||
pileCols: new Uint16Array(colCount),
|
||||
pileCanvas: null,
|
||||
pileCtx: null,
|
||||
flyingCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** @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;
|
||||
|
||||
const dt = Math.min(dtSeconds, 0.05);
|
||||
const g = sim.profile.gravity;
|
||||
|
||||
let spawned = 0;
|
||||
while (
|
||||
spawned < MAX_SPAWNS_PER_FRAME &&
|
||||
sim.spawnCursor < sim.spawnPlan.length &&
|
||||
sim.spawnPlan[sim.spawnCursor].atMs <= now
|
||||
) {
|
||||
spawnOne(sim, sim.spawnPlan[sim.spawnCursor], now);
|
||||
sim.spawnCursor += 1;
|
||||
spawned += 1;
|
||||
}
|
||||
|
||||
const floor = sim.floorY;
|
||||
const left = 4;
|
||||
const right = sim.width - 4;
|
||||
|
||||
for (let i = 0; i < sim.particles.length; i += 1) {
|
||||
const p = sim.particles[i];
|
||||
if (p.settled) continue;
|
||||
|
||||
p.vy += g * dt;
|
||||
p.vx *= DRAG;
|
||||
p.vy *= DRAG;
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.angle += p.spin * dt;
|
||||
|
||||
if (p.x < left) {
|
||||
p.x = left;
|
||||
p.vx = Math.abs(p.vx) * BOUNCE;
|
||||
} else if (p.x > right) {
|
||||
p.x = right;
|
||||
p.vx = -Math.abs(p.vx) * BOUNCE;
|
||||
}
|
||||
|
||||
if (p.y >= floor - p.halfSize) {
|
||||
p.y = floor - p.halfSize;
|
||||
const speed = Math.hypot(p.vx, p.vy);
|
||||
if (speed < SETTLE_SPEED || Math.abs(p.vy) < SETTLE_SPEED * 0.55) {
|
||||
settleParticle(sim, p);
|
||||
} else {
|
||||
p.vy = -Math.abs(p.vy) * BOUNCE;
|
||||
p.vx *= 0.85;
|
||||
p.spin *= 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function drawFireworkSim(
|
||||
context: CanvasRenderingContext2D,
|
||||
sim: FireworkSim,
|
||||
now: number
|
||||
) {
|
||||
const elapsed = now - sim.startMs;
|
||||
let fade = 1;
|
||||
if (elapsed >= SETTLE_HOLD_MS) {
|
||||
fade = 1 - Math.min(1, (elapsed - SETTLE_HOLD_MS) / FADE_MS);
|
||||
}
|
||||
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.imageSmoothingEnabled = false;
|
||||
|
||||
if (sim.pileCanvas) {
|
||||
context.globalAlpha = fade;
|
||||
context.drawImage(sim.pileCanvas, 0, 0);
|
||||
}
|
||||
|
||||
for (let i = 0; i < sim.particles.length; i += 1) {
|
||||
const p = sim.particles[i];
|
||||
if (p.settled) continue;
|
||||
|
||||
const ageMs = now - p.bornAt;
|
||||
if (ageMs < 0) continue;
|
||||
|
||||
let opacity = fade;
|
||||
let scale = 1;
|
||||
if (ageMs < SPARKLE_IN_MS) {
|
||||
const t = ageMs / SPARKLE_IN_MS;
|
||||
const appear = t * t * (3 - 2 * t);
|
||||
scale = 0.5 + 0.65 * appear;
|
||||
opacity *= appear;
|
||||
}
|
||||
if (opacity <= 0.02) continue;
|
||||
|
||||
const rad = (p.angle * Math.PI) / 180;
|
||||
const size = p.drawSize * scale;
|
||||
const half = size / 2;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
context.globalAlpha = opacity;
|
||||
context.setTransform(cos, sin, -sin, cos, p.x, p.y);
|
||||
context.drawImage(p.emojiCanvas, -half, -half, size, size);
|
||||
}
|
||||
|
||||
context.globalAlpha = 1;
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
}
|
||||
|
||||
export function getFireworkDpr(): number {
|
||||
return 1;
|
||||
}
|
||||
Reference in New Issue
Block a user