use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::RenderCtx;
use crate::motion::{Mulberry32, REFERENCE_VMIN, interpolate};
pub const DEFAULT_COLORS: [&str; 6] = [
"#1d9bf0", "#ff5da2", "#ffd23f", "#22c55e", "#a855f7", "#ff7a45",
];
#[derive(Debug, Clone, PartialEq)]
pub struct Particle {
pub angle: f32,
pub speed: f32,
pub color: String,
pub size: f32,
pub rot0: f32,
pub spin: f32,
pub drift: f32,
}
pub fn make_particles(count: usize, seed: u32, colors: &[String]) -> Vec<Particle> {
let palette: Vec<String> = if colors.is_empty() {
DEFAULT_COLORS.iter().map(|c| (*c).to_owned()).collect()
} else {
colors.to_vec()
};
let mut rand = Mulberry32::new(seed);
let mut out = Vec::with_capacity(count);
for _ in 0..count {
let angle = rand.next_f32() * std::f32::consts::TAU;
let speed = 0.35 + 0.65 * rand.next_f32();
let color = palette[rand.index(palette.len())].clone();
out.push(Particle {
angle,
speed,
color,
size: 0.7 + 0.6 * rand.next_f32(),
rot0: rand.next_f32() * std::f32::consts::TAU,
spin: (rand.next_f32() - 0.5) * 0.6,
drift: (rand.next_f32() - 0.5) * 2.0,
});
}
out
}
pub fn particle_opacity(tt: f32, lifetime: f32) -> f32 {
if tt < 0.0 || tt > lifetime {
return 0.0;
}
let fade_in = interpolate(tt, [0.0, 4.0], [0.0, 1.0]);
let fade_out = interpolate(tt, [lifetime * 0.7, lifetime], [1.0, 0.0]);
fade_in.min(fade_out)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ParticlePose {
pub x: f32,
pub y: f32,
pub rotate_deg: f32,
pub flutter: f32,
}
pub fn particle_pose(
particle: &Particle,
tt: f32,
origin: (f32, f32),
power: f32,
gravity: f32,
unit: f32,
) -> ParticlePose {
let (ox, oy) = origin;
let vx = particle.angle.cos() * particle.speed * power + particle.drift;
let vy = particle.angle.sin() * particle.speed * power;
ParticlePose {
x: ox + vx * tt * unit,
y: oy + (vy * tt + 0.5 * gravity * tt * tt) * unit,
rotate_deg: (particle.rot0 + particle.spin * tt).to_degrees(),
flutter: (particle.rot0 + particle.spin * tt * 1.6).cos(),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ConfettiConfig {
pub particle_count: usize,
pub colors: Vec<String>,
pub origin_x: f32,
pub origin_y: f32,
pub start_frame: usize,
pub lifetime: usize,
pub power: f32,
pub gravity: f32,
pub size: f32,
pub seed: u32,
pub aspect: f32,
pub corner_radius: f32,
}
impl Default for ConfettiConfig {
fn default() -> Self {
Self {
particle_count: 140,
colors: Vec::new(),
origin_x: 0.5,
origin_y: 0.5,
start_frame: 0,
lifetime: 90,
power: 17.0,
gravity: 0.45,
size: 13.0,
seed: 1,
aspect: 0.62,
corner_radius: 2.0,
}
}
}
#[derive(Debug, Clone)]
pub struct Confetti {
pub config: ConfettiConfig,
particles: std::sync::OnceLock<Vec<Particle>>,
}
impl Default for Confetti {
fn default() -> Self {
Self::new(ConfettiConfig::default())
}
}
impl Confetti {
pub fn new(config: ConfettiConfig) -> Self {
Self {
config,
particles: std::sync::OnceLock::new(),
}
}
pub fn particles(&self) -> &[Particle] {
self.particles.get_or_init(|| {
make_particles(
self.config.particle_count,
self.config.seed,
&self.config.colors,
)
})
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "confetti",
name: "Confetti",
summary: "A deterministic burst of drifting confetti pieces.",
feature: "confetti",
}
}
}
impl Component for Confetti {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
self.config.start_frame + self.config.lifetime.max(1)
}
fn background(&self) -> Option<&'static str> {
Some("#0d1117")
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
let unit = ctx.vmin() / REFERENCE_VMIN;
let tt = ctx.frame() - config.start_frame as f32;
let opacity = particle_opacity(tt, config.lifetime as f32);
if opacity <= 0.0 {
return Svgr::empty();
}
let origin = (
ctx.width() * config.origin_x,
ctx.height() * config.origin_y,
);
let mut nodes: Vec<Svgr<'_>> = Vec::new();
for particle in self.particles() {
let pose = particle_pose(particle, tt, origin, config.power, config.gravity, unit);
let width = config.size * particle.size * unit;
let height = width * config.aspect;
let rx = config.corner_radius * unit;
let transform = format!(
"rotate({:.3} {:.3} {:.3}) translate({:.3} {:.3}) scale({:.4} 1)",
pose.rotate_deg,
pose.x,
pose.y,
-width / 2.0,
-height / 2.0,
pose.flutter,
);
nodes.push(fframes::svgr!(
<rect x="0" y="0" width={width} height={height} rx={rx}
fill={particle.color.clone()} opacity={opacity}
transform={transform} />
));
}
let nodes = Svgr::from(nodes);
fframes::svgr!(<g>{nodes}</g>)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn colors() -> Vec<String> {
DEFAULT_COLORS.iter().map(|c| (*c).to_owned()).collect()
}
#[test]
fn the_same_seed_builds_the_same_particles() {
let a = make_particles(20, 7, &colors());
let b = make_particles(20, 7, &colors());
assert_eq!(a, b);
let c = make_particles(20, 8, &colors());
assert_ne!(a, c);
}
#[test]
fn particles_stay_inside_their_documented_ranges() {
for particle in make_particles(500, 3, &colors()) {
assert!((0.35..=1.0).contains(&particle.speed), "{}", particle.speed);
assert!((0.7..=1.3).contains(&particle.size), "{}", particle.size);
assert!((-0.3..=0.3).contains(&particle.spin), "{}", particle.spin);
assert!((-1.0..=1.0).contains(&particle.drift), "{}", particle.drift);
assert!(colors().contains(&particle.color));
}
}
#[test]
fn an_empty_palette_falls_back_to_the_defaults() {
let particles = make_particles(5, 1, &[]);
assert!(
particles
.iter()
.all(|p| DEFAULT_COLORS.contains(&p.color.as_str()))
);
}
#[test]
fn the_opacity_envelope_rises_and_falls() {
assert!(particle_opacity(-1.0, 90.0).abs() < 1e-6);
assert!(particle_opacity(91.0, 90.0).abs() < 1e-6);
assert!(particle_opacity(0.0, 90.0).abs() < 1e-6);
assert!((particle_opacity(4.0, 90.0) - 1.0).abs() < 1e-6);
assert!((particle_opacity(45.0, 90.0) - 1.0).abs() < 1e-6);
assert!(particle_opacity(90.0, 90.0).abs() < 1e-6);
assert!(particle_opacity(72.0, 90.0) < 1.0);
}
#[test]
fn the_flight_follows_the_closed_form() {
let config = ConfettiConfig::default();
let particle = Particle {
angle: 0.0,
speed: 1.0,
color: "#fff".to_owned(),
size: 1.0,
rot0: 0.0,
spin: 0.1,
drift: 0.0,
};
let pose = particle_pose(
&particle,
0.0,
(100.0, 200.0),
config.power,
config.gravity,
1.0,
);
assert!((pose.x - 100.0).abs() < 1e-6);
assert!((pose.y - 200.0).abs() < 1e-6);
let pose = particle_pose(
&particle,
2.0,
(100.0, 200.0),
config.power,
config.gravity,
1.0,
);
assert!((pose.x - (100.0 + 2.0 * config.power)).abs() < 1e-4);
assert!((pose.y - (200.0 + 0.5 * config.gravity * 4.0)).abs() < 1e-4);
assert!((pose.rotate_deg - 0.2_f32.to_degrees()).abs() < 1e-3);
}
#[test]
fn duration_covers_the_burst() {
let component = Confetti::default();
assert_eq!(component.duration(), 90);
assert_eq!(component.particles().len(), 140);
assert_eq!(component.meta().id, "confetti");
}
}