mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
feat(renderer): add winner-reveal confetti burst
Deterministic frame-based confetti fired from the wheel center during the winner hold, seeded by winner index and colored from the theme palette. Uses Remotion's random() so each GIF frame renders identically.
This commit is contained in:
1 parent
5ca7e343ac
commit
376a7ae688
4 files changed
+268
No files matched your search
@@ -0,0 +1,64 @@
|
||||
import {AbsoluteFill, interpolate, random, useCurrentFrame, useVideoConfig} from 'remotion';
|
||||
import {createConfettiParticles, getConfettiParticleState} from './confetti-layout.js';
|
||||
|
||||
/**
|
||||
* Winner-celebration confetti burst. Renders nothing before `startFrame`, then
|
||||
* fires a deterministic paper-strip burst from the wheel center through the end
|
||||
* of the composition.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {number} props.startFrame frame the burst begins on (winner reveal)
|
||||
* @param {string[]} props.colors palette (theme slice colors)
|
||||
* @param {number|string} props.seed reproducibility seed (e.g. winner index)
|
||||
* @param {number} [props.count] particle count
|
||||
*/
|
||||
export const Confetti = ({startFrame, colors, seed, count = 70}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const {durationInFrames, fps, height, width} = useVideoConfig();
|
||||
|
||||
if (frame < startFrame) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const size = Math.min(width, height);
|
||||
const lifetimeFrames = Math.max(1, durationInFrames - 1 - startFrame);
|
||||
const localFrame = frame - startFrame;
|
||||
const seconds = localFrame / fps;
|
||||
const progress = localFrame / lifetimeFrames;
|
||||
const opacity = interpolate(progress, [0, 0.06, 0.7, 1], [0, 1, 1, 0], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
|
||||
const particles = createConfettiParticles({
|
||||
colors,
|
||||
count,
|
||||
random: (key) => random(`${seed}-${key}`),
|
||||
size,
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{opacity, pointerEvents: 'none', zIndex: 10}}>
|
||||
{particles.map((particle, index) => {
|
||||
const state = getConfettiParticleState(particle, seconds);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`confetti-${index}`}
|
||||
style={{
|
||||
background: particle.color,
|
||||
borderRadius: 1,
|
||||
height: particle.height,
|
||||
left: state.x,
|
||||
position: 'absolute',
|
||||
top: state.y,
|
||||
transform: `translate(-50%, -50%) rotate(${state.rotation}deg)`,
|
||||
transformOrigin: '50% 50%',
|
||||
width: particle.width,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from './wheel-layout.js';
|
||||
import {getRadialLabelLayout} from './wheel-label-layout.js';
|
||||
import {getTheme} from './themes.js';
|
||||
import {Confetti} from './Confetti.jsx';
|
||||
|
||||
const baseFont =
|
||||
'Quicksand, Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
@@ -181,6 +182,8 @@ export const WheelComposition = (props) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Confetti colors={theme.slices} seed={props.winnerIndex} startFrame={holdStartFrame} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Deterministic confetti field for the winner celebration.
|
||||
*
|
||||
* Remotion renders every GIF frame independently in headless Chromium, so the
|
||||
* animation must be a pure function of the frame number. These helpers turn a
|
||||
* seeded random source into stable particle descriptors and evaluate their
|
||||
* ballistic path at an arbitrary time, keeping the visual identical on every
|
||||
* render of the same seed.
|
||||
*/
|
||||
|
||||
/** Canvas size the physics constants below are tuned for. */
|
||||
export const CONFETTI_REFERENCE_SIZE = 512;
|
||||
|
||||
/**
|
||||
* @param {number} t normalized value in [0, 1]
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number}
|
||||
*/
|
||||
const lerp = (t, min, max) => min + t * (max - min);
|
||||
|
||||
/**
|
||||
* @typedef {object} ConfettiParticle
|
||||
* @property {string} color
|
||||
* @property {number} gravity px/s^2 downward acceleration
|
||||
* @property {number} height px
|
||||
* @property {number} originX px
|
||||
* @property {number} originY px
|
||||
* @property {number} rotationSpeed deg/s
|
||||
* @property {number} startRotation deg
|
||||
* @property {number} vx px/s
|
||||
* @property {number} vy px/s (negative points up on screen)
|
||||
* @property {number} width px
|
||||
*/
|
||||
|
||||
/**
|
||||
* Builds the confetti particles bursting outward and upward from the wheel
|
||||
* center. Physics scale with `size` relative to {@link CONFETTI_REFERENCE_SIZE}.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {number} params.count number of particles
|
||||
* @param {number} params.size canvas edge length in px
|
||||
* @param {string[]} params.colors palette to cycle through
|
||||
* @param {(seed: string) => number} params.random deterministic [0, 1) source
|
||||
* @returns {ConfettiParticle[]}
|
||||
*/
|
||||
export const createConfettiParticles = ({count, size, colors, random}) => {
|
||||
const scale = size / CONFETTI_REFERENCE_SIZE;
|
||||
const center = size / 2;
|
||||
const palette = colors.length > 0 ? colors : ['#f97316'];
|
||||
|
||||
return Array.from({length: count}, (_, index) => {
|
||||
const rand = (/** @type {string} */ key) => random(`${index}-${key}`);
|
||||
// Fan upward: -160deg..-20deg in screen coords (0deg right, -90deg up).
|
||||
const angleRadians = (lerp(rand('angle'), -160, -20) * Math.PI) / 180;
|
||||
const speed = lerp(rand('speed'), 320, 820) * scale;
|
||||
const width = lerp(rand('width'), 6, 12) * scale;
|
||||
|
||||
return {
|
||||
color: palette[Math.floor(rand('color') * palette.length)] ?? palette[0] ?? '#f97316',
|
||||
gravity: 1500 * scale,
|
||||
height: width * lerp(rand('ratio'), 0.4, 1),
|
||||
originX: center + lerp(rand('originX'), -0.06, 0.06) * size,
|
||||
originY: center + lerp(rand('originY'), -0.04, 0.02) * size,
|
||||
rotationSpeed: lerp(rand('spin'), -540, 540),
|
||||
startRotation: lerp(rand('rotation'), 0, 360),
|
||||
vx: Math.cos(angleRadians) * speed,
|
||||
vy: Math.sin(angleRadians) * speed,
|
||||
width,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates a particle's position and rotation at `seconds` after the burst.
|
||||
*
|
||||
* @param {ConfettiParticle} particle
|
||||
* @param {number} seconds elapsed since the burst started
|
||||
* @returns {{rotation: number, x: number, y: number}}
|
||||
*/
|
||||
export const getConfettiParticleState = (particle, seconds) => ({
|
||||
rotation: particle.startRotation + particle.rotationSpeed * seconds,
|
||||
x: particle.originX + particle.vx * seconds,
|
||||
y: particle.originY + particle.vy * seconds + 0.5 * particle.gravity * seconds * seconds,
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {
|
||||
CONFETTI_REFERENCE_SIZE,
|
||||
createConfettiParticles,
|
||||
getConfettiParticleState,
|
||||
} from '../src/remotion/confetti-layout.js';
|
||||
|
||||
// Cyclic pseudo-random source: stable, spans (0, 1), and keys map to values.
|
||||
const makeRandom = () => {
|
||||
const seen = new Map();
|
||||
let next = 0;
|
||||
return (/** @type {string} */ seed) => {
|
||||
if (!seen.has(seed)) {
|
||||
seen.set(seed, (next++ % 10) / 10 + 0.05);
|
||||
}
|
||||
return seen.get(seed);
|
||||
};
|
||||
};
|
||||
|
||||
describe('confetti layout', () => {
|
||||
test('creates the requested particle count', () => {
|
||||
const particles = createConfettiParticles({
|
||||
colors: ['#f97316', '#14b8a6'],
|
||||
count: 24,
|
||||
random: makeRandom(),
|
||||
size: 512,
|
||||
});
|
||||
|
||||
expect(particles).toHaveLength(24);
|
||||
});
|
||||
|
||||
test('is deterministic for the same seed source', () => {
|
||||
const params = {colors: ['#f97316', '#14b8a6'], count: 8, size: 512};
|
||||
const first = createConfettiParticles({...params, random: makeRandom()});
|
||||
const second = createConfettiParticles({...params, random: makeRandom()});
|
||||
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
test('draws colors only from the provided palette', () => {
|
||||
const palette = ['#f97316', '#14b8a6', '#facc15'];
|
||||
const particles = createConfettiParticles({
|
||||
colors: palette,
|
||||
count: 40,
|
||||
random: makeRandom(),
|
||||
size: 512,
|
||||
});
|
||||
|
||||
for (const particle of particles) {
|
||||
expect(palette).toContain(particle.color);
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to a default color when palette is empty', () => {
|
||||
const particles = createConfettiParticles({
|
||||
colors: [],
|
||||
count: 4,
|
||||
random: makeRandom(),
|
||||
size: 512,
|
||||
});
|
||||
|
||||
for (const particle of particles) {
|
||||
expect(typeof particle.color).toBe('string');
|
||||
expect(particle.color.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('scales physics with canvas size', () => {
|
||||
const [small] = createConfettiParticles({colors: ['#000'], count: 1, random: makeRandom(), size: 256});
|
||||
const [large] = createConfettiParticles({colors: ['#000'], count: 1, random: makeRandom(), size: 1024});
|
||||
|
||||
if (!small || !large) {
|
||||
throw new Error('expected one particle per call');
|
||||
}
|
||||
|
||||
expect(large.gravity).toBeCloseTo(small.gravity * 4);
|
||||
expect(large.width).toBeCloseTo(small.width * 4);
|
||||
});
|
||||
|
||||
test('reference size keeps gravity at the tuned constant', () => {
|
||||
const [particle] = createConfettiParticles({
|
||||
colors: ['#000'],
|
||||
count: 1,
|
||||
random: makeRandom(),
|
||||
size: CONFETTI_REFERENCE_SIZE,
|
||||
});
|
||||
|
||||
expect(particle?.gravity).toBe(1500);
|
||||
});
|
||||
|
||||
test('particle starts at its origin and accelerates downward', () => {
|
||||
const particle = {
|
||||
color: '#000',
|
||||
gravity: 1500,
|
||||
height: 8,
|
||||
originX: 100,
|
||||
originY: 200,
|
||||
rotationSpeed: 90,
|
||||
startRotation: 10,
|
||||
vx: 50,
|
||||
vy: -300,
|
||||
width: 8,
|
||||
};
|
||||
|
||||
expect(getConfettiParticleState(particle, 0)).toEqual({
|
||||
rotation: 10,
|
||||
x: 100,
|
||||
y: 200,
|
||||
});
|
||||
|
||||
const later = getConfettiParticleState(particle, 1);
|
||||
expect(later.x).toBe(150);
|
||||
expect(later.y).toBe(650); // 200 - 300 + 0.5 * 1500
|
||||
expect(later.rotation).toBe(100);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user