Compare commits
1 Commits
8a68a1e30a
...
13523fea2b
| Author | SHA1 | Date | |
|---|---|---|---|
| 13523fea2b |
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } 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 } from './findJumboMount';
|
||||||
@@ -8,6 +8,14 @@ import {
|
|||||||
spawnEmojiBurst,
|
spawnEmojiBurst,
|
||||||
stepBurstParticles,
|
stepBurstParticles,
|
||||||
} from './emojiBurstEngine';
|
} from './emojiBurstEngine';
|
||||||
|
import { getEmojiBurstProfile, isFullscreenBurstEmoji } from './emojiParticleProfiles';
|
||||||
|
import {
|
||||||
|
createFireworkSim,
|
||||||
|
drawFireworkSim,
|
||||||
|
FireworkSim,
|
||||||
|
getFireworkDpr,
|
||||||
|
stepFireworkSim,
|
||||||
|
} from './fireworkParticleEngine';
|
||||||
|
|
||||||
const MAX_DPR = 2;
|
const MAX_DPR = 2;
|
||||||
|
|
||||||
@@ -17,6 +25,147 @@ type EmojiConfettiBurstCanvasProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBurstCanvasProps) {
|
export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBurstCanvasProps) {
|
||||||
|
const primaryEmoji = burst.emojis[0] ?? '🎉';
|
||||||
|
const fullscreen = isFullscreenBurstEmoji(primaryEmoji);
|
||||||
|
|
||||||
|
if (fullscreen) {
|
||||||
|
return <FireworkBurstCanvas burst={burst} primaryEmoji={primaryEmoji} onComplete={onComplete} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LocalBurstCanvas burst={burst} primaryEmoji={primaryEmoji} onComplete={onComplete} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BurstCanvasProps = {
|
||||||
|
burst: EmojiConfettiBurst;
|
||||||
|
primaryEmoji: string;
|
||||||
|
onComplete: (burstId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const simRef = useRef<FireworkSim | null>(null);
|
||||||
|
const frameRef = useRef<number | null>(null);
|
||||||
|
const lastFrameTimeRef = useRef<number | null>(null);
|
||||||
|
const spawnedRef = useRef(false);
|
||||||
|
const onCompleteRef = useRef(onComplete);
|
||||||
|
onCompleteRef.current = onComplete;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current || spawnedRef.current) return undefined;
|
||||||
|
|
||||||
|
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
if (reducedMotion) {
|
||||||
|
onCompleteRef.current(burst.id);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const context = canvas.getContext('2d', { alpha: true });
|
||||||
|
if (!context) {
|
||||||
|
onCompleteRef.current(burst.id);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
context.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
spawnedRef.current = true;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const dpr = getFireworkDpr();
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
canvas.width = Math.round(width * dpr);
|
||||||
|
canvas.height = Math.round(height * dpr);
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
return { width, height, dpr };
|
||||||
|
};
|
||||||
|
|
||||||
|
const { width, height } = resize();
|
||||||
|
const jumbo = findJumboEmojiElement(burst.targetEventId);
|
||||||
|
const jumboRect = jumbo?.getBoundingClientRect();
|
||||||
|
const origin = {
|
||||||
|
x: jumboRect ? jumboRect.left + jumboRect.width / 2 : burst.origin.x,
|
||||||
|
y: jumboRect ? jumboRect.top + jumboRect.height / 2 : burst.origin.y,
|
||||||
|
maskRadius: burst.origin.maskRadius ?? 36,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clamp origin into the viewport so off-screen messages still blast on-screen.
|
||||||
|
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(width, height, origin, primaryEmoji, profile);
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
// Extra Things: match CSS size; sim keeps its launch-time dimensions.
|
||||||
|
resize();
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const sim = simRef.current;
|
||||||
|
if (!sim) return;
|
||||||
|
|
||||||
|
const last = lastFrameTimeRef.current ?? now;
|
||||||
|
const dtSeconds = Math.min((now - last) / 1000, 0.05);
|
||||||
|
lastFrameTimeRef.current = now;
|
||||||
|
|
||||||
|
const alive = stepFireworkSim(sim, now, dtSeconds);
|
||||||
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
if (alive) {
|
||||||
|
drawFireworkSim(context, sim, now);
|
||||||
|
frameRef.current = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCompleteRef.current(burst.id);
|
||||||
|
frameRef.current = null;
|
||||||
|
lastFrameTimeRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
lastFrameTimeRef.current = performance.now();
|
||||||
|
frameRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
if (frameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(frameRef.current);
|
||||||
|
frameRef.current = null;
|
||||||
|
}
|
||||||
|
simRef.current = null;
|
||||||
|
};
|
||||||
|
}, [burst.id, burst.origin.maskRadius, burst.origin.x, burst.origin.y, burst.targetEventId, primaryEmoji]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocalBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const layerRef = useRef<HTMLDivElement>(null);
|
const layerRef = useRef<HTMLDivElement>(null);
|
||||||
const particlesRef = useRef<BurstParticle[]>([]);
|
const particlesRef = useRef<BurstParticle[]>([]);
|
||||||
@@ -89,13 +238,7 @@ export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBur
|
|||||||
canvas.style.width = `${canvasSize}px`;
|
canvas.style.width = `${canvasSize}px`;
|
||||||
canvas.style.height = `${canvasSize}px`;
|
canvas.style.height = `${canvasSize}px`;
|
||||||
|
|
||||||
spawnEmojiBurst(
|
spawnEmojiBurst(particlesRef.current, burst.id, localOrigin, primaryEmoji, performance.now());
|
||||||
particlesRef.current,
|
|
||||||
burst.id,
|
|
||||||
localOrigin,
|
|
||||||
burst.emojis[0] ?? '🎉',
|
|
||||||
performance.now()
|
|
||||||
);
|
|
||||||
|
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
updateLayerPosition();
|
updateLayerPosition();
|
||||||
@@ -152,7 +295,7 @@ export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBur
|
|||||||
window.clearInterval(retry);
|
window.clearInterval(retry);
|
||||||
frameCleanup?.();
|
frameCleanup?.();
|
||||||
};
|
};
|
||||||
}, [burst.emojis, burst.id, burst.targetEventId, canvasSize, localOrigin]);
|
}, [burst.id, burst.targetEventId, canvasSize, localOrigin, primaryEmoji]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export type BurstMotionStyle =
|
|||||||
| 'spiral'
|
| 'spiral'
|
||||||
| 'punch'
|
| 'punch'
|
||||||
| 'scatter'
|
| 'scatter'
|
||||||
| 'shower';
|
| 'shower'
|
||||||
|
| 'firework';
|
||||||
|
|
||||||
export type EmojiBurstProfile = {
|
export type EmojiBurstProfile = {
|
||||||
style: BurstMotionStyle;
|
style: BurstMotionStyle;
|
||||||
@@ -32,6 +33,8 @@ export type EmojiBurstProfile = {
|
|||||||
angleSpread?: number;
|
angleSpread?: number;
|
||||||
twinkle?: boolean;
|
twinkle?: boolean;
|
||||||
wobble?: boolean;
|
wobble?: boolean;
|
||||||
|
/** Full-viewport overlay (Box2D pile for fireworks). */
|
||||||
|
fullscreen?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PROFILE: EmojiBurstProfile = {
|
const DEFAULT_PROFILE: EmojiBurstProfile = {
|
||||||
@@ -340,6 +343,45 @@ const PROFILE_OVERRIDES: Record<string, Partial<EmojiBurstProfile>> = {
|
|||||||
companions: ['🎉', '🎊', '🎈'],
|
companions: ['🎉', '🎊', '🎈'],
|
||||||
companionChance: 0.4,
|
companionChance: 0.4,
|
||||||
},
|
},
|
||||||
|
'🎆': {
|
||||||
|
style: 'firework',
|
||||||
|
fullscreen: true,
|
||||||
|
particleCount: 320,
|
||||||
|
gravity: 980,
|
||||||
|
drag: 1,
|
||||||
|
speedMin: 420,
|
||||||
|
speedMax: 980,
|
||||||
|
launchUpMin: 180,
|
||||||
|
launchUpMax: 420,
|
||||||
|
spinMin: -520,
|
||||||
|
spinMax: 520,
|
||||||
|
fontSizeMin: 16,
|
||||||
|
fontSizeMax: 30,
|
||||||
|
heroFontSizeMin: 40,
|
||||||
|
heroFontSizeMax: 52,
|
||||||
|
companions: ['🎇', '✨', '💥', '⭐', '🎉'],
|
||||||
|
companionChance: 0.55,
|
||||||
|
},
|
||||||
|
'🎇': {
|
||||||
|
style: 'firework',
|
||||||
|
fullscreen: true,
|
||||||
|
particleCount: 280,
|
||||||
|
gravity: 960,
|
||||||
|
drag: 1,
|
||||||
|
speedMin: 380,
|
||||||
|
speedMax: 920,
|
||||||
|
launchUpMin: 160,
|
||||||
|
launchUpMax: 400,
|
||||||
|
spinMin: -480,
|
||||||
|
spinMax: 480,
|
||||||
|
fontSizeMin: 14,
|
||||||
|
fontSizeMax: 28,
|
||||||
|
heroFontSizeMin: 36,
|
||||||
|
heroFontSizeMax: 48,
|
||||||
|
companions: ['🎆', '✨', '⭐', '💥'],
|
||||||
|
companionChance: 0.5,
|
||||||
|
twinkle: true,
|
||||||
|
},
|
||||||
'🐱': {
|
'🐱': {
|
||||||
style: 'bounce',
|
style: 'bounce',
|
||||||
particleCount: 20,
|
particleCount: 20,
|
||||||
@@ -369,6 +411,10 @@ export function getEmojiBurstProfile(emoji: string): EmojiBurstProfile {
|
|||||||
return { ...DEFAULT_PROFILE, ...override };
|
return { ...DEFAULT_PROFILE, ...override };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isFullscreenBurstEmoji(emoji: string): boolean {
|
||||||
|
return getEmojiBurstProfile(emoji).fullscreen === true;
|
||||||
|
}
|
||||||
|
|
||||||
export function pickParticleEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
|
export function pickParticleEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
|
||||||
if (!profile.companions?.length || !profile.companionChance) {
|
if (!profile.companions?.length || !profile.companionChance) {
|
||||||
return primaryEmoji;
|
return primaryEmoji;
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -4,21 +4,30 @@ import { readClipboardImage, isTauri, isLinux } from '../utils/tauri';
|
|||||||
|
|
||||||
export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler =>
|
export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler =>
|
||||||
useCallback(
|
useCallback(
|
||||||
async (evt) => {
|
(evt) => {
|
||||||
const files = getDataTransferFiles(evt.clipboardData);
|
const files = getDataTransferFiles(evt.clipboardData);
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
|
evt.preventDefault();
|
||||||
onPaste(files);
|
onPaste(files);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// On Linux with Tauri, browser clipboard API doesn't work for images
|
// Address-bar / plain-text copies still win. On Linux the native clipboard
|
||||||
// Use our custom Tauri command with arboard/Wayland support
|
// often still has a previous image (or a junk bitmap) alongside text/plain;
|
||||||
if (isTauri() && isLinux()) {
|
// don't treat those as an image paste.
|
||||||
const clipboardImage = await readClipboardImage();
|
const text = evt.clipboardData?.getData('text/plain')?.trim() ?? '';
|
||||||
if (clipboardImage) {
|
if (text.length > 0) {
|
||||||
onPaste([clipboardImage]);
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Browser clipboardData.files is empty for raw image copies on Linux Tauri/Electron.
|
||||||
|
if (!(isTauri() && isLinux())) return;
|
||||||
|
|
||||||
|
// preventDefault before the async read, or the editor also inserts text.
|
||||||
|
evt.preventDefault();
|
||||||
|
void readClipboardImage().then((clipboardImage) => {
|
||||||
|
if (clipboardImage) onPaste([clipboardImage]);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[onPaste]
|
[onPaste]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -534,12 +534,20 @@ export const readClipboardImage = async (): Promise<File | null> => {
|
|||||||
try {
|
try {
|
||||||
const electron = (window as any).electron;
|
const electron = (window as any).electron;
|
||||||
if (electron?.clipboard?.readImage) {
|
if (electron?.clipboard?.readImage) {
|
||||||
const dataUrl = await electron.clipboard.readImage();
|
const result = await electron.clipboard.readImage();
|
||||||
|
// IPC may return a data URL string or { success, data } depending on path.
|
||||||
|
const dataUrl =
|
||||||
|
typeof result === 'string'
|
||||||
|
? result
|
||||||
|
: result && typeof result === 'object' && typeof result.data === 'string'
|
||||||
|
? result.data
|
||||||
|
: null;
|
||||||
if (!dataUrl) return null;
|
if (!dataUrl) return null;
|
||||||
|
|
||||||
// Convert data URL to File
|
// Convert data URL to File
|
||||||
const response = await fetch(dataUrl);
|
const response = await fetch(dataUrl);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
|
if (blob.size < 32) return null;
|
||||||
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
|
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -559,6 +567,7 @@ export const readClipboardImage = async (): Promise<File | null> => {
|
|||||||
// Convert data URL to File
|
// Convert data URL to File
|
||||||
const response = await fetch(dataUrl);
|
const response = await fetch(dataUrl);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
|
if (blob.size < 32) return null;
|
||||||
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
|
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to read Tauri clipboard image:', err);
|
console.warn('Failed to read Tauri clipboard image:', err);
|
||||||
|
|||||||
Reference in New Issue
Block a user