Skip to main content

jugar_web/
juice.rs

1//! Game juice and visual feedback effects.
2//!
3//! This module implements "game feel" enhancements based on Steve Swink's framework:
4//! - Screen shake on goals and impacts
5//! - Ball trail effects
6//! - Hit confirmation feedback (flash effects)
7//! - Score popup animations
8//!
9//! Reference: Swink, S. (2008). "Game Feel: A Game Designer's Guide to Virtual Sensation"
10
11// const fn with mutable references is not yet stable; mul_add less readable here
12#![allow(clippy::missing_const_for_fn, clippy::suboptimal_flops)]
13
14use serde::{Deserialize, Serialize};
15
16/// Screen shake effect state.
17#[derive(Debug, Clone, Default)]
18pub struct ScreenShake {
19    /// Current shake intensity (decays over time)
20    intensity: f32,
21    /// Shake duration remaining in seconds
22    duration: f32,
23    /// Current shake offset X
24    offset_x: f32,
25    /// Current shake offset Y
26    offset_y: f32,
27    /// Random seed for deterministic shake pattern
28    seed: u64,
29}
30
31impl ScreenShake {
32    /// Creates a new screen shake controller.
33    #[must_use]
34    pub const fn new() -> Self {
35        Self {
36            intensity: 0.0,
37            duration: 0.0,
38            offset_x: 0.0,
39            offset_y: 0.0,
40            seed: 12345,
41        }
42    }
43
44    /// Triggers a screen shake effect.
45    ///
46    /// # Arguments
47    ///
48    /// * `intensity` - Maximum shake offset in pixels (e.g., 10.0 for goal, 3.0 for hit)
49    /// * `duration` - How long the shake lasts in seconds
50    pub fn trigger(&mut self, intensity: f32, duration: f32) {
51        // Only override if new shake is stronger
52        if intensity > self.intensity {
53            self.intensity = intensity;
54            self.duration = duration;
55        }
56    }
57
58    /// Updates the shake effect and returns current offset.
59    ///
60    /// # Arguments
61    ///
62    /// * `dt` - Delta time in seconds
63    ///
64    /// # Returns
65    ///
66    /// Tuple of (offset_x, offset_y) to apply to camera/rendering
67    pub fn update(&mut self, dt: f32) -> (f32, f32) {
68        if self.duration <= 0.0 {
69            self.offset_x = 0.0;
70            self.offset_y = 0.0;
71            return (0.0, 0.0);
72        }
73
74        self.duration -= dt;
75
76        // Decay intensity over time (exponential decay)
77        let decay = (self.duration * 10.0).min(1.0);
78        let current_intensity = self.intensity * decay;
79
80        // Generate pseudo-random shake using simple xorshift
81        self.seed ^= self.seed << 13;
82        self.seed ^= self.seed >> 7;
83        self.seed ^= self.seed << 17;
84
85        let rand_x = (self.seed as f32 / u64::MAX as f32) * 2.0 - 1.0;
86
87        self.seed ^= self.seed << 13;
88        self.seed ^= self.seed >> 7;
89        self.seed ^= self.seed << 17;
90
91        let rand_y = (self.seed as f32 / u64::MAX as f32) * 2.0 - 1.0;
92
93        self.offset_x = rand_x * current_intensity;
94        self.offset_y = rand_y * current_intensity;
95
96        (self.offset_x, self.offset_y)
97    }
98
99    /// Returns true if shake is currently active.
100    #[must_use]
101    pub fn is_active(&self) -> bool {
102        self.duration > 0.0
103    }
104
105    /// Returns current shake offsets.
106    #[must_use]
107    pub const fn offset(&self) -> (f32, f32) {
108        (self.offset_x, self.offset_y)
109    }
110
111    /// Resets the shake state.
112    pub fn reset(&mut self) {
113        self.intensity = 0.0;
114        self.duration = 0.0;
115        self.offset_x = 0.0;
116        self.offset_y = 0.0;
117    }
118}
119
120/// A single point in a ball trail.
121#[derive(Debug, Clone, Copy, Default)]
122pub struct TrailPoint {
123    /// X position
124    pub x: f32,
125    /// Y position
126    pub y: f32,
127    /// Age in seconds (0 = newest)
128    pub age: f32,
129    /// Whether this point is active
130    pub active: bool,
131}
132
133/// Ball trail effect for motion visualization.
134#[derive(Debug, Clone)]
135pub struct BallTrail {
136    /// Trail points (ring buffer)
137    points: Vec<TrailPoint>,
138    /// Current write index
139    write_index: usize,
140    /// Time since last point was added
141    time_since_last: f32,
142    /// Interval between trail points
143    interval: f32,
144    /// Maximum age before point fades
145    max_age: f32,
146}
147
148impl Default for BallTrail {
149    fn default() -> Self {
150        Self::new(10, 0.016, 0.15)
151    }
152}
153
154impl BallTrail {
155    /// Creates a new ball trail.
156    ///
157    /// # Arguments
158    ///
159    /// * `max_points` - Maximum number of trail points
160    /// * `interval` - Time between adding new points (seconds)
161    /// * `max_age` - How long points last before fading (seconds)
162    #[must_use]
163    pub fn new(max_points: usize, interval: f32, max_age: f32) -> Self {
164        Self {
165            points: vec![TrailPoint::default(); max_points],
166            write_index: 0,
167            time_since_last: 0.0,
168            interval,
169            max_age,
170        }
171    }
172
173    /// Updates the trail with the current ball position.
174    ///
175    /// # Arguments
176    ///
177    /// * `x` - Ball X position
178    /// * `y` - Ball Y position
179    /// * `dt` - Delta time in seconds
180    pub fn update(&mut self, x: f32, y: f32, dt: f32) {
181        // Age existing points
182        for point in &mut self.points {
183            if point.active {
184                point.age += dt;
185                if point.age > self.max_age {
186                    point.active = false;
187                }
188            }
189        }
190
191        // Add new point at interval
192        self.time_since_last += dt;
193        if self.time_since_last >= self.interval {
194            self.time_since_last = 0.0;
195
196            self.points[self.write_index] = TrailPoint {
197                x,
198                y,
199                age: 0.0,
200                active: true,
201            };
202
203            self.write_index = (self.write_index + 1) % self.points.len();
204        }
205    }
206
207    /// Returns active trail points for rendering.
208    ///
209    /// Points are returned with alpha values based on age (1.0 = new, 0.0 = old).
210    #[must_use]
211    pub fn get_points(&self) -> Vec<(f32, f32, f32)> {
212        self.points
213            .iter()
214            .filter(|p| p.active)
215            .map(|p| {
216                let alpha = 1.0 - (p.age / self.max_age).min(1.0);
217                (p.x, p.y, alpha)
218            })
219            .collect()
220    }
221
222    /// Clears all trail points.
223    pub fn clear(&mut self) {
224        for point in &mut self.points {
225            point.active = false;
226        }
227        self.time_since_last = 0.0;
228    }
229
230    /// Returns the number of active points.
231    #[must_use]
232    pub fn active_count(&self) -> usize {
233        self.points.iter().filter(|p| p.active).count()
234    }
235}
236
237/// Hit flash effect for paddle collision feedback.
238#[derive(Debug, Clone, Default)]
239pub struct HitFlash {
240    /// Flash duration remaining
241    duration: f32,
242    /// Flash intensity (1.0 = full white)
243    intensity: f32,
244    /// Which paddle flashed (true = right, false = left)
245    right_paddle: bool,
246}
247
248impl HitFlash {
249    /// Creates a new hit flash controller.
250    #[must_use]
251    pub const fn new() -> Self {
252        Self {
253            duration: 0.0,
254            intensity: 0.0,
255            right_paddle: false,
256        }
257    }
258
259    /// Triggers a hit flash on a paddle.
260    ///
261    /// # Arguments
262    ///
263    /// * `right_paddle` - True for right paddle, false for left
264    /// * `intensity` - Flash brightness (0.0-1.0)
265    /// * `duration` - How long the flash lasts
266    pub fn trigger(&mut self, right_paddle: bool, intensity: f32, duration: f32) {
267        self.right_paddle = right_paddle;
268        self.intensity = intensity;
269        self.duration = duration;
270    }
271
272    /// Updates the flash effect.
273    ///
274    /// # Arguments
275    ///
276    /// * `dt` - Delta time in seconds
277    ///
278    /// # Returns
279    ///
280    /// Current flash state: (is_active, is_right_paddle, intensity)
281    pub fn update(&mut self, dt: f32) -> (bool, bool, f32) {
282        if self.duration <= 0.0 {
283            return (false, false, 0.0);
284        }
285
286        self.duration -= dt;
287
288        // Linear decay
289        let current_intensity = self.intensity * (self.duration * 20.0).min(1.0);
290
291        (true, self.right_paddle, current_intensity)
292    }
293
294    /// Returns true if flash is currently active.
295    #[must_use]
296    pub fn is_active(&self) -> bool {
297        self.duration > 0.0
298    }
299
300    /// Resets the flash state.
301    pub fn reset(&mut self) {
302        self.duration = 0.0;
303        self.intensity = 0.0;
304    }
305
306    /// Returns flash state for rendering.
307    ///
308    /// # Returns
309    ///
310    /// Tuple of (left_flash_active, right_flash_active, intensity)
311    #[must_use]
312    pub fn flash_state(&self) -> (bool, bool, f32) {
313        if self.duration <= 0.0 {
314            return (false, false, 0.0);
315        }
316        let current_intensity = self.intensity * (self.duration * 20.0).min(1.0);
317        if self.right_paddle {
318            (false, true, current_intensity)
319        } else {
320            (true, false, current_intensity)
321        }
322    }
323}
324
325/// A single particle in the particle system.
326#[derive(Debug, Clone, Copy, Default)]
327pub struct Particle {
328    /// X position
329    pub x: f32,
330    /// Y position
331    pub y: f32,
332    /// X velocity
333    pub vx: f32,
334    /// Y velocity
335    pub vy: f32,
336    /// Lifetime remaining in seconds
337    pub lifetime: f32,
338    /// Initial lifetime (for alpha calculation)
339    pub initial_lifetime: f32,
340    /// Particle size (radius)
341    pub size: f32,
342    /// Color RGB (packed as u32: 0xRRGGBB)
343    pub color: u32,
344    /// Whether this particle is active
345    pub active: bool,
346}
347
348impl Particle {
349    /// Creates a new particle.
350    #[must_use]
351    pub const fn new(
352        x: f32,
353        y: f32,
354        vx: f32,
355        vy: f32,
356        lifetime: f32,
357        size: f32,
358        color: u32,
359    ) -> Self {
360        Self {
361            x,
362            y,
363            vx,
364            vy,
365            lifetime,
366            initial_lifetime: lifetime,
367            size,
368            color,
369            active: true,
370        }
371    }
372
373    /// Updates the particle position and lifetime.
374    ///
375    /// # Returns
376    ///
377    /// True if particle is still alive
378    pub fn update(&mut self, dt: f32) -> bool {
379        if !self.active {
380            return false;
381        }
382
383        self.lifetime -= dt;
384        if self.lifetime <= 0.0 {
385            self.active = false;
386            return false;
387        }
388
389        // Update position
390        self.x += self.vx * dt;
391        self.y += self.vy * dt;
392
393        // Apply gravity (subtle downward pull)
394        self.vy += 200.0 * dt;
395
396        // Shrink over time
397        let life_ratio = self.lifetime / self.initial_lifetime;
398        self.size *= 0.99 + 0.01 * life_ratio;
399
400        true
401    }
402
403    /// Returns the current alpha value based on remaining lifetime.
404    #[must_use]
405    pub fn alpha(&self) -> f32 {
406        if self.initial_lifetime <= 0.0 {
407            return 0.0;
408        }
409        (self.lifetime / self.initial_lifetime).clamp(0.0, 1.0)
410    }
411
412    /// Returns the RGB color components.
413    #[must_use]
414    pub const fn rgb(&self) -> (f32, f32, f32) {
415        let r = ((self.color >> 16) & 0xFF) as f32 / 255.0;
416        let g = ((self.color >> 8) & 0xFF) as f32 / 255.0;
417        let b = (self.color & 0xFF) as f32 / 255.0;
418        (r, g, b)
419    }
420}
421
422/// Particle system for visual effects.
423#[derive(Debug, Clone)]
424pub struct ParticleSystem {
425    /// Pool of particles (pre-allocated)
426    particles: Vec<Particle>,
427    /// Next particle index to write
428    write_index: usize,
429    /// Random seed for variation
430    seed: u64,
431}
432
433impl Default for ParticleSystem {
434    fn default() -> Self {
435        Self::new(200) // Default pool size
436    }
437}
438
439impl ParticleSystem {
440    /// Creates a new particle system with the given pool size.
441    #[must_use]
442    pub fn new(pool_size: usize) -> Self {
443        Self {
444            particles: vec![Particle::default(); pool_size],
445            write_index: 0,
446            seed: 42,
447        }
448    }
449
450    /// Generates a pseudo-random f32 in [0, 1).
451    fn random(&mut self) -> f32 {
452        self.seed ^= self.seed << 13;
453        self.seed ^= self.seed >> 7;
454        self.seed ^= self.seed << 17;
455        (self.seed as f32) / (u64::MAX as f32)
456    }
457
458    /// Generates a pseudo-random f32 in [-1, 1).
459    fn random_signed(&mut self) -> f32 {
460        self.random() * 2.0 - 1.0
461    }
462
463    /// Spawns particles at the given position.
464    ///
465    /// # Arguments
466    ///
467    /// * `x` - X position
468    /// * `y` - Y position
469    /// * `count` - Number of particles to spawn
470    /// * `speed` - Base speed of particles
471    /// * `lifetime` - Base lifetime in seconds
472    /// * `size` - Base size (radius)
473    /// * `color` - Color as 0xRRGGBB
474    #[allow(clippy::too_many_arguments)]
475    pub fn spawn(
476        &mut self,
477        x: f32,
478        y: f32,
479        count: usize,
480        speed: f32,
481        lifetime: f32,
482        size: f32,
483        color: u32,
484    ) {
485        for _ in 0..count {
486            // Random direction
487            let angle = self.random() * core::f32::consts::TAU;
488            let speed_var = speed * (0.5 + self.random() * 0.5);
489            let vx = angle.cos() * speed_var;
490            let vy = angle.sin() * speed_var;
491
492            // Random lifetime and size variation
493            let life_var = lifetime * (0.7 + self.random() * 0.3);
494            let size_var = size * (0.5 + self.random() * 0.5);
495
496            self.particles[self.write_index] =
497                Particle::new(x, y, vx, vy, life_var, size_var, color);
498            self.write_index = (self.write_index + 1) % self.particles.len();
499        }
500    }
501
502    /// Spawns particles in a directional burst.
503    ///
504    /// # Arguments
505    ///
506    /// * `x` - X position
507    /// * `y` - Y position
508    /// * `direction_x` - Direction X component
509    /// * `direction_y` - Direction Y component
510    /// * `spread` - Angular spread in radians
511    /// * `count` - Number of particles
512    /// * `speed` - Base speed
513    /// * `lifetime` - Base lifetime
514    /// * `size` - Base size
515    /// * `color` - Color as 0xRRGGBB
516    #[allow(clippy::too_many_arguments)]
517    pub fn spawn_directional(
518        &mut self,
519        x: f32,
520        y: f32,
521        direction_x: f32,
522        direction_y: f32,
523        spread: f32,
524        count: usize,
525        speed: f32,
526        lifetime: f32,
527        size: f32,
528        color: u32,
529    ) {
530        let base_angle = direction_y.atan2(direction_x);
531
532        for _ in 0..count {
533            // Random angle within spread
534            let angle = base_angle + self.random_signed() * spread;
535            let speed_var = speed * (0.5 + self.random() * 0.5);
536            let vx = angle.cos() * speed_var;
537            let vy = angle.sin() * speed_var;
538
539            // Random lifetime and size variation
540            let life_var = lifetime * (0.7 + self.random() * 0.3);
541            let size_var = size * (0.5 + self.random() * 0.5);
542
543            self.particles[self.write_index] =
544                Particle::new(x, y, vx, vy, life_var, size_var, color);
545            self.write_index = (self.write_index + 1) % self.particles.len();
546        }
547    }
548
549    /// Updates all particles.
550    pub fn update(&mut self, dt: f32) {
551        for particle in &mut self.particles {
552            let _ = particle.update(dt);
553        }
554    }
555
556    /// Returns all active particles for rendering.
557    #[must_use]
558    pub fn get_active(&self) -> Vec<&Particle> {
559        self.particles.iter().filter(|p| p.active).collect()
560    }
561
562    /// Returns the number of active particles.
563    #[must_use]
564    pub fn active_count(&self) -> usize {
565        self.particles.iter().filter(|p| p.active).count()
566    }
567
568    /// Clears all particles.
569    pub fn clear(&mut self) {
570        for particle in &mut self.particles {
571            particle.active = false;
572        }
573    }
574}
575
576/// Score popup animation.
577#[derive(Debug, Clone, Serialize, Deserialize)]
578pub struct ScorePopup {
579    /// X position
580    pub x: f32,
581    /// Y position
582    pub y: f32,
583    /// Text to display
584    pub text: String,
585    /// Time remaining
586    pub duration: f32,
587    /// Initial Y position (for float animation)
588    pub start_y: f32,
589}
590
591impl ScorePopup {
592    /// Creates a new score popup.
593    #[must_use]
594    pub fn new(x: f32, y: f32, text: &str, duration: f32) -> Self {
595        Self {
596            x,
597            y,
598            text: text.to_string(),
599            duration,
600            start_y: y,
601        }
602    }
603
604    /// Updates the popup animation.
605    ///
606    /// # Returns
607    ///
608    /// True if popup is still active
609    pub fn update(&mut self, dt: f32) -> bool {
610        if self.duration <= 0.0 {
611            return false;
612        }
613
614        self.duration -= dt;
615
616        // Float upward
617        self.y -= 50.0 * dt;
618
619        true
620    }
621
622    /// Returns the current alpha (fades out over time).
623    #[must_use]
624    pub fn alpha(&self) -> f32 {
625        (self.duration * 2.0).min(1.0)
626    }
627}
628
629/// Combined juice effects manager.
630#[derive(Debug, Clone)]
631pub struct JuiceEffects {
632    /// Screen shake effect
633    pub screen_shake: ScreenShake,
634    /// Ball trail effect
635    pub ball_trail: BallTrail,
636    /// Hit flash effect
637    pub hit_flash: HitFlash,
638    /// Active score popups
639    pub score_popups: Vec<ScorePopup>,
640    /// Particle system for collision effects
641    pub particles: ParticleSystem,
642}
643
644impl Default for JuiceEffects {
645    fn default() -> Self {
646        Self::new()
647    }
648}
649
650impl JuiceEffects {
651    /// Creates a new juice effects manager.
652    #[must_use]
653    pub fn new() -> Self {
654        Self {
655            screen_shake: ScreenShake::new(),
656            ball_trail: BallTrail::default(),
657            hit_flash: HitFlash::new(),
658            score_popups: Vec::new(),
659            particles: ParticleSystem::default(),
660        }
661    }
662
663    /// Updates all juice effects.
664    ///
665    /// # Arguments
666    ///
667    /// * `ball_x` - Current ball X position
668    /// * `ball_y` - Current ball Y position
669    /// * `dt` - Delta time in seconds
670    pub fn update(&mut self, ball_x: f32, ball_y: f32, dt: f32) {
671        let _ = self.screen_shake.update(dt);
672        self.ball_trail.update(ball_x, ball_y, dt);
673        let _ = self.hit_flash.update(dt);
674        self.particles.update(dt);
675
676        // Update and remove expired popups
677        self.score_popups.retain_mut(|popup| popup.update(dt));
678    }
679
680    /// Triggers effects for a goal scored.
681    ///
682    /// # Arguments
683    ///
684    /// * `scorer_x` - X position where score occurred
685    /// * `scorer_y` - Y position where score occurred
686    /// * `points_text` - Text to show in popup (e.g., "+1")
687    pub fn on_goal(&mut self, scorer_x: f32, scorer_y: f32, points_text: &str) {
688        // Strong screen shake for goals
689        self.screen_shake.trigger(8.0, 0.3);
690
691        // Score popup
692        self.score_popups
693            .push(ScorePopup::new(scorer_x, scorer_y, points_text, 1.0));
694
695        // Celebratory particle burst (gold/yellow)
696        self.particles
697            .spawn(scorer_x, scorer_y, 30, 200.0, 0.8, 4.0, 0x00FF_D700);
698
699        // Clear trail on goal (ball resets)
700        self.ball_trail.clear();
701    }
702
703    /// Triggers effects for a paddle hit.
704    ///
705    /// # Arguments
706    ///
707    /// * `right_paddle` - True if right paddle was hit
708    pub fn on_paddle_hit(&mut self, right_paddle: bool) {
709        // Light screen shake for hits
710        self.screen_shake.trigger(3.0, 0.1);
711
712        // Flash the paddle
713        self.hit_flash.trigger(right_paddle, 0.8, 0.1);
714    }
715
716    /// Triggers effects for a paddle hit with ball position.
717    ///
718    /// # Arguments
719    ///
720    /// * `ball_x` - Ball X position
721    /// * `ball_y` - Ball Y position
722    /// * `right_paddle` - True if right paddle was hit
723    pub fn on_paddle_hit_at(&mut self, ball_x: f32, ball_y: f32, right_paddle: bool) {
724        // Light screen shake for hits
725        self.screen_shake.trigger(3.0, 0.1);
726
727        // Flash the paddle
728        self.hit_flash.trigger(right_paddle, 0.8, 0.1);
729
730        // Directional particle burst (white/cyan sparks)
731        // Direction away from paddle
732        let direction_x = if right_paddle { -1.0 } else { 1.0 };
733        self.particles.spawn_directional(
734            ball_x,
735            ball_y,
736            direction_x,
737            0.0,
738            0.5, // ~30 degree spread
739            12,
740            150.0,
741            0.4,
742            3.0,
743            0x0000_FFFF, // Cyan
744        );
745    }
746
747    /// Triggers effects for a wall bounce.
748    pub fn on_wall_bounce(&mut self) {
749        // Very light shake for wall bounces
750        self.screen_shake.trigger(1.5, 0.05);
751    }
752
753    /// Resets all effects.
754    pub fn reset(&mut self) {
755        self.screen_shake.reset();
756        self.ball_trail.clear();
757        self.hit_flash.reset();
758        self.score_popups.clear();
759        self.particles.clear();
760    }
761}
762
763#[cfg(test)]
764#[allow(clippy::float_cmp, clippy::unreadable_literal)]
765mod tests {
766    use super::*;
767
768    // =========================================================================
769    // ScreenShake Tests
770    // =========================================================================
771
772    #[test]
773    fn test_screen_shake_new() {
774        let shake = ScreenShake::new();
775        assert_eq!(shake.intensity, 0.0);
776        assert_eq!(shake.duration, 0.0);
777        assert!(!shake.is_active());
778    }
779
780    #[test]
781    fn test_screen_shake_trigger() {
782        let mut shake = ScreenShake::new();
783        shake.trigger(10.0, 0.5);
784
785        assert_eq!(shake.intensity, 10.0);
786        assert_eq!(shake.duration, 0.5);
787        assert!(shake.is_active());
788    }
789
790    #[test]
791    fn test_screen_shake_stronger_override() {
792        let mut shake = ScreenShake::new();
793        shake.trigger(5.0, 0.5);
794        shake.trigger(10.0, 0.3); // Stronger intensity
795
796        assert_eq!(shake.intensity, 10.0);
797    }
798
799    #[test]
800    fn test_screen_shake_weaker_no_override() {
801        let mut shake = ScreenShake::new();
802        shake.trigger(10.0, 0.5);
803        shake.trigger(5.0, 0.3); // Weaker intensity
804
805        assert_eq!(shake.intensity, 10.0); // Should keep original
806    }
807
808    #[test]
809    fn test_screen_shake_update_decay() {
810        let mut shake = ScreenShake::new();
811        shake.trigger(10.0, 0.5);
812
813        // Update several times
814        for _ in 0..100 {
815            let _ = shake.update(0.016);
816        }
817
818        // Should have decayed to zero
819        assert!(!shake.is_active());
820        let (x, y) = shake.offset();
821        assert_eq!(x, 0.0);
822        assert_eq!(y, 0.0);
823    }
824
825    #[test]
826    fn test_screen_shake_produces_offset() {
827        let mut shake = ScreenShake::new();
828        shake.trigger(10.0, 0.5);
829
830        let (x, y) = shake.update(0.016);
831
832        // Should produce some offset
833        assert!(x != 0.0 || y != 0.0);
834    }
835
836    #[test]
837    fn test_screen_shake_reset() {
838        let mut shake = ScreenShake::new();
839        shake.trigger(10.0, 0.5);
840        let _ = shake.update(0.016);
841
842        shake.reset();
843
844        assert!(!shake.is_active());
845        assert_eq!(shake.offset(), (0.0, 0.0));
846    }
847
848    // =========================================================================
849    // BallTrail Tests
850    // =========================================================================
851
852    #[test]
853    fn test_ball_trail_new() {
854        let trail = BallTrail::new(10, 0.016, 0.15);
855        assert_eq!(trail.points.len(), 10);
856        assert_eq!(trail.active_count(), 0);
857    }
858
859    #[test]
860    fn test_ball_trail_default() {
861        let trail = BallTrail::default();
862        assert_eq!(trail.points.len(), 10);
863    }
864
865    #[test]
866    fn test_ball_trail_update_adds_points() {
867        let mut trail = BallTrail::new(10, 0.016, 0.15);
868
869        // Update with ball position
870        trail.update(100.0, 200.0, 0.016);
871
872        assert_eq!(trail.active_count(), 1);
873    }
874
875    #[test]
876    fn test_ball_trail_points_age() {
877        let mut trail = BallTrail::new(10, 0.016, 0.1);
878        trail.update(100.0, 200.0, 0.016);
879
880        // Age the point past max_age
881        for _ in 0..20 {
882            trail.update(100.0, 200.0, 0.016);
883        }
884
885        // Old points should have been removed due to aging
886        let points = trail.get_points();
887        for (_, _, alpha) in &points {
888            assert!(*alpha > 0.0);
889        }
890    }
891
892    #[test]
893    fn test_ball_trail_get_points() {
894        let mut trail = BallTrail::new(10, 0.016, 0.15);
895        trail.update(100.0, 200.0, 0.016);
896
897        let points = trail.get_points();
898        assert_eq!(points.len(), 1);
899
900        let (x, y, alpha) = points[0];
901        assert_eq!(x, 100.0);
902        assert_eq!(y, 200.0);
903        assert!((alpha - 1.0).abs() < 0.1); // Nearly full alpha
904    }
905
906    #[test]
907    fn test_ball_trail_clear() {
908        let mut trail = BallTrail::new(10, 0.016, 0.15);
909        trail.update(100.0, 200.0, 0.016);
910        trail.update(100.0, 200.0, 0.016);
911
912        trail.clear();
913
914        assert_eq!(trail.active_count(), 0);
915    }
916
917    #[test]
918    fn test_ball_trail_ring_buffer() {
919        let mut trail = BallTrail::new(5, 0.001, 1.0); // Very short interval
920
921        // Add more points than capacity
922        for i in 0..10 {
923            trail.update(i as f32 * 10.0, 0.0, 0.002);
924        }
925
926        // Should have at most max_points active
927        assert!(trail.active_count() <= 5);
928    }
929
930    // =========================================================================
931    // HitFlash Tests
932    // =========================================================================
933
934    #[test]
935    fn test_hit_flash_new() {
936        let flash = HitFlash::new();
937        assert!(!flash.is_active());
938    }
939
940    #[test]
941    fn test_hit_flash_trigger() {
942        let mut flash = HitFlash::new();
943        flash.trigger(true, 0.8, 0.1);
944
945        assert!(flash.is_active());
946    }
947
948    #[test]
949    fn test_hit_flash_update() {
950        let mut flash = HitFlash::new();
951        flash.trigger(false, 1.0, 0.1);
952
953        let (active, right, intensity) = flash.update(0.016);
954
955        assert!(active);
956        assert!(!right);
957        assert!(intensity > 0.0);
958    }
959
960    #[test]
961    fn test_hit_flash_decays() {
962        let mut flash = HitFlash::new();
963        flash.trigger(true, 1.0, 0.1);
964
965        // Update past duration
966        for _ in 0..20 {
967            let _ = flash.update(0.016);
968        }
969
970        assert!(!flash.is_active());
971    }
972
973    #[test]
974    fn test_hit_flash_reset() {
975        let mut flash = HitFlash::new();
976        flash.trigger(true, 1.0, 0.5);
977
978        flash.reset();
979
980        assert!(!flash.is_active());
981    }
982
983    #[test]
984    fn test_hit_flash_state_inactive() {
985        let flash = HitFlash::new();
986        let (left, right, intensity) = flash.flash_state();
987
988        assert!(!left);
989        assert!(!right);
990        assert_eq!(intensity, 0.0);
991    }
992
993    #[test]
994    fn test_hit_flash_state_left_paddle() {
995        let mut flash = HitFlash::new();
996        flash.trigger(false, 1.0, 0.1);
997
998        let (left, right, intensity) = flash.flash_state();
999
1000        assert!(left);
1001        assert!(!right);
1002        assert!(intensity > 0.0);
1003    }
1004
1005    #[test]
1006    fn test_hit_flash_state_right_paddle() {
1007        let mut flash = HitFlash::new();
1008        flash.trigger(true, 1.0, 0.1);
1009
1010        let (left, right, intensity) = flash.flash_state();
1011
1012        assert!(!left);
1013        assert!(right);
1014        assert!(intensity > 0.0);
1015    }
1016
1017    // =========================================================================
1018    // ScorePopup Tests
1019    // =========================================================================
1020
1021    #[test]
1022    fn test_score_popup_new() {
1023        let popup = ScorePopup::new(400.0, 300.0, "+1", 1.0);
1024
1025        assert_eq!(popup.x, 400.0);
1026        assert_eq!(popup.y, 300.0);
1027        assert_eq!(popup.text, "+1");
1028        assert_eq!(popup.duration, 1.0);
1029    }
1030
1031    #[test]
1032    fn test_score_popup_update() {
1033        let mut popup = ScorePopup::new(400.0, 300.0, "+1", 1.0);
1034        let initial_y = popup.y;
1035
1036        let active = popup.update(0.1);
1037
1038        assert!(active);
1039        assert!(popup.y < initial_y); // Should float up
1040    }
1041
1042    #[test]
1043    fn test_score_popup_expires() {
1044        let mut popup = ScorePopup::new(400.0, 300.0, "+1", 0.1);
1045
1046        // Update past duration
1047        for _ in 0..20 {
1048            let _ = popup.update(0.016);
1049        }
1050
1051        let active = popup.update(0.016);
1052        assert!(!active);
1053    }
1054
1055    #[test]
1056    fn test_score_popup_alpha() {
1057        let popup = ScorePopup::new(400.0, 300.0, "+1", 1.0);
1058        assert_eq!(popup.alpha(), 1.0);
1059
1060        let mut popup2 = ScorePopup::new(400.0, 300.0, "+1", 0.3);
1061        let _ = popup2.update(0.2);
1062        assert!(popup2.alpha() < 1.0);
1063    }
1064
1065    // =========================================================================
1066    // JuiceEffects Tests
1067    // =========================================================================
1068
1069    #[test]
1070    fn test_juice_effects_new() {
1071        let juice = JuiceEffects::new();
1072        assert!(!juice.screen_shake.is_active());
1073        assert!(!juice.hit_flash.is_active());
1074        assert!(juice.score_popups.is_empty());
1075    }
1076
1077    #[test]
1078    fn test_juice_effects_default() {
1079        let juice = JuiceEffects::default();
1080        assert!(!juice.screen_shake.is_active());
1081    }
1082
1083    #[test]
1084    fn test_juice_effects_on_goal() {
1085        let mut juice = JuiceEffects::new();
1086        juice.on_goal(400.0, 300.0, "+1");
1087
1088        assert!(juice.screen_shake.is_active());
1089        assert_eq!(juice.score_popups.len(), 1);
1090    }
1091
1092    #[test]
1093    fn test_juice_effects_on_paddle_hit() {
1094        let mut juice = JuiceEffects::new();
1095        juice.on_paddle_hit(true);
1096
1097        assert!(juice.screen_shake.is_active());
1098        assert!(juice.hit_flash.is_active());
1099    }
1100
1101    #[test]
1102    fn test_juice_effects_on_wall_bounce() {
1103        let mut juice = JuiceEffects::new();
1104        juice.on_wall_bounce();
1105
1106        assert!(juice.screen_shake.is_active());
1107    }
1108
1109    #[test]
1110    fn test_juice_effects_update() {
1111        let mut juice = JuiceEffects::new();
1112        juice.on_goal(400.0, 300.0, "+1");
1113
1114        // Update should process all effects
1115        juice.update(100.0, 200.0, 0.016);
1116
1117        // Trail should have a point
1118        assert!(juice.ball_trail.active_count() > 0);
1119    }
1120
1121    #[test]
1122    fn test_juice_effects_reset() {
1123        let mut juice = JuiceEffects::new();
1124        juice.on_goal(400.0, 300.0, "+1");
1125        juice.on_paddle_hit(false);
1126        juice.update(100.0, 200.0, 0.016);
1127
1128        juice.reset();
1129
1130        assert!(!juice.screen_shake.is_active());
1131        assert!(!juice.hit_flash.is_active());
1132        assert!(juice.score_popups.is_empty());
1133        assert_eq!(juice.ball_trail.active_count(), 0);
1134    }
1135
1136    #[test]
1137    fn test_juice_effects_popup_cleanup() {
1138        let mut juice = JuiceEffects::new();
1139        juice.on_goal(400.0, 300.0, "+1");
1140
1141        // Update many times to expire popup
1142        for _ in 0..100 {
1143            juice.update(100.0, 200.0, 0.016);
1144        }
1145
1146        // Popup should have been removed
1147        assert!(juice.score_popups.is_empty());
1148    }
1149
1150    // =========================================================================
1151    // Particle Tests
1152    // =========================================================================
1153
1154    #[test]
1155    fn test_particle_new() {
1156        let particle = Particle::new(100.0, 200.0, 50.0, -30.0, 1.0, 5.0, 0xFF0000);
1157
1158        assert_eq!(particle.x, 100.0);
1159        assert_eq!(particle.y, 200.0);
1160        assert_eq!(particle.vx, 50.0);
1161        assert_eq!(particle.vy, -30.0);
1162        assert_eq!(particle.lifetime, 1.0);
1163        assert_eq!(particle.initial_lifetime, 1.0);
1164        assert_eq!(particle.size, 5.0);
1165        assert_eq!(particle.color, 0xFF0000);
1166        assert!(particle.active);
1167    }
1168
1169    #[test]
1170    fn test_particle_update_position() {
1171        let mut particle = Particle::new(100.0, 200.0, 50.0, -30.0, 1.0, 5.0, 0xFF0000);
1172
1173        let alive = particle.update(0.1);
1174
1175        assert!(alive);
1176        // Position should change based on velocity
1177        assert!((particle.x - 105.0).abs() < 0.01);
1178        // Y changes by velocity + gravity
1179        assert!(particle.y < 200.0 - 2.0); // Should have moved up somewhat
1180    }
1181
1182    #[test]
1183    fn test_particle_expires() {
1184        let mut particle = Particle::new(100.0, 200.0, 50.0, -30.0, 0.1, 5.0, 0xFF0000);
1185
1186        // Update past lifetime
1187        for _ in 0..20 {
1188            let _ = particle.update(0.016);
1189        }
1190
1191        assert!(!particle.active);
1192        assert!(!particle.update(0.016)); // Should return false when dead
1193    }
1194
1195    #[test]
1196    fn test_particle_alpha() {
1197        let mut particle = Particle::new(100.0, 200.0, 50.0, -30.0, 1.0, 5.0, 0xFF0000);
1198
1199        // Initially full alpha
1200        assert!((particle.alpha() - 1.0).abs() < 0.01);
1201
1202        // Half way through
1203        let _ = particle.update(0.5);
1204        assert!(particle.alpha() > 0.4 && particle.alpha() < 0.6);
1205
1206        // Dead particle
1207        particle.lifetime = 0.0;
1208        particle.active = false;
1209        assert_eq!(particle.alpha(), 0.0);
1210    }
1211
1212    #[test]
1213    fn test_particle_alpha_zero_initial_lifetime() {
1214        let mut particle = Particle::new(100.0, 200.0, 50.0, -30.0, 1.0, 5.0, 0xFF0000);
1215        particle.initial_lifetime = 0.0;
1216
1217        assert_eq!(particle.alpha(), 0.0);
1218    }
1219
1220    #[test]
1221    fn test_particle_rgb() {
1222        let particle = Particle::new(100.0, 200.0, 0.0, 0.0, 1.0, 5.0, 0xFF8000); // Orange
1223
1224        let (r, g, b) = particle.rgb();
1225
1226        assert!((r - 1.0).abs() < 0.01); // FF = 255 = 1.0
1227        assert!((g - 0.5).abs() < 0.02); // 80 = 128 ≈ 0.5
1228        assert!((b - 0.0).abs() < 0.01); // 00 = 0 = 0.0
1229    }
1230
1231    #[test]
1232    fn test_particle_inactive_doesnt_update() {
1233        let mut particle = Particle::new(100.0, 200.0, 50.0, -30.0, 1.0, 5.0, 0xFF0000);
1234        particle.active = false;
1235
1236        let alive = particle.update(0.1);
1237
1238        assert!(!alive);
1239        // Position should not have changed
1240        assert_eq!(particle.x, 100.0);
1241    }
1242
1243    // =========================================================================
1244    // ParticleSystem Tests
1245    // =========================================================================
1246
1247    #[test]
1248    fn test_particle_system_new() {
1249        let system = ParticleSystem::new(100);
1250
1251        assert_eq!(system.particles.len(), 100);
1252        assert_eq!(system.active_count(), 0);
1253    }
1254
1255    #[test]
1256    fn test_particle_system_default() {
1257        let system = ParticleSystem::default();
1258
1259        assert_eq!(system.particles.len(), 200);
1260        assert_eq!(system.active_count(), 0);
1261    }
1262
1263    #[test]
1264    fn test_particle_system_spawn() {
1265        let mut system = ParticleSystem::new(100);
1266
1267        system.spawn(400.0, 300.0, 10, 100.0, 1.0, 5.0, 0xFFFFFF);
1268
1269        assert_eq!(system.active_count(), 10);
1270    }
1271
1272    #[test]
1273    fn test_particle_system_spawn_directional() {
1274        let mut system = ParticleSystem::new(100);
1275
1276        system.spawn_directional(400.0, 300.0, 1.0, 0.0, 0.5, 15, 100.0, 1.0, 5.0, 0x00FF00);
1277
1278        assert_eq!(system.active_count(), 15);
1279    }
1280
1281    #[test]
1282    fn test_particle_system_update() {
1283        let mut system = ParticleSystem::new(100);
1284        system.spawn(400.0, 300.0, 5, 100.0, 1.0, 5.0, 0xFFFFFF);
1285
1286        // Get initial positions
1287        let initial: Vec<(f32, f32)> = system.get_active().iter().map(|p| (p.x, p.y)).collect();
1288
1289        system.update(0.1);
1290
1291        // Positions should have changed
1292        let updated: Vec<(f32, f32)> = system.get_active().iter().map(|p| (p.x, p.y)).collect();
1293        assert_ne!(initial, updated);
1294    }
1295
1296    #[test]
1297    fn test_particle_system_particles_expire() {
1298        let mut system = ParticleSystem::new(100);
1299        system.spawn(400.0, 300.0, 10, 100.0, 0.1, 5.0, 0xFFFFFF); // Short lifetime
1300
1301        // Update past lifetime
1302        for _ in 0..20 {
1303            system.update(0.016);
1304        }
1305
1306        // All particles should have expired
1307        assert_eq!(system.active_count(), 0);
1308    }
1309
1310    #[test]
1311    fn test_particle_system_get_active() {
1312        let mut system = ParticleSystem::new(100);
1313        system.spawn(400.0, 300.0, 5, 100.0, 1.0, 5.0, 0xFFFFFF);
1314
1315        let active = system.get_active();
1316
1317        assert_eq!(active.len(), 5);
1318        for particle in active {
1319            assert!(particle.active);
1320        }
1321    }
1322
1323    #[test]
1324    fn test_particle_system_clear() {
1325        let mut system = ParticleSystem::new(100);
1326        system.spawn(400.0, 300.0, 10, 100.0, 1.0, 5.0, 0xFFFFFF);
1327        assert_eq!(system.active_count(), 10);
1328
1329        system.clear();
1330
1331        assert_eq!(system.active_count(), 0);
1332    }
1333
1334    #[test]
1335    fn test_particle_system_ring_buffer() {
1336        let mut system = ParticleSystem::new(10);
1337
1338        // Spawn more particles than pool size
1339        system.spawn(400.0, 300.0, 15, 100.0, 1.0, 5.0, 0xFFFFFF);
1340
1341        // Should have wrapped around
1342        assert!(system.active_count() <= 10);
1343    }
1344
1345    // =========================================================================
1346    // JuiceEffects Particle Integration Tests
1347    // =========================================================================
1348
1349    #[test]
1350    fn test_juice_effects_on_goal_spawns_particles() {
1351        let mut juice = JuiceEffects::new();
1352        juice.on_goal(400.0, 300.0, "+1");
1353
1354        // Should have spawned celebration particles
1355        assert!(juice.particles.active_count() > 0);
1356    }
1357
1358    #[test]
1359    fn test_juice_effects_on_paddle_hit_at_spawns_particles() {
1360        let mut juice = JuiceEffects::new();
1361        juice.on_paddle_hit_at(50.0, 300.0, false);
1362
1363        // Should have spawned spark particles
1364        assert!(juice.particles.active_count() > 0);
1365    }
1366
1367    #[test]
1368    fn test_juice_effects_particles_update() {
1369        let mut juice = JuiceEffects::new();
1370        juice.on_goal(400.0, 300.0, "+1");
1371        let initial_count = juice.particles.active_count();
1372
1373        // Update many times
1374        for _ in 0..100 {
1375            juice.update(100.0, 200.0, 0.016);
1376        }
1377
1378        // Particles should have decayed
1379        assert!(juice.particles.active_count() < initial_count);
1380    }
1381
1382    #[test]
1383    fn test_juice_effects_reset_clears_particles() {
1384        let mut juice = JuiceEffects::new();
1385        juice.on_goal(400.0, 300.0, "+1");
1386        assert!(juice.particles.active_count() > 0);
1387
1388        juice.reset();
1389
1390        assert_eq!(juice.particles.active_count(), 0);
1391    }
1392}