use crate::animation::request_draw;
use crate::color::Color;
use crate::draw_ctx::DrawCtx;
use crate::geometry::Rect;
const GRAVITY: f64 = 520.0;
const FADE_SECS: f64 = 0.8;
struct SplitMix64 {
state: u64,
}
impl SplitMix64 {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn next_unit(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
fn range(&mut self, lo: f64, hi: f64) -> f64 {
lo + (hi - lo) * self.next_unit()
}
}
struct Particle {
x: f64,
y: f64,
vx: f64,
vy: f64,
w: f64,
h: f64,
rot: f64,
ang_vel: f64,
sway_amp: f64,
sway_freq: f64,
sway_phase: f64,
color: Color,
age: f64,
lifetime: f64,
}
impl Particle {
fn alpha_factor(&self) -> f32 {
let remaining = self.lifetime - self.age;
if remaining >= FADE_SECS {
1.0
} else {
(remaining / FADE_SECS).clamp(0.0, 1.0) as f32
}
}
fn integrate(&mut self, dt: f64) {
self.age += dt;
self.vy -= GRAVITY * dt;
let sway = self.sway_amp * (self.sway_freq * self.age + self.sway_phase).sin();
self.x += (self.vx + sway) * dt;
self.y += self.vy * dt;
self.rot += self.ang_vel * dt;
}
}
pub struct ConfettiSystem {
particles: Vec<Particle>,
}
impl ConfettiSystem {
pub fn burst(bounds: Rect, count: usize, palette: &[Color], seed: u64) -> Self {
let mut rng = SplitMix64::new(seed);
let mut particles = Vec::with_capacity(count);
let band = (bounds.height * 0.15).max(4.0);
let top = bounds.top();
for i in 0..count {
let color = if palette.is_empty() {
Color::white()
} else {
palette[i % palette.len()]
};
let w = rng.range(4.0, 10.0);
let h = w * rng.range(0.45, 0.9);
particles.push(Particle {
x: rng.range(bounds.left(), bounds.right()),
y: rng.range(top - band, top),
vx: rng.range(-140.0, 140.0),
vy: rng.range(40.0, 190.0),
w,
h,
rot: rng.range(0.0, std::f64::consts::TAU),
ang_vel: rng.range(-7.0, 7.0),
sway_amp: rng.range(18.0, 60.0),
sway_freq: rng.range(2.0, 6.0),
sway_phase: rng.range(0.0, std::f64::consts::TAU),
color,
age: 0.0,
lifetime: rng.range(2.5, 4.0),
});
}
Self { particles }
}
pub fn particle_count(&self) -> usize {
self.particles.len()
}
pub fn is_active(&self) -> bool {
!self.particles.is_empty()
}
pub fn tick(&mut self, dt_seconds: f64) -> bool {
let dt = if dt_seconds.is_finite() {
dt_seconds.max(0.0)
} else {
0.0
};
for p in &mut self.particles {
p.integrate(dt);
}
self.particles.retain(|p| p.age < p.lifetime);
let alive = !self.particles.is_empty();
if alive {
request_draw();
}
alive
}
pub fn paint(&self, ctx: &mut dyn DrawCtx) {
for p in &self.particles {
let a = p.alpha_factor();
if a <= 0.0 {
continue;
}
ctx.set_fill_color(p.color.with_alpha(p.color.a * a));
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.begin_path();
ctx.rect(-p.w * 0.5, -p.h * 0.5, p.w, p.h);
ctx.fill();
ctx.restore();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn palette() -> Vec<Color> {
vec![
Color::from_rgb8(0xE8, 0x3A, 0x3A),
Color::from_rgb8(0x3A, 0xC8, 0x5A),
Color::from_rgb8(0x3A, 0x6A, 0xE8),
Color::from_rgb8(0xF2, 0xC4, 0x2A),
]
}
fn bounds() -> Rect {
Rect::new(0.0, 0.0, 800.0, 600.0)
}
#[test]
fn deterministic_from_seed() {
let a = ConfettiSystem::burst(bounds(), 200, &palette(), 0xDEAD_BEEF);
let b = ConfettiSystem::burst(bounds(), 200, &palette(), 0xDEAD_BEEF);
assert_eq!(a.particle_count(), b.particle_count());
for (pa, pb) in a.particles.iter().zip(b.particles.iter()) {
assert_eq!(pa.x.to_bits(), pb.x.to_bits());
assert_eq!(pa.y.to_bits(), pb.y.to_bits());
assert_eq!(pa.vx.to_bits(), pb.vx.to_bits());
assert_eq!(pa.vy.to_bits(), pb.vy.to_bits());
assert_eq!(pa.rot.to_bits(), pb.rot.to_bits());
assert_eq!(pa.lifetime.to_bits(), pb.lifetime.to_bits());
}
}
#[test]
fn distinct_seeds_differ() {
let a = ConfettiSystem::burst(bounds(), 64, &palette(), 1);
let b = ConfettiSystem::burst(bounds(), 64, &palette(), 2);
let any_diff = a
.particles
.iter()
.zip(b.particles.iter())
.any(|(pa, pb)| pa.x.to_bits() != pb.x.to_bits());
assert!(any_diff, "distinct seeds produced identical bursts");
}
#[test]
fn count_and_spawn_region() {
let b = bounds();
let sys = ConfettiSystem::burst(b, 300, &palette(), 7);
assert_eq!(sys.particle_count(), 300);
let band = (b.height * 0.15).max(4.0);
for p in &sys.particles {
assert!(
p.x >= b.left() && p.x <= b.right(),
"x {} out of bounds",
p.x
);
assert!(
p.y >= b.top() - band && p.y <= b.top(),
"y {} outside spawn band",
p.y
);
assert!(p.x.is_finite() && p.y.is_finite());
}
}
#[test]
fn empty_palette_is_white() {
let sys = ConfettiSystem::burst(bounds(), 10, &[], 3);
assert_eq!(sys.particle_count(), 10);
for p in &sys.particles {
assert_eq!(p.color, Color::white());
}
}
#[test]
fn ticks_to_completion_without_nan() {
let mut sys = ConfettiSystem::burst(bounds(), 128, &palette(), 42);
assert!(sys.is_active());
assert!(sys.tick(0.0));
for p in &sys.particles {
assert!(p.x.is_finite() && p.y.is_finite());
}
for _ in 0..30 {
sys.tick(1.0 / 60.0);
for p in &sys.particles {
assert!(
p.x.is_finite() && p.y.is_finite(),
"NaN/inf during normal ticks"
);
assert!(p.alpha_factor() >= 0.0 && p.alpha_factor() <= 1.0);
}
}
assert!(sys.tick(f64::NAN));
assert!(sys.tick(-5.0));
for p in &sys.particles {
assert!(p.x.is_finite() && p.y.is_finite());
}
assert!(!sys.tick(100.0));
assert_eq!(sys.particle_count(), 0);
assert!(!sys.is_active());
}
#[test]
fn alpha_reaches_zero_at_end_of_life() {
let mut sys = ConfettiSystem::burst(bounds(), 1, &palette(), 99);
let lifetime = sys.particles[0].lifetime;
assert_eq!(sys.particles[0].alpha_factor(), 1.0);
sys.tick(lifetime - 0.001);
assert!(sys.is_active());
let a = sys.particles[0].alpha_factor();
assert!(
(0.0..0.01).contains(&a),
"expected near-zero alpha, got {a}"
);
}
#[test]
fn paint_smoke() {
use crate::framebuffer::Framebuffer;
use crate::gfx_ctx::GfxCtx;
let mut sys = ConfettiSystem::burst(Rect::new(0.0, 0.0, 64.0, 48.0), 128, &palette(), 5);
sys.tick(0.1);
let mut fb = Framebuffer::new(64, 48);
let mut ctx = GfxCtx::new(&mut fb);
ctx.clear(Color::black());
sys.paint(&mut ctx);
}
}