Skip to main content

agg_gui/
confetti.rs

1//! Confetti particle overlay — a reusable win-celebration burst effect.
2//!
3//! This is a self-contained simulation + paint helper, not a [`Widget`]: it has
4//! no layout, bounds, or event handling. It sits alongside [`crate::animation`]
5//! (the `Tween` interpolator) and [`crate::timestep`] (the fixed-step scheduler)
6//! as one of the animation/simulation primitives the framework offers to apps.
7//!
8//! A caller creates a [`ConfettiSystem`] with [`ConfettiSystem::burst`], then
9//! each frame:
10//!
11//! 1. advances the physics with [`ConfettiSystem::tick`], passing the wall-clock
12//!    delta since the previous frame in seconds, and
13//! 2. draws it with [`ConfettiSystem::paint`].
14//!
15//! `tick` returns `false` once every particle has expired, so the caller can
16//! drop the system and stop drawing. While particles are alive, `tick` calls
17//! [`crate::animation::request_draw`] so the host keeps producing frames without
18//! any user input — the same pattern [`crate::animation::Tween::tick`] uses.
19//!
20//! Time is taken entirely from the caller (`dt_seconds`); the module reads no
21//! clock and pulls in no OS randomness, so it is `wasm32`-clean. Randomness
22//! comes from a small deterministic splitmix64 PRNG seeded by the caller, which
23//! makes a burst fully reproducible from its `seed`.
24//!
25//! All coordinates follow the crate-wide **Y-up, first-quadrant** convention:
26//! positive Y is up, so gravity pulls particles toward smaller Y and the initial
27//! upward spread uses positive vertical velocity.
28
29use crate::animation::request_draw;
30use crate::color::Color;
31use crate::draw_ctx::DrawCtx;
32use crate::geometry::Rect;
33
34/// Downward gravitational acceleration in logical px/s².
35const GRAVITY: f64 = 520.0;
36
37/// Seconds of alpha fade at the tail end of a particle's life.
38const FADE_SECS: f64 = 0.8;
39
40/// Deterministic splitmix64 PRNG.
41///
42/// Kept private and tiny on purpose: the crate has no RNG dependency and the
43/// confetti burst only needs a reproducible stream of uniform values seeded by
44/// the caller. splitmix64 has excellent statistical quality for a single 64-bit
45/// state word and is trivially portable across targets (identical output on
46/// native and `wasm32`).
47struct SplitMix64 {
48    state: u64,
49}
50
51impl SplitMix64 {
52    fn new(seed: u64) -> Self {
53        Self { state: seed }
54    }
55
56    fn next_u64(&mut self) -> u64 {
57        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
58        let mut z = self.state;
59        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
60        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
61        z ^ (z >> 31)
62    }
63
64    /// Uniform `f64` in `[0.0, 1.0)`.
65    fn next_unit(&mut self) -> f64 {
66        // Top 53 bits → exact double in [0, 1).
67        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
68    }
69
70    /// Uniform `f64` in `[lo, hi)`.
71    fn range(&mut self, lo: f64, hi: f64) -> f64 {
72        lo + (hi - lo) * self.next_unit()
73    }
74}
75
76/// A single confetti flake: a small rotated rectangle drifting under gravity.
77struct Particle {
78    /// Position of the flake's center, Y-up.
79    x: f64,
80    y: f64,
81    /// Linear velocity in logical px/s.
82    vx: f64,
83    vy: f64,
84    /// Rectangle dimensions in logical px.
85    w: f64,
86    h: f64,
87    /// Orientation and spin.
88    rot: f64,
89    ang_vel: f64,
90    /// Sinusoidal horizontal flutter.
91    sway_amp: f64,
92    sway_freq: f64,
93    sway_phase: f64,
94    color: Color,
95    /// Elapsed and total life in seconds.
96    age: f64,
97    lifetime: f64,
98}
99
100impl Particle {
101    /// Fade factor in `[0, 1]`: `1.0` for most of the life, ramping to `0.0` at
102    /// `age == lifetime` so the flake vanishes smoothly instead of popping.
103    fn alpha_factor(&self) -> f32 {
104        let remaining = self.lifetime - self.age;
105        if remaining >= FADE_SECS {
106            1.0
107        } else {
108            (remaining / FADE_SECS).clamp(0.0, 1.0) as f32
109        }
110    }
111
112    /// Advance one step. `dt` is already sanitized (finite, non-negative).
113    fn integrate(&mut self, dt: f64) {
114        self.age += dt;
115        self.vy -= GRAVITY * dt;
116        let sway = self.sway_amp * (self.sway_freq * self.age + self.sway_phase).sin();
117        self.x += (self.vx + sway) * dt;
118        self.y += self.vy * dt;
119        self.rot += self.ang_vel * dt;
120    }
121}
122
123/// An owned collection of confetti particles that animate together.
124///
125/// Create with [`ConfettiSystem::burst`], drive per frame with
126/// [`ConfettiSystem::tick`] + [`ConfettiSystem::paint`]. The system owns all
127/// particle state; there is no shared or global mutable state.
128pub struct ConfettiSystem {
129    particles: Vec<Particle>,
130}
131
132impl ConfettiSystem {
133    /// Spawn a burst of `count` flakes across the top region of `bounds`.
134    ///
135    /// Flakes start near the top edge with a slight upward/outward velocity
136    /// spread, then fall under gravity while fluttering horizontally. Colors are
137    /// drawn from `palette` (cycled deterministically); an empty palette yields
138    /// white flakes. `seed` fully determines the burst — the same seed produces
139    /// the same particle stream on every platform.
140    ///
141    /// Coordinates are Y-up: the "top region" is the high-Y band of `bounds`.
142    pub fn burst(bounds: Rect, count: usize, palette: &[Color], seed: u64) -> Self {
143        let mut rng = SplitMix64::new(seed);
144        let mut particles = Vec::with_capacity(count);
145
146        // Spawn band: the top ~15% of the bounds height (at least a few px).
147        let band = (bounds.height * 0.15).max(4.0);
148        let top = bounds.top();
149
150        for i in 0..count {
151            let color = if palette.is_empty() {
152                Color::white()
153            } else {
154                palette[i % palette.len()]
155            };
156            let w = rng.range(4.0, 10.0);
157            let h = w * rng.range(0.45, 0.9);
158            particles.push(Particle {
159                x: rng.range(bounds.left(), bounds.right()),
160                y: rng.range(top - band, top),
161                // Upward (+Y) launch with an outward (±X) horizontal spread.
162                vx: rng.range(-140.0, 140.0),
163                vy: rng.range(40.0, 190.0),
164                w,
165                h,
166                rot: rng.range(0.0, std::f64::consts::TAU),
167                ang_vel: rng.range(-7.0, 7.0),
168                sway_amp: rng.range(18.0, 60.0),
169                sway_freq: rng.range(2.0, 6.0),
170                sway_phase: rng.range(0.0, std::f64::consts::TAU),
171                color,
172                age: 0.0,
173                lifetime: rng.range(2.5, 4.0),
174            });
175        }
176
177        Self { particles }
178    }
179
180    /// Number of live particles remaining.
181    pub fn particle_count(&self) -> usize {
182        self.particles.len()
183    }
184
185    /// Whether any particle is still alive.
186    pub fn is_active(&self) -> bool {
187        !self.particles.is_empty()
188    }
189
190    /// Advance the simulation by `dt_seconds` and return whether the burst is
191    /// still alive (any particle remaining).
192    ///
193    /// Expired particles are removed. A non-finite or negative `dt` is treated
194    /// as `0.0`, so a hostile or degenerate frame delta can never introduce NaN
195    /// into particle positions. While particles remain, this calls
196    /// [`request_draw`] so the host keeps drawing frames until the burst
197    /// finishes on its own.
198    pub fn tick(&mut self, dt_seconds: f64) -> bool {
199        let dt = if dt_seconds.is_finite() {
200            dt_seconds.max(0.0)
201        } else {
202            0.0
203        };
204
205        for p in &mut self.particles {
206            p.integrate(dt);
207        }
208        self.particles.retain(|p| p.age < p.lifetime);
209
210        let alive = !self.particles.is_empty();
211        if alive {
212            request_draw();
213        }
214        alive
215    }
216
217    /// Paint every live particle as a rotated, alpha-faded filled rectangle.
218    ///
219    /// Drawn in the current coordinate space (Y-up); callers position the burst
220    /// by setting up `ctx`'s transform before calling, or by passing bounds in
221    /// the same space they paint in. A few hundred particles paint comfortably
222    /// with the standard `DrawCtx` fill path.
223    pub fn paint(&self, ctx: &mut dyn DrawCtx) {
224        for p in &self.particles {
225            let a = p.alpha_factor();
226            if a <= 0.0 {
227                continue;
228            }
229            ctx.set_fill_color(p.color.with_alpha(p.color.a * a));
230            ctx.save();
231            ctx.translate(p.x, p.y);
232            ctx.rotate(p.rot);
233            ctx.begin_path();
234            ctx.rect(-p.w * 0.5, -p.h * 0.5, p.w, p.h);
235            ctx.fill();
236            ctx.restore();
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    fn palette() -> Vec<Color> {
246        vec![
247            Color::from_rgb8(0xE8, 0x3A, 0x3A),
248            Color::from_rgb8(0x3A, 0xC8, 0x5A),
249            Color::from_rgb8(0x3A, 0x6A, 0xE8),
250            Color::from_rgb8(0xF2, 0xC4, 0x2A),
251        ]
252    }
253
254    fn bounds() -> Rect {
255        Rect::new(0.0, 0.0, 800.0, 600.0)
256    }
257
258    /// Same seed → byte-identical first-frame particle state.
259    #[test]
260    fn deterministic_from_seed() {
261        let a = ConfettiSystem::burst(bounds(), 200, &palette(), 0xDEAD_BEEF);
262        let b = ConfettiSystem::burst(bounds(), 200, &palette(), 0xDEAD_BEEF);
263
264        assert_eq!(a.particle_count(), b.particle_count());
265        for (pa, pb) in a.particles.iter().zip(b.particles.iter()) {
266            assert_eq!(pa.x.to_bits(), pb.x.to_bits());
267            assert_eq!(pa.y.to_bits(), pb.y.to_bits());
268            assert_eq!(pa.vx.to_bits(), pb.vx.to_bits());
269            assert_eq!(pa.vy.to_bits(), pb.vy.to_bits());
270            assert_eq!(pa.rot.to_bits(), pb.rot.to_bits());
271            assert_eq!(pa.lifetime.to_bits(), pb.lifetime.to_bits());
272        }
273    }
274
275    /// Different seeds → different bursts (guards against a constant stream).
276    #[test]
277    fn distinct_seeds_differ() {
278        let a = ConfettiSystem::burst(bounds(), 64, &palette(), 1);
279        let b = ConfettiSystem::burst(bounds(), 64, &palette(), 2);
280        let any_diff = a
281            .particles
282            .iter()
283            .zip(b.particles.iter())
284            .any(|(pa, pb)| pa.x.to_bits() != pb.x.to_bits());
285        assert!(any_diff, "distinct seeds produced identical bursts");
286    }
287
288    /// Requested particle count is honored, and spawns land inside bounds.
289    #[test]
290    fn count_and_spawn_region() {
291        let b = bounds();
292        let sys = ConfettiSystem::burst(b, 300, &palette(), 7);
293        assert_eq!(sys.particle_count(), 300);
294
295        let band = (b.height * 0.15).max(4.0);
296        for p in &sys.particles {
297            assert!(
298                p.x >= b.left() && p.x <= b.right(),
299                "x {} out of bounds",
300                p.x
301            );
302            // Spawn band is the top slice of the bounds.
303            assert!(
304                p.y >= b.top() - band && p.y <= b.top(),
305                "y {} outside spawn band",
306                p.y
307            );
308            assert!(p.x.is_finite() && p.y.is_finite());
309        }
310    }
311
312    /// Empty palette falls back to white rather than panicking on modulo.
313    #[test]
314    fn empty_palette_is_white() {
315        let sys = ConfettiSystem::burst(bounds(), 10, &[], 3);
316        assert_eq!(sys.particle_count(), 10);
317        for p in &sys.particles {
318            assert_eq!(p.color, Color::white());
319        }
320    }
321
322    /// Ticking advances particles and eventually reports finished. Alpha
323    /// reaches 0 at end of life, and positions never go NaN across normal,
324    /// zero, and huge dt steps.
325    #[test]
326    fn ticks_to_completion_without_nan() {
327        let mut sys = ConfettiSystem::burst(bounds(), 128, &palette(), 42);
328        assert!(sys.is_active());
329
330        // Zero dt: still alive, no state corruption.
331        assert!(sys.tick(0.0));
332        for p in &sys.particles {
333            assert!(p.x.is_finite() && p.y.is_finite());
334        }
335
336        // Normal frames advance the sim without producing NaN.
337        for _ in 0..30 {
338            sys.tick(1.0 / 60.0);
339            for p in &sys.particles {
340                assert!(
341                    p.x.is_finite() && p.y.is_finite(),
342                    "NaN/inf during normal ticks"
343                );
344                assert!(p.alpha_factor() >= 0.0 && p.alpha_factor() <= 1.0);
345            }
346        }
347
348        // Non-finite / negative dt is treated as zero — no NaN leaks in.
349        assert!(sys.tick(f64::NAN));
350        assert!(sys.tick(-5.0));
351        for p in &sys.particles {
352            assert!(p.x.is_finite() && p.y.is_finite());
353        }
354
355        // A single huge step exhausts every lifetime → finished, no infinite life.
356        assert!(!sys.tick(100.0));
357        assert_eq!(sys.particle_count(), 0);
358        assert!(!sys.is_active());
359    }
360
361    /// Alpha fades to exactly 0 as a particle reaches the end of its life.
362    #[test]
363    fn alpha_reaches_zero_at_end_of_life() {
364        let mut sys = ConfettiSystem::burst(bounds(), 1, &palette(), 99);
365        let lifetime = sys.particles[0].lifetime;
366
367        // Early on, fully opaque.
368        assert_eq!(sys.particles[0].alpha_factor(), 1.0);
369
370        // Step to just shy of the end so the particle is still present but
371        // deep into its fade window.
372        sys.tick(lifetime - 0.001);
373        assert!(sys.is_active());
374        let a = sys.particles[0].alpha_factor();
375        assert!(
376            (0.0..0.01).contains(&a),
377            "expected near-zero alpha, got {a}"
378        );
379    }
380
381    /// `paint` executes without panicking on a small software context.
382    #[test]
383    fn paint_smoke() {
384        use crate::framebuffer::Framebuffer;
385        use crate::gfx_ctx::GfxCtx;
386
387        let mut sys = ConfettiSystem::burst(Rect::new(0.0, 0.0, 64.0, 48.0), 128, &palette(), 5);
388        sys.tick(0.1);
389
390        let mut fb = Framebuffer::new(64, 48);
391        let mut ctx = GfxCtx::new(&mut fb);
392        ctx.clear(Color::black());
393        sys.paint(&mut ctx);
394    }
395}