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}