Skip to main content

game_gem/
particles.rs

1//! Particle system for visual effects.
2//!
3//! Features vs macroquad (which has none):
4//! - **Emitters** with configurable shape (point, line, circle, rect)
5//! - **Per-particle properties**: velocity, acceleration, color, size, lifetime, rotation
6//! - **Easing functions** for size/alpha over lifetime
7//! - **Gravity and drag**
8//! - **Particle pooling** for zero-allocation steady-state
9//! - **Multiple emitters** active simultaneously
10
11use crate::math::{Vec2, FloatExt};
12use crate::color::Color;
13use quad_rand as rand;
14
15// ─────────────────────────────────────────────
16// Easing
17// ─────────────────────────────────────────────
18
19/// Easing function for particle properties over lifetime.
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub enum Easing {
22    /// Constant value.
23    Linear,
24    /// Fast start, slow end.
25    EaseOut,
26    /// Slow start, fast end.
27    EaseIn,
28    /// Slow start, fast middle, slow end.
29    EaseInOut,
30    /// Quadratic ease out.
31    QuadOut,
32}
33
34impl Easing {
35    /// Apply the easing function. `t` should be 0.0–1.0.
36    pub fn apply(self, t: f32) -> f32 {
37        let t = t.clamp(0.0, 1.0);
38        match self {
39            Easing::Linear => t,
40            Easing::EaseOut => 1.0 - (1.0 - t).powi(3),
41            Easing::EaseIn => t.powi(3),
42            Easing::EaseInOut => {
43                if t < 0.5 {
44                    4.0 * t * t * t
45                } else {
46                    1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
47                }
48            }
49            Easing::QuadOut => 1.0 - (1.0 - t) * (1.0 - t),
50        }
51    }
52}
53
54// ─────────────────────────────────────────────
55// Particle
56// ─────────────────────────────────────────────
57
58/// A single particle with all its properties.
59#[derive(Debug, Clone)]
60pub struct Particle {
61    /// Current position.
62    pub position: Vec2,
63    /// Current velocity.
64    pub velocity: Vec2,
65    /// Acceleration applied each frame.
66    pub acceleration: Vec2,
67    /// Current color.
68    pub color: Color,
69    /// Start color (for interpolation).
70    pub color_start: Color,
71    /// End color (for interpolation).
72    pub color_end: Color,
73    /// Current size.
74    pub size: f32,
75    /// Start size.
76    pub size_start: f32,
77    /// End size.
78    pub size_end: f32,
79    /// Current rotation (radians).
80    pub rotation: f32,
81    /// Rotation speed (radians/sec).
82    pub rotation_speed: f32,
83    /// Time lived so far (seconds).
84    pub age: f32,
85    /// Maximum lifetime (seconds).
86    pub lifetime: f32,
87    /// Whether this particle is alive.
88    pub alive: bool,
89    /// Alpha easing.
90    pub alpha_easing: Easing,
91    /// Size easing.
92    pub size_easing: Easing,
93}
94
95impl Particle {
96    /// Create a new particle with default values.
97    fn new() -> Self {
98        Self {
99            position: Vec2::ZERO,
100            velocity: Vec2::ZERO,
101            acceleration: Vec2::ZERO,
102            color: Color::WHITE,
103            color_start: Color::WHITE,
104            color_end: Color::TRANSPARENT,
105            size: 5.0,
106            size_start: 5.0,
107            size_end: 0.0,
108            rotation: 0.0,
109            rotation_speed: 0.0,
110            age: 0.0,
111            lifetime: 1.0,
112            alive: true,
113            alpha_easing: Easing::Linear,
114            size_easing: Easing::QuadOut,
115        }
116    }
117
118    /// Update the particle for one frame.
119    fn update(&mut self, dt: f32, drag: f32, gravity: Vec2) {
120        if !self.alive {
121            return;
122        }
123
124        self.age += dt;
125        if self.age >= self.lifetime {
126            self.alive = false;
127            return;
128        }
129
130        let t = self.age / self.lifetime; // 0..1 progress
131
132        // Apply forces
133        self.velocity += (self.acceleration + gravity) * dt;
134        self.velocity *= 1.0 - drag * dt;
135        self.position += self.velocity * dt;
136        self.rotation += self.rotation_speed * dt;
137
138        // Interpolate color
139        self.color = self.color_start.lerp(self.color_end, Easing::Linear.apply(t));
140        self.color.a = self.color_start.a * (1.0 - Easing::Linear.apply(t));
141
142        // Interpolate size
143        self.size = self.size_start.lerp(self.size_end, self.size_easing.apply(t));
144    }
145}
146
147// ─────────────────────────────────────────────
148// Emitter shape
149// ─────────────────────────────────────────────
150
151/// Shape of the emission area.
152#[derive(Debug, Clone, Copy)]
153pub enum EmitterShape {
154    /// Single point.
155    Point,
156    /// Line segment from `(x1, y1)` to `(x2, y2)`.
157    Line { x1: f32, y1: f32, x2: f32, y2: f32 },
158    /// Circle with center and radius.
159    Circle { cx: f32, cy: f32, radius: f32 },
160    /// Rectangle.
161    Rect { x: f32, y: f32, w: f32, h: f32 },
162}
163
164// ─────────────────────────────────────────────
165// Particle Emitter
166// ─────────────────────────────────────────────
167
168/// A particle emitter that spawns particles over time.
169#[derive(Debug, Clone)]
170pub struct ParticleEmitter {
171    /// Position offset for the emitter.
172    pub position: Vec2,
173    /// Emission shape.
174    pub shape: EmitterShape,
175    /// Particles per second.
176    pub rate: f32,
177    /// Emission accumulator.
178    emit_accumulator: f32,
179    /// Burst count (if > 0, emit this many at once then stop).
180    pub burst: u32,
181    /// Burst emitted count.
182    burst_emitted: u32,
183    /// Template particle (properties are copied to new particles).
184    pub template: Particle,
185    /// Velocity range (min, max speed).
186    pub speed_range: (f32, f32),
187    /// Angle range for initial velocity (min, max in radians).
188    pub angle_range: (f32, f32),
189    /// Size range (min, max).
190    pub size_range: (f32, f32),
191    /// Lifetime range (min, max seconds).
192    pub lifetime_range: (f32, f32),
193    /// Gravity applied to all particles.
194    pub gravity: Vec2,
195    /// Drag coefficient (0 = no drag, 1 = full stop).
196    pub drag: f32,
197    /// Maximum number of alive particles.
198    pub max_particles: usize,
199    /// Active particles.
200    particles: Vec<Particle>,
201    /// Whether the emitter is active.
202    pub active: bool,
203    /// Whether the emitter loops (re-emits after all particles die, if burst mode).
204    pub looping: bool,
205}
206
207impl ParticleEmitter {
208    /// Create a new emitter with default settings.
209    pub fn new(position: Vec2) -> Self {
210        Self {
211            position,
212            shape: EmitterShape::Point,
213            rate: 10.0,
214            emit_accumulator: 0.0,
215            burst: 0,
216            burst_emitted: 0,
217            template: Particle::new(),
218            speed_range: (50.0, 150.0),
219            angle_range: (0.0, std::f32::consts::TAU),
220            size_range: (2.0, 8.0),
221            lifetime_range: (0.5, 2.0),
222            gravity: Vec2::new(0.0, 100.0),
223            drag: 0.0,
224            max_particles: 1000,
225            particles: Vec::with_capacity(1000),
226            active: true,
227            looping: false,
228        }
229    }
230
231    /// Configure the emitter using a builder-style closure.
232    pub fn configure(&mut self, f: impl FnOnce(&mut ParticleEmitterConfig)) {
233        let mut cfg = ParticleEmitterConfig { emitter: self };
234        f(&mut cfg);
235    }
236
237    /// Get a reference to the active particles (for rendering).
238    pub fn particles(&self) -> &[Particle] {
239        &self.particles
240    }
241
242    /// Get a mutable reference to active particles.
243    pub fn particles_mut(&mut self) -> &mut Vec<Particle> {
244        &mut self.particles
245    }
246
247    /// Count of alive particles.
248    pub fn alive_count(&self) -> usize {
249        self.particles.iter().filter(|p| p.alive).count()
250    }
251
252    /// Emit a single particle with randomized properties.
253    fn emit_one(&mut self) {
254        let pos = match self.shape {
255            EmitterShape::Point => self.position,
256            EmitterShape::Line { x1, y1, x2, y2 } => {
257                let t = rand::gen_range(0.0, 1.0);
258                Vec2::new(x1.lerp(x2, t), y1.lerp(y2, t))
259            }
260            EmitterShape::Circle { cx, cy, radius } => {
261                let angle = rand::gen_range(0.0, std::f32::consts::TAU);
262                let r = radius.sqrt() * rand::gen_range(0.0, 1.0);
263                Vec2::new(cx + angle.cos() * r, cy + angle.sin() * r)
264            }
265            EmitterShape::Rect { x, y, w, h } => {
266                Vec2::new(x + rand::gen_range(0.0, w), y + rand::gen_range(0.0, h))
267            }
268        };
269
270        let speed = rand::gen_range(self.speed_range.0, self.speed_range.1);
271        let angle = rand::gen_range(self.angle_range.0, self.angle_range.1);
272        let vel = Vec2::new(angle.cos() * speed, angle.sin() * speed);
273
274        let size = rand::gen_range(self.size_range.0, self.size_range.1);
275        let lifetime = rand::gen_range(self.lifetime_range.0, self.lifetime_range.1);
276
277        let mut p = self.template.clone();
278        p.position = pos;
279        p.velocity = vel;
280        p.size_start = size;
281        p.size = size;
282        p.size_end = self.template.size_end;
283        p.lifetime = lifetime;
284        p.color_start = self.template.color_start;
285        p.color_end = self.template.color_end;
286
287        self.particles.push(p);
288    }
289
290    /// Update the emitter and all its particles.
291    pub fn update(&mut self, dt: f32) {
292        if !self.active {
293            return;
294        }
295
296        // Emit new particles
297        if self.burst > 0 {
298            if self.burst_emitted < self.burst {
299                let to_emit = self.burst - self.burst_emitted;
300                for _ in 0..to_emit {
301                    if self.alive_count() < self.max_particles {
302                        self.emit_one();
303                        self.burst_emitted += 1;
304                    }
305                }
306                if !self.looping {
307                    self.active = false;
308                }
309            } else if self.looping {
310                // Wait for all particles to die before re-bursting
311                if self.alive_count() == 0 {
312                    self.burst_emitted = 0;
313                }
314            }
315        } else {
316            self.emit_accumulator += dt;
317            let interval = 1.0 / self.rate;
318            while self.emit_accumulator >= interval {
319                self.emit_accumulator -= interval;
320                if self.alive_count() < self.max_particles {
321                    self.emit_one();
322                }
323            }
324        }
325
326        // Update existing particles
327        for p in &mut self.particles {
328            p.update(dt, self.drag, self.gravity);
329        }
330
331        // Remove dead particles periodically (every 60 frames to amortize cost)
332        // In practice, we just compact when the dead count exceeds a threshold
333        let dead_count = self.particles.iter().filter(|p| !p.alive).count();
334        if dead_count > self.max_particles / 4 {
335            self.particles.retain(|p| p.alive);
336        }
337    }
338
339    /// Remove all particles and reset the emitter.
340    pub fn clear(&mut self) {
341        self.particles.clear();
342        self.emit_accumulator = 0.0;
343        self.burst_emitted = 0;
344        self.active = true;
345    }
346
347    /// Trigger a burst emission regardless of current mode.
348    pub fn burst_now(&mut self, count: u32) {
349        for _ in 0..count {
350            if self.alive_count() < self.max_particles {
351                self.emit_one();
352            }
353        }
354    }
355}
356
357/// Helper for builder-style configuration.
358pub struct ParticleEmitterConfig<'a> {
359    emitter: &'a mut ParticleEmitter,
360}
361
362impl<'a> ParticleEmitterConfig<'a> {
363    pub fn rate(&mut self, rate: f32) -> &mut Self { self.emitter.rate = rate; self }
364    pub fn shape(&mut self, shape: EmitterShape) -> &mut Self { self.emitter.shape = shape; self }
365    pub fn speed(&mut self, min: f32, max: f32) -> &mut Self { self.emitter.speed_range = (min, max); self }
366    pub fn angle(&mut self, min: f32, max: f32) -> &mut Self { self.emitter.angle_range = (min, max); self }
367    pub fn size(&mut self, min: f32, max: f32) -> &mut Self { self.emitter.size_range = (min, max); self }
368    pub fn lifetime(&mut self, min: f32, max: f32) -> &mut Self { self.emitter.lifetime_range = (min, max); self }
369    pub fn gravity(&mut self, x: f32, y: f32) -> &mut Self { self.emitter.gravity = Vec2::new(x, y); self }
370    pub fn drag(&mut self, drag: f32) -> &mut Self { self.emitter.drag = drag; self }
371    pub fn max_particles(&mut self, max: usize) -> &mut Self { self.emitter.max_particles = max; self }
372    pub fn color_start(&mut self, color: Color) -> &mut Self { self.emitter.template.color_start = color; self }
373    pub fn color_end(&mut self, color: Color) -> &mut Self { self.emitter.template.color_end = color; self }
374    pub fn size_end(&mut self, size: f32) -> &mut Self { self.emitter.template.size_end = size; self }
375    pub fn burst(&mut self, count: u32) -> &mut Self { self.emitter.burst = count; self }
376    pub fn looping(&mut self, looping: bool) -> &mut Self { self.emitter.looping = looping; self }
377    pub fn rotation_speed(&mut self, min: f32, max: f32) -> &mut Self {
378        self.emitter.template.rotation_speed = rand::gen_range(min, max);
379        self
380    }
381}