Skip to main content

proof_engine/game/
weapon_physics.rs

1//! Weapon physics and combat trail system for Chaos RPG.
2//!
3//! Provides swing arc simulation, weapon trail rendering (ribbon mesh generation),
4//! impact effects (camera shake, debris, distortion rings), damage number popups
5//! with physics-based arcs, combo-trail integration, block/parry feedback, and
6//! per-element visual effect descriptors.
7
8use glam::{Vec2, Vec3};
9use crate::combat::Element;
10
11// ============================================================================
12// Constants
13// ============================================================================
14
15const MAX_TRAIL_SEGMENTS: usize = 32;
16const MAX_DAMAGE_NUMBERS: usize = 50;
17const GRAVITY: f32 = 9.81;
18const IMPACT_COMPRESS_DURATION: f32 = 0.2;
19const PARRY_TIME_SCALE: f32 = 0.3;
20const PARRY_SLOW_DURATION: f32 = 0.5;
21const DEFAULT_DEBRIS_COUNT_MIN: usize = 5;
22const DEFAULT_DEBRIS_COUNT_MAX: usize = 10;
23
24// ============================================================================
25// WeaponType
26// ============================================================================
27
28/// The ten weapon archetypes available in Chaos RPG.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum WeaponType {
31    Sword,
32    Axe,
33    Mace,
34    Staff,
35    Dagger,
36    Spear,
37    Bow,
38    Fist,
39    Scythe,
40    Whip,
41}
42
43impl WeaponType {
44    /// All weapon types as a slice, useful for iteration.
45    pub fn all() -> &'static [WeaponType] {
46        &[
47            WeaponType::Sword,
48            WeaponType::Axe,
49            WeaponType::Mace,
50            WeaponType::Staff,
51            WeaponType::Dagger,
52            WeaponType::Spear,
53            WeaponType::Bow,
54            WeaponType::Fist,
55            WeaponType::Scythe,
56            WeaponType::Whip,
57        ]
58    }
59
60    /// Human-readable name for display.
61    pub fn name(self) -> &'static str {
62        match self {
63            WeaponType::Sword  => "Sword",
64            WeaponType::Axe    => "Axe",
65            WeaponType::Mace   => "Mace",
66            WeaponType::Staff  => "Staff",
67            WeaponType::Dagger => "Dagger",
68            WeaponType::Spear  => "Spear",
69            WeaponType::Bow    => "Bow",
70            WeaponType::Fist   => "Fist",
71            WeaponType::Scythe => "Scythe",
72            WeaponType::Whip   => "Whip",
73        }
74    }
75}
76
77// ============================================================================
78// WeaponProfile
79// ============================================================================
80
81/// Physical and visual properties that govern how a weapon behaves during
82/// swings, impacts, and trail rendering.
83#[derive(Debug, Clone)]
84pub struct WeaponProfile {
85    /// Mass in kilograms. Affects impact camera shake and knockback.
86    pub mass: f32,
87    /// Length in metres from grip to tip.
88    pub length: f32,
89    /// Swing speed multiplier (1.0 = baseline sword speed).
90    pub swing_speed: f32,
91    /// Force applied on contact (Newtons-ish, game units).
92    pub impact_force: f32,
93    /// Half-width of the rendered trail ribbon.
94    pub trail_width: f32,
95    /// Number of trail segments to spawn per swing.
96    pub trail_segments: usize,
97    /// Optional elemental affinity baked into the weapon.
98    pub element: Option<Element>,
99}
100
101impl WeaponProfile {
102    /// Construct with explicit values.
103    pub fn new(
104        mass: f32,
105        length: f32,
106        swing_speed: f32,
107        impact_force: f32,
108        trail_width: f32,
109        trail_segments: usize,
110        element: Option<Element>,
111    ) -> Self {
112        Self { mass, length, swing_speed, impact_force, trail_width, trail_segments, element }
113    }
114}
115
116// ============================================================================
117// WeaponProfiles — factory presets
118// ============================================================================
119
120/// Factory methods returning canonical `WeaponProfile` for each `WeaponType`.
121pub struct WeaponProfiles;
122
123impl WeaponProfiles {
124    /// Return the preset profile for the given weapon type.
125    pub fn get(weapon: WeaponType) -> WeaponProfile {
126        match weapon {
127            WeaponType::Sword => WeaponProfile {
128                mass: 1.5,
129                length: 1.0,
130                swing_speed: 1.0,
131                impact_force: 80.0,
132                trail_width: 0.12,
133                trail_segments: 24,
134                element: None,
135            },
136            WeaponType::Axe => WeaponProfile {
137                mass: 3.5,
138                length: 0.9,
139                swing_speed: 0.65,
140                impact_force: 160.0,
141                trail_width: 0.18,
142                trail_segments: 18,
143                element: None,
144            },
145            WeaponType::Mace => WeaponProfile {
146                mass: 4.0,
147                length: 0.8,
148                swing_speed: 0.55,
149                impact_force: 200.0,
150                trail_width: 0.15,
151                trail_segments: 16,
152                element: None,
153            },
154            WeaponType::Staff => WeaponProfile {
155                mass: 1.2,
156                length: 1.6,
157                swing_speed: 0.85,
158                impact_force: 40.0,
159                trail_width: 0.20,
160                trail_segments: 28,
161                element: Some(Element::Entropy),
162            },
163            WeaponType::Dagger => WeaponProfile {
164                mass: 0.5,
165                length: 0.35,
166                swing_speed: 1.6,
167                impact_force: 35.0,
168                trail_width: 0.06,
169                trail_segments: 20,
170                element: None,
171            },
172            WeaponType::Spear => WeaponProfile {
173                mass: 2.0,
174                length: 2.0,
175                swing_speed: 0.75,
176                impact_force: 120.0,
177                trail_width: 0.08,
178                trail_segments: 22,
179                element: None,
180            },
181            WeaponType::Bow => WeaponProfile {
182                mass: 0.8,
183                length: 1.3,
184                swing_speed: 0.40,
185                impact_force: 90.0,
186                trail_width: 0.04,
187                trail_segments: 30,
188                element: None,
189            },
190            WeaponType::Fist => WeaponProfile {
191                mass: 0.3,
192                length: 0.25,
193                swing_speed: 2.0,
194                impact_force: 50.0,
195                trail_width: 0.10,
196                trail_segments: 14,
197                element: None,
198            },
199            WeaponType::Scythe => WeaponProfile {
200                mass: 3.0,
201                length: 1.8,
202                swing_speed: 0.70,
203                impact_force: 140.0,
204                trail_width: 0.22,
205                trail_segments: 26,
206                element: Some(Element::Shadow),
207            },
208            WeaponType::Whip => WeaponProfile {
209                mass: 0.6,
210                length: 3.0,
211                swing_speed: 1.2,
212                impact_force: 30.0,
213                trail_width: 0.05,
214                trail_segments: 32,
215                element: None,
216            },
217        }
218    }
219
220    /// Convenience: return all presets as a vec of `(WeaponType, WeaponProfile)`.
221    pub fn all() -> Vec<(WeaponType, WeaponProfile)> {
222        WeaponType::all().iter().map(|&w| (w, Self::get(w))).collect()
223    }
224}
225
226// ============================================================================
227// SwingArc
228// ============================================================================
229
230/// Describes a circular swing arc in 3D space (in the XZ plane centred at
231/// `origin`). The weapon tip traces from `start_angle` to `end_angle` over
232/// `duration` seconds at the given `radius` (== weapon length).
233#[derive(Debug, Clone)]
234pub struct SwingArc {
235    /// Starting angle in radians.
236    pub start_angle: f32,
237    /// Ending angle in radians.
238    pub end_angle: f32,
239    /// Total swing duration in seconds.
240    pub duration: f32,
241    /// Elapsed time since swing began.
242    pub elapsed: f32,
243    /// World-space origin of the swing (character pivot).
244    pub origin: Vec3,
245    /// Radius of the arc (weapon length).
246    pub radius: f32,
247}
248
249impl SwingArc {
250    /// Create a new swing arc.
251    pub fn new(
252        start_angle: f32,
253        end_angle: f32,
254        duration: f32,
255        origin: Vec3,
256        radius: f32,
257    ) -> Self {
258        Self {
259            start_angle,
260            end_angle,
261            duration,
262            elapsed: 0.0,
263            origin,
264            radius,
265        }
266    }
267
268    /// Normalised progress of the swing `[0, 1]`.
269    pub fn progress(&self) -> f32 {
270        if self.duration <= 0.0 { return 1.0; }
271        (self.elapsed / self.duration).clamp(0.0, 1.0)
272    }
273
274    /// Whether the swing has completed.
275    pub fn finished(&self) -> bool {
276        self.elapsed >= self.duration
277    }
278
279    /// Angle at normalised time `t` (`[0,1]`).
280    fn angle_at(&self, t: f32) -> f32 {
281        self.start_angle + (self.end_angle - self.start_angle) * t
282    }
283
284    /// Position along the arc at normalised time `t` (`[0,1]`).
285    /// The arc lies in the XZ plane relative to `origin`.
286    pub fn sample(&self, t: f32) -> Vec3 {
287        let t_clamped = t.clamp(0.0, 1.0);
288        let angle = self.angle_at(t_clamped);
289        let x = angle.cos() * self.radius;
290        let z = angle.sin() * self.radius;
291        self.origin + Vec3::new(x, 0.0, z)
292    }
293
294    /// Tangential velocity at normalised time `t`.
295    /// The magnitude equals `radius * angular_velocity`.
296    pub fn velocity_at(&self, t: f32) -> Vec3 {
297        let t_clamped = t.clamp(0.0, 1.0);
298        let angle = self.angle_at(t_clamped);
299        let angular_vel = if self.duration > 0.0 {
300            (self.end_angle - self.start_angle) / self.duration
301        } else {
302            0.0
303        };
304        let speed = self.radius * angular_vel;
305        // Tangent to circle at angle: (-sin, 0, cos)
306        Vec3::new(-angle.sin() * speed, 0.0, angle.cos() * speed)
307    }
308
309    /// Advance the arc by `dt` seconds. Returns `true` while still active.
310    pub fn tick(&mut self, dt: f32) -> bool {
311        self.elapsed += dt;
312        !self.finished()
313    }
314}
315
316// ============================================================================
317// WeaponTrailSegment
318// ============================================================================
319
320/// A single segment of the weapon trail ribbon.
321#[derive(Debug, Clone)]
322pub struct WeaponTrailSegment {
323    /// World-space position of the segment centre.
324    pub position: Vec3,
325    /// Velocity (used for physics-based compression on impact).
326    pub velocity: Vec3,
327    /// Half-width of the ribbon at this segment.
328    pub width: f32,
329    /// RGBA colour.
330    pub color: [f32; 4],
331    /// Emission intensity (bloom).
332    pub emission: f32,
333    /// Age in seconds since this segment was spawned.
334    pub age: f32,
335}
336
337impl WeaponTrailSegment {
338    pub fn new(position: Vec3, velocity: Vec3, width: f32, color: [f32; 4], emission: f32) -> Self {
339        Self { position, velocity, width, color, emission, age: 0.0 }
340    }
341
342    /// Update the segment by `dt` seconds.
343    pub fn update(&mut self, dt: f32) {
344        self.age += dt;
345        self.position += self.velocity * dt;
346        // Dampen velocity over time
347        self.velocity *= (1.0 - 3.0 * dt).max(0.0);
348    }
349
350    /// Fade alpha based on age relative to a maximum lifetime.
351    pub fn alpha(&self, max_age: f32) -> f32 {
352        if max_age <= 0.0 { return 0.0; }
353        (1.0 - (self.age / max_age)).clamp(0.0, 1.0)
354    }
355}
356
357// ============================================================================
358// TrailVertex — output for GPU ribbon rendering
359// ============================================================================
360
361/// A vertex emitted by the trail ribbon builder, ready for GPU upload.
362#[derive(Debug, Clone, Copy)]
363pub struct TrailVertex {
364    pub position: Vec3,
365    pub color: [f32; 4],
366    pub emission: f32,
367    pub uv: Vec2,
368}
369
370// ============================================================================
371// WeaponTrail — ring-buffer trail manager
372// ============================================================================
373
374/// Manages a ring buffer of trail segments, spawning them along a swing arc
375/// and applying impact compression.
376#[derive(Debug, Clone)]
377pub struct WeaponTrail {
378    /// Ring buffer of trail segments.
379    segments: Vec<WeaponTrailSegment>,
380    /// Write index into the ring buffer.
381    head: usize,
382    /// Number of live segments in the buffer.
383    count: usize,
384    /// Timer controlling segment spawn rate.
385    spawn_timer: f32,
386    /// Interval between segment spawns (seconds).
387    spawn_interval: f32,
388    /// The weapon profile driving trail appearance.
389    pub profile: WeaponProfile,
390    /// Active swing arc (if any).
391    active_arc: Option<SwingArc>,
392    /// Impact compression state.
393    impact_state: Option<ImpactCompressState>,
394    /// Maximum segment age before culling (seconds).
395    max_segment_age: f32,
396    /// Combo intensity multiplier (1.0 = normal).
397    combo_intensity: f32,
398}
399
400/// Internal state for the impact-compression animation.
401#[derive(Debug, Clone)]
402struct ImpactCompressState {
403    contact_point: Vec3,
404    elapsed: f32,
405    duration: f32,
406    phase: ImpactPhase,
407}
408
409#[derive(Debug, Clone, Copy, PartialEq)]
410enum ImpactPhase {
411    /// Segments are being pulled toward the contact point.
412    Compress,
413    /// Segments spring back outward.
414    SpringBack,
415}
416
417impl WeaponTrail {
418    /// Create a new trail for the given weapon profile.
419    pub fn new(profile: WeaponProfile) -> Self {
420        let seg_count = profile.trail_segments.min(MAX_TRAIL_SEGMENTS);
421        let spawn_interval = if seg_count > 0 { 1.0 / (seg_count as f32 * 2.0) } else { 0.05 };
422        let mut segments = Vec::with_capacity(MAX_TRAIL_SEGMENTS);
423        for _ in 0..MAX_TRAIL_SEGMENTS {
424            segments.push(WeaponTrailSegment::new(
425                Vec3::ZERO, Vec3::ZERO, 0.0, [0.0; 4], 0.0,
426            ));
427        }
428        Self {
429            segments,
430            head: 0,
431            count: 0,
432            spawn_timer: 0.0,
433            spawn_interval,
434            profile,
435            active_arc: None,
436            impact_state: None,
437            max_segment_age: 0.6,
438            combo_intensity: 1.0,
439        }
440    }
441
442    /// Set the combo intensity multiplier. Higher values make the trail wider,
443    /// brighter, and more emissive.
444    pub fn set_combo_intensity(&mut self, intensity: f32) {
445        self.combo_intensity = intensity.max(1.0);
446    }
447
448    /// Begin a new swing, replacing any active arc.
449    pub fn begin_swing(&mut self, arc: SwingArc) {
450        self.active_arc = Some(arc);
451        self.spawn_timer = 0.0;
452    }
453
454    /// Update the trail by `dt` seconds.
455    pub fn update(&mut self, dt: f32) {
456        // Pre-compute values that need &self before mutable borrow
457        let trail_color = self.element_trail_color();
458        let trail_width = self.profile.trail_width * self.combo_intensity;
459        let base_emission = 0.5 * self.combo_intensity;
460
461        // Tick the active arc and collect new segments (avoids borrow conflict)
462        let mut new_segments: Vec<WeaponTrailSegment> = Vec::new();
463        let mut arc_finished = false;
464        if let Some(ref mut arc) = self.active_arc {
465            arc.tick(dt);
466            self.spawn_timer += dt;
467            while self.spawn_timer >= self.spawn_interval {
468                self.spawn_timer -= self.spawn_interval;
469                let t = arc.progress();
470                let pos = arc.sample(t);
471                let vel = arc.velocity_at(t);
472                new_segments.push(WeaponTrailSegment::new(
473                    pos, vel * 0.1, trail_width, trail_color, base_emission,
474                ));
475            }
476            arc_finished = arc.finished();
477        }
478        for seg in new_segments {
479            self.push_segment(seg);
480        }
481        if arc_finished {
482            self.active_arc = None;
483        }
484
485        // Age and update existing segments
486        for i in 0..MAX_TRAIL_SEGMENTS {
487            if self.segment_alive(i) {
488                self.segments[i].update(dt);
489            }
490        }
491
492        // Impact compression animation
493        if let Some(ref mut state) = self.impact_state.clone() {
494            state.elapsed += dt;
495            match state.phase {
496                ImpactPhase::Compress => {
497                    // Pull segments toward impact point
498                    let compress_strength = 8.0 * dt;
499                    for i in 0..MAX_TRAIL_SEGMENTS {
500                        if self.segment_alive(i) {
501                            let diff = state.contact_point - self.segments[i].position;
502                            let dist = diff.length();
503                            if dist > 0.01 && dist < 2.0 {
504                                let pull = diff.normalize() * compress_strength * (1.0 / (dist + 0.5));
505                                self.segments[i].velocity += pull;
506                            }
507                        }
508                    }
509                    if state.elapsed >= IMPACT_COMPRESS_DURATION {
510                        state.phase = ImpactPhase::SpringBack;
511                        state.elapsed = 0.0;
512                    }
513                }
514                ImpactPhase::SpringBack => {
515                    // Push segments away from impact point
516                    let spring_strength = 12.0 * dt;
517                    for i in 0..MAX_TRAIL_SEGMENTS {
518                        if self.segment_alive(i) {
519                            let diff = self.segments[i].position - state.contact_point;
520                            let dist = diff.length();
521                            if dist > 0.01 && dist < 3.0 {
522                                let push = diff.normalize() * spring_strength * (1.0 / (dist + 0.5));
523                                self.segments[i].velocity += push;
524                            }
525                        }
526                    }
527                    if state.elapsed >= IMPACT_COMPRESS_DURATION {
528                        self.impact_state = None;
529                        return;
530                    }
531                }
532            }
533            self.impact_state = Some(state.clone());
534        }
535    }
536
537    /// Trigger the impact compression effect at the given contact point.
538    /// Segments near the contact will compress toward it, then spring back
539    /// after `IMPACT_COMPRESS_DURATION` seconds.
540    pub fn on_impact(&mut self, contact_point: Vec3) {
541        // Boost emission near the impact
542        for i in 0..MAX_TRAIL_SEGMENTS {
543            if self.segment_alive(i) {
544                let dist = (self.segments[i].position - contact_point).length();
545                if dist < 1.5 {
546                    self.segments[i].emission += 2.0 * (1.0 - dist / 1.5);
547                }
548            }
549        }
550        self.impact_state = Some(ImpactCompressState {
551            contact_point,
552            elapsed: 0.0,
553            duration: IMPACT_COMPRESS_DURATION * 2.0,
554            phase: ImpactPhase::Compress,
555        });
556    }
557
558    /// Build triangle-strip render data from the current trail segments.
559    pub fn get_render_data(&self) -> Vec<TrailVertex> {
560        TrailRibbon::build(self)
561    }
562
563    // ── internal helpers ─────────────────────────────────────────────────
564
565    fn push_segment(&mut self, seg: WeaponTrailSegment) {
566        self.segments[self.head] = seg;
567        self.head = (self.head + 1) % MAX_TRAIL_SEGMENTS;
568        if self.count < MAX_TRAIL_SEGMENTS {
569            self.count += 1;
570        }
571    }
572
573    fn segment_alive(&self, index: usize) -> bool {
574        if index >= MAX_TRAIL_SEGMENTS { return false; }
575        // A segment is alive if its age is below the max and it has been written
576        self.segments[index].age < self.max_segment_age && self.count > 0
577    }
578
579    fn ordered_segments(&self) -> Vec<&WeaponTrailSegment> {
580        if self.count == 0 { return Vec::new(); }
581        let mut out = Vec::with_capacity(self.count);
582        let start = if self.count < MAX_TRAIL_SEGMENTS {
583            0
584        } else {
585            self.head
586        };
587        for i in 0..self.count {
588            let idx = (start + i) % MAX_TRAIL_SEGMENTS;
589            if self.segments[idx].age < self.max_segment_age {
590                out.push(&self.segments[idx]);
591            }
592        }
593        out
594    }
595
596    fn element_trail_color(&self) -> [f32; 4] {
597        match self.profile.element {
598            Some(Element::Fire)      => [1.0, 0.5, 0.1, 1.0],
599            Some(Element::Ice)       => [0.5, 0.85, 1.0, 1.0],
600            Some(Element::Lightning) => [1.0, 0.95, 0.3, 1.0],
601            Some(Element::Void)      => [0.3, 0.0, 0.5, 1.0],
602            Some(Element::Entropy)   => [0.7, 0.2, 0.9, 1.0],
603            Some(Element::Gravity)   => [0.3, 0.3, 0.7, 1.0],
604            Some(Element::Radiant)   => [1.0, 1.0, 0.8, 1.0],
605            Some(Element::Shadow)    => [0.15, 0.05, 0.25, 1.0],
606            Some(Element::Temporal)  => [0.4, 0.9, 0.7, 1.0],
607            Some(Element::Physical) | None => [0.9, 0.9, 0.95, 1.0],
608        }
609    }
610}
611
612// ============================================================================
613// TrailRibbon — convert trail segments to a triangle-strip mesh
614// ============================================================================
615
616/// Converts a set of trail segments into a triangle-strip mesh suitable for
617/// GPU rendering. Each segment produces two vertices (centre +/- width *
618/// perpendicular direction). Colour fades with age; emission increases near
619/// impact points.
620pub struct TrailRibbon;
621
622impl TrailRibbon {
623    /// Build trail vertices from the weapon trail state.
624    pub fn build(trail: &WeaponTrail) -> Vec<TrailVertex> {
625        let segments = trail.ordered_segments();
626        let seg_count = segments.len();
627        if seg_count < 2 {
628            return Vec::new();
629        }
630
631        let mut vertices = Vec::with_capacity(seg_count * 2);
632
633        for i in 0..seg_count {
634            let seg = &segments[i];
635            let alpha = seg.alpha(trail.max_segment_age);
636            let mut color = seg.color;
637            color[3] *= alpha;
638
639            // Compute perpendicular direction
640            let forward = if i + 1 < seg_count {
641                (segments[i + 1].position - seg.position).normalize_or_zero()
642            } else if i > 0 {
643                (seg.position - segments[i - 1].position).normalize_or_zero()
644            } else {
645                Vec3::X
646            };
647
648            let up = Vec3::Y;
649            let perp = forward.cross(up).normalize_or_zero();
650            let half_w = seg.width * 0.5;
651
652            let uv_v = if seg_count > 1 { i as f32 / (seg_count - 1) as f32 } else { 0.0 };
653
654            vertices.push(TrailVertex {
655                position: seg.position + perp * half_w,
656                color,
657                emission: seg.emission,
658                uv: Vec2::new(0.0, uv_v),
659            });
660            vertices.push(TrailVertex {
661                position: seg.position - perp * half_w,
662                color,
663                emission: seg.emission,
664                uv: Vec2::new(1.0, uv_v),
665            });
666        }
667
668        vertices
669    }
670
671    /// Build index buffer for the triangle strip (pairs of triangles for each
672    /// quad between consecutive segment pairs).
673    pub fn build_indices(vertex_count: usize) -> Vec<u32> {
674        if vertex_count < 4 { return Vec::new(); }
675        let quad_count = vertex_count / 2 - 1;
676        let mut indices = Vec::with_capacity(quad_count * 6);
677        for q in 0..quad_count {
678            let base = (q * 2) as u32;
679            // Triangle 1
680            indices.push(base);
681            indices.push(base + 1);
682            indices.push(base + 2);
683            // Triangle 2
684            indices.push(base + 1);
685            indices.push(base + 3);
686            indices.push(base + 2);
687        }
688        indices
689    }
690}
691
692// ============================================================================
693// ElementEffect — per-element impact visual descriptions
694// ============================================================================
695
696/// Describes the visual effect played when a weapon with a given element
697/// strikes an entity.
698#[derive(Debug, Clone)]
699pub struct ElementEffect {
700    /// Human-readable label for the effect.
701    pub name: &'static str,
702    /// Number of particles to spawn.
703    pub particle_count: usize,
704    /// Base colour of the effect.
705    pub color: [f32; 4],
706    /// Emission multiplier.
707    pub emission: f32,
708    /// Radius of the effect.
709    pub radius: f32,
710    /// Duration of the effect in seconds.
711    pub duration: f32,
712    /// Whether the effect chains / spreads to nearby targets.
713    pub chains: bool,
714    /// Number of chain targets.
715    pub chain_count: usize,
716    /// Maximum chain range.
717    pub chain_range: f32,
718}
719
720impl ElementEffect {
721    /// Get the canonical impact effect for the given element.
722    pub fn for_element(element: Element) -> Self {
723        match element {
724            Element::Fire => Self {
725                name: "Ember Burst",
726                particle_count: 30,
727                color: [1.0, 0.4, 0.1, 1.0],
728                emission: 3.0,
729                radius: 1.5,
730                duration: 0.8,
731                chains: false,
732                chain_count: 0,
733                chain_range: 0.0,
734            },
735            Element::Ice => Self {
736                name: "Crystal Shatter",
737                particle_count: 20,
738                color: [0.5, 0.85, 1.0, 1.0],
739                emission: 2.0,
740                radius: 1.2,
741                duration: 1.0,
742                chains: false,
743                chain_count: 0,
744                chain_range: 0.0,
745            },
746            Element::Lightning => Self {
747                name: "Arc Chain",
748                particle_count: 15,
749                color: [1.0, 0.95, 0.2, 1.0],
750                emission: 5.0,
751                radius: 0.5,
752                duration: 0.3,
753                chains: true,
754                chain_count: 3,
755                chain_range: 5.0,
756            },
757            Element::Void => Self {
758                name: "Void Collapse",
759                particle_count: 25,
760                color: [0.2, 0.0, 0.4, 1.0],
761                emission: 2.5,
762                radius: 2.0,
763                duration: 1.2,
764                chains: false,
765                chain_count: 0,
766                chain_range: 0.0,
767            },
768            Element::Entropy => Self {
769                name: "Chaos Splatter",
770                particle_count: 40,
771                color: [0.6, 0.1, 0.8, 1.0],
772                emission: 4.0,
773                radius: 2.5,
774                duration: 1.5,
775                chains: false,
776                chain_count: 0,
777                chain_range: 0.0,
778            },
779            Element::Gravity => Self {
780                name: "Gravity Pulse",
781                particle_count: 18,
782                color: [0.3, 0.3, 0.6, 1.0],
783                emission: 2.0,
784                radius: 3.0,
785                duration: 0.6,
786                chains: false,
787                chain_count: 0,
788                chain_range: 0.0,
789            },
790            Element::Radiant => Self {
791                name: "Radiant Burst",
792                particle_count: 35,
793                color: [1.0, 1.0, 0.7, 1.0],
794                emission: 6.0,
795                radius: 2.0,
796                duration: 0.5,
797                chains: false,
798                chain_count: 0,
799                chain_range: 0.0,
800            },
801            Element::Shadow => Self {
802                name: "Shadow Tendrils",
803                particle_count: 22,
804                color: [0.1, 0.05, 0.2, 1.0],
805                emission: 1.5,
806                radius: 2.5,
807                duration: 1.8,
808                chains: true,
809                chain_count: 2,
810                chain_range: 3.0,
811            },
812            Element::Temporal => Self {
813                name: "Time Fracture",
814                particle_count: 16,
815                color: [0.4, 0.9, 0.7, 1.0],
816                emission: 3.5,
817                radius: 1.8,
818                duration: 2.0,
819                chains: false,
820                chain_count: 0,
821                chain_range: 0.0,
822            },
823            Element::Physical => Self {
824                name: "Impact Spark",
825                particle_count: 12,
826                color: [0.85, 0.8, 0.75, 1.0],
827                emission: 1.0,
828                radius: 0.8,
829                duration: 0.4,
830                chains: false,
831                chain_count: 0,
832                chain_range: 0.0,
833            },
834        }
835    }
836}
837
838// ============================================================================
839// CameraShake
840// ============================================================================
841
842/// Screen camera shake triggered by weapon impacts.
843#[derive(Debug, Clone)]
844pub struct CameraShake {
845    /// Remaining duration in seconds.
846    pub duration: f32,
847    /// Current intensity (decays over time).
848    pub intensity: f32,
849    /// Maximum intensity at spawn.
850    pub max_intensity: f32,
851    /// Current shake offset to apply to the camera.
852    pub offset: Vec3,
853    /// Frequency of the shake oscillation.
854    pub frequency: f32,
855    /// Elapsed time since shake began.
856    pub elapsed: f32,
857}
858
859impl CameraShake {
860    /// Create a shake proportional to weapon mass and velocity magnitude.
861    pub fn from_impact(mass: f32, velocity_magnitude: f32) -> Self {
862        let intensity = (mass * velocity_magnitude * 0.01).clamp(0.01, 2.0);
863        let duration = (intensity * 0.3).clamp(0.1, 0.8);
864        Self {
865            duration,
866            intensity,
867            max_intensity: intensity,
868            offset: Vec3::ZERO,
869            frequency: 25.0,
870            elapsed: 0.0,
871        }
872    }
873
874    /// Advance the shake and return the current offset.
875    pub fn update(&mut self, dt: f32) -> Vec3 {
876        if self.duration <= 0.0 {
877            self.offset = Vec3::ZERO;
878            return Vec3::ZERO;
879        }
880        self.elapsed += dt;
881        self.duration -= dt;
882        let decay = (self.duration / (self.max_intensity * 0.3).max(0.01)).clamp(0.0, 1.0);
883        let phase = self.elapsed * self.frequency;
884        self.offset = Vec3::new(
885            phase.sin() * self.intensity * decay,
886            (phase * 1.3).cos() * self.intensity * decay * 0.7,
887            (phase * 0.7).sin() * self.intensity * decay * 0.3,
888        );
889        self.offset
890    }
891
892    /// Whether the shake has finished.
893    pub fn finished(&self) -> bool {
894        self.duration <= 0.0
895    }
896}
897
898// ============================================================================
899// DebrisGlyph — flying glyph debris on hit
900// ============================================================================
901
902/// A small glyph fragment that flies off when an entity is struck.
903#[derive(Debug, Clone)]
904pub struct DebrisGlyph {
905    /// The character rendered for this debris piece.
906    pub glyph: char,
907    /// World-space position.
908    pub position: Vec3,
909    /// World-space velocity.
910    pub velocity: Vec3,
911    /// Spin rate in radians/s.
912    pub spin: f32,
913    /// Current rotation angle.
914    pub rotation: f32,
915    /// Scale factor (shrinks over lifetime).
916    pub scale: f32,
917    /// RGBA colour.
918    pub color: [f32; 4],
919    /// Remaining lifetime in seconds.
920    pub lifetime: f32,
921    /// Maximum lifetime.
922    pub max_lifetime: f32,
923}
924
925impl DebrisGlyph {
926    /// Create debris flying outward from a contact point.
927    pub fn spawn(contact: Vec3, direction: Vec3, glyph: char, color: [f32; 4]) -> Self {
928        let speed = 3.0 + pseudo_random_from_pos(contact) * 4.0;
929        let spread = Vec3::new(
930            pseudo_random_component(contact.x),
931            pseudo_random_component(contact.y).abs() * 0.5 + 0.5,
932            pseudo_random_component(contact.z),
933        );
934        let vel = (direction.normalize_or_zero() + spread).normalize_or_zero() * speed;
935        let lifetime = 0.5 + pseudo_random_from_pos(contact) * 0.8;
936        Self {
937            glyph,
938            position: contact,
939            velocity: vel,
940            spin: (pseudo_random_component(contact.x + contact.z) * 10.0),
941            rotation: 0.0,
942            scale: 0.8 + pseudo_random_from_pos(contact) * 0.4,
943            color,
944            lifetime,
945            max_lifetime: lifetime,
946        }
947    }
948
949    /// Advance the debris physics.
950    pub fn update(&mut self, dt: f32) {
951        self.lifetime -= dt;
952        self.position += self.velocity * dt;
953        self.velocity.y -= GRAVITY * dt;
954        self.velocity *= (1.0 - 1.5 * dt).max(0.0);
955        self.rotation += self.spin * dt;
956        let age_ratio = 1.0 - (self.lifetime / self.max_lifetime).clamp(0.0, 1.0);
957        self.scale *= (1.0 - age_ratio * 0.3).max(0.1);
958        self.color[3] = (self.lifetime / self.max_lifetime).clamp(0.0, 1.0);
959    }
960
961    /// Whether this debris has expired.
962    pub fn dead(&self) -> bool {
963        self.lifetime <= 0.0
964    }
965}
966
967/// Spawn a batch of debris glyphs from a hit.
968pub fn spawn_debris(
969    contact: Vec3,
970    direction: Vec3,
971    color: [f32; 4],
972    count: usize,
973) -> Vec<DebrisGlyph> {
974    let glyphs = ['*', '+', '#', '~', '^', '%', '!', '?', '@', '&'];
975    let actual_count = count.clamp(DEFAULT_DEBRIS_COUNT_MIN, DEFAULT_DEBRIS_COUNT_MAX);
976    (0..actual_count)
977        .map(|i| {
978            let offset = Vec3::new(i as f32 * 0.1, i as f32 * 0.05, -(i as f32) * 0.08);
979            let g = glyphs[i % glyphs.len()];
980            DebrisGlyph::spawn(contact + offset * 0.1, direction, g, color)
981        })
982        .collect()
983}
984
985// ============================================================================
986// ShockwaveRing — screen-space distortion at impact
987// ============================================================================
988
989/// A screen-space distortion ring (shockwave) expanding outward from the
990/// impact point.
991#[derive(Debug, Clone)]
992pub struct ShockwaveRing {
993    /// Screen-space centre (normalised [0,1]).
994    pub center: Vec2,
995    /// Current ring radius (normalised screen units).
996    pub radius: f32,
997    /// Expansion speed (units/s).
998    pub speed: f32,
999    /// Ring thickness.
1000    pub thickness: f32,
1001    /// Distortion magnitude.
1002    pub distortion: f32,
1003    /// Remaining lifetime.
1004    pub lifetime: f32,
1005    /// Max lifetime.
1006    pub max_lifetime: f32,
1007}
1008
1009impl ShockwaveRing {
1010    /// Create a new shockwave at screen-space position.
1011    pub fn new(center: Vec2, intensity: f32) -> Self {
1012        Self {
1013            center,
1014            radius: 0.0,
1015            speed: 0.8 + intensity * 0.4,
1016            thickness: 0.02 + intensity * 0.01,
1017            distortion: 0.03 * intensity,
1018            lifetime: 0.4 + intensity * 0.2,
1019            max_lifetime: 0.4 + intensity * 0.2,
1020        }
1021    }
1022
1023    /// Advance the ring.
1024    pub fn update(&mut self, dt: f32) {
1025        self.lifetime -= dt;
1026        self.radius += self.speed * dt;
1027        let decay = (self.lifetime / self.max_lifetime).clamp(0.0, 1.0);
1028        self.distortion *= decay;
1029        self.thickness *= decay;
1030    }
1031
1032    /// Whether the ring has expired.
1033    pub fn finished(&self) -> bool {
1034        self.lifetime <= 0.0
1035    }
1036}
1037
1038// ============================================================================
1039// DamageNumber — physics-based popup
1040// ============================================================================
1041
1042/// A floating damage number with physics-based arc movement.
1043#[derive(Debug, Clone)]
1044pub struct DamageNumber {
1045    /// The damage value to display.
1046    pub value: i32,
1047    /// World-space position.
1048    pub position: Vec3,
1049    /// Current velocity (upward + slight horizontal drift).
1050    pub velocity: Vec3,
1051    /// Display scale (crits are larger).
1052    pub scale: f32,
1053    /// RGBA colour.
1054    pub color: [f32; 4],
1055    /// Remaining lifetime in seconds.
1056    pub lifetime: f32,
1057    /// Maximum lifetime.
1058    pub max_lifetime: f32,
1059    /// Whether this was a critical hit.
1060    pub crit: bool,
1061    /// Element (for colour).
1062    pub element: Option<Element>,
1063}
1064
1065impl DamageNumber {
1066    /// Spawn a new damage number at the given position.
1067    pub fn new(value: i32, position: Vec3, crit: bool, element: Option<Element>) -> Self {
1068        let base_scale = if crit { 1.8 } else { 1.0 };
1069        let lifetime = if crit { 1.5 } else { 1.0 };
1070        let rand_x = pseudo_random_component(position.x) * 1.5;
1071        let rand_z = pseudo_random_component(position.z) * 1.5;
1072        let upward_speed = if crit { 5.0 } else { 3.0 };
1073
1074        let color = match element {
1075            Some(el) => {
1076                let c = el.color();
1077                [c.x, c.y, c.z, 1.0]
1078            }
1079            None => {
1080                if crit {
1081                    [1.0, 0.9, 0.1, 1.0] // Gold for crits
1082                } else {
1083                    [1.0, 1.0, 1.0, 1.0] // White
1084                }
1085            }
1086        };
1087
1088        Self {
1089            value,
1090            position,
1091            velocity: Vec3::new(rand_x, upward_speed, rand_z),
1092            scale: base_scale,
1093            color,
1094            lifetime,
1095            max_lifetime: lifetime,
1096            crit,
1097            element,
1098        }
1099    }
1100
1101    /// Advance the damage number physics.
1102    pub fn update(&mut self, dt: f32) {
1103        self.lifetime -= dt;
1104        self.position += self.velocity * dt;
1105        // Gravity pulls it down slightly for an arc
1106        self.velocity.y -= GRAVITY * 0.4 * dt;
1107        // Dampen horizontal drift
1108        self.velocity.x *= (1.0 - 2.0 * dt).max(0.0);
1109        self.velocity.z *= (1.0 - 2.0 * dt).max(0.0);
1110        // Fade out
1111        let age_ratio = (self.lifetime / self.max_lifetime).clamp(0.0, 1.0);
1112        self.color[3] = age_ratio;
1113        // Crit numbers have a dramatic scale pulse
1114        if self.crit {
1115            let life_pct = 1.0 - age_ratio;
1116            if life_pct < 0.15 {
1117                // Quick scale-up at the start
1118                self.scale = 1.8 + (life_pct / 0.15) * 0.5;
1119            } else {
1120                self.scale = 2.3 * age_ratio;
1121            }
1122        } else {
1123            self.scale = 1.0 * age_ratio.max(0.3);
1124        }
1125    }
1126
1127    /// Whether this number has expired.
1128    pub fn dead(&self) -> bool {
1129        self.lifetime <= 0.0
1130    }
1131}
1132
1133// ============================================================================
1134// DamageNumberManager — pooled damage number system
1135// ============================================================================
1136
1137/// Object pool of `DamageNumber` instances. Re-uses slots to avoid allocation.
1138#[derive(Debug, Clone)]
1139pub struct DamageNumberManager {
1140    /// Pool of damage numbers.
1141    numbers: Vec<Option<DamageNumber>>,
1142    /// Maximum pool size.
1143    capacity: usize,
1144}
1145
1146impl DamageNumberManager {
1147    /// Create a pool with the given capacity (default 50).
1148    pub fn new(capacity: usize) -> Self {
1149        Self {
1150            numbers: vec![None; capacity],
1151            capacity,
1152        }
1153    }
1154
1155    /// Spawn a damage number. If the pool is full, the oldest number is
1156    /// replaced.
1157    pub fn spawn(&mut self, value: i32, position: Vec3, crit: bool, element: Option<Element>) {
1158        let dmg = DamageNumber::new(value, position, crit, element);
1159        // Try to find an empty slot
1160        for slot in self.numbers.iter_mut() {
1161            if slot.is_none() {
1162                *slot = Some(dmg);
1163                return;
1164            }
1165        }
1166        // If all slots are occupied, replace the one with the least lifetime remaining
1167        let mut min_life = f32::MAX;
1168        let mut min_idx = 0;
1169        for (i, slot) in self.numbers.iter().enumerate() {
1170            if let Some(ref n) = slot {
1171                if n.lifetime < min_life {
1172                    min_life = n.lifetime;
1173                    min_idx = i;
1174                }
1175            }
1176        }
1177        self.numbers[min_idx] = Some(dmg);
1178    }
1179
1180    /// Update all active damage numbers.
1181    pub fn update(&mut self, dt: f32) {
1182        for slot in self.numbers.iter_mut() {
1183            if let Some(ref mut n) = slot {
1184                n.update(dt);
1185                if n.dead() {
1186                    *slot = None;
1187                }
1188            }
1189        }
1190    }
1191
1192    /// Return references to all active damage numbers.
1193    pub fn active(&self) -> Vec<&DamageNumber> {
1194        self.numbers.iter().filter_map(|s| s.as_ref()).collect()
1195    }
1196
1197    /// Current count of active numbers.
1198    pub fn active_count(&self) -> usize {
1199        self.numbers.iter().filter(|s| s.is_some()).count()
1200    }
1201}
1202
1203impl Default for DamageNumberManager {
1204    fn default() -> Self {
1205        Self::new(MAX_DAMAGE_NUMBERS)
1206    }
1207}
1208
1209// ============================================================================
1210// ImpactEffect — orchestrates all impact visuals
1211// ============================================================================
1212
1213/// Full description of an impact event, combining camera shake, debris,
1214/// trail compression, damage number, shockwave, and element-specific effects.
1215#[derive(Debug, Clone)]
1216pub struct ImpactEffect {
1217    /// Camera shake to apply.
1218    pub camera_shake: CameraShake,
1219    /// Debris glyphs spawned.
1220    pub debris: Vec<DebrisGlyph>,
1221    /// Screen-space shockwave ring.
1222    pub shockwave: ShockwaveRing,
1223    /// Damage number to display.
1224    pub damage_number: DamageNumber,
1225    /// Element-specific visual effect (if weapon has an element).
1226    pub element_effect: Option<ElementEffect>,
1227    /// Contact point in world space.
1228    pub contact_point: Vec3,
1229    /// Whether this impact has been fully consumed by the renderer.
1230    pub consumed: bool,
1231}
1232
1233impl ImpactEffect {
1234    /// Generate a complete impact from the weapon striking at a contact point.
1235    pub fn generate(
1236        weapon: &WeaponProfile,
1237        contact_point: Vec3,
1238        velocity_magnitude: f32,
1239        damage: i32,
1240        crit: bool,
1241        screen_pos: Vec2,
1242        hit_direction: Vec3,
1243    ) -> Self {
1244        let mass = weapon.mass;
1245
1246        // Camera shake
1247        let camera_shake = CameraShake::from_impact(mass, velocity_magnitude);
1248
1249        // Debris (5-10 glyphs)
1250        let debris_count = (5.0 + mass * 1.5).min(10.0) as usize;
1251        let debris_color = match weapon.element {
1252            Some(el) => {
1253                let c = el.color();
1254                [c.x, c.y, c.z, 1.0]
1255            }
1256            None => [0.85, 0.8, 0.75, 1.0],
1257        };
1258        let debris = spawn_debris(contact_point, hit_direction, debris_color, debris_count);
1259
1260        // Shockwave ring
1261        let shock_intensity = (mass * velocity_magnitude * 0.005).clamp(0.5, 2.0);
1262        let shockwave = ShockwaveRing::new(screen_pos, shock_intensity);
1263
1264        // Damage number
1265        let damage_number = DamageNumber::new(damage, contact_point + Vec3::Y * 0.5, crit, weapon.element);
1266
1267        // Element effect
1268        let element_effect = weapon.element.map(ElementEffect::for_element);
1269
1270        Self {
1271            camera_shake,
1272            debris,
1273            shockwave,
1274            damage_number,
1275            element_effect,
1276            contact_point,
1277            consumed: false,
1278        }
1279    }
1280
1281    /// Advance all sub-effects by `dt`.
1282    pub fn update(&mut self, dt: f32) {
1283        self.camera_shake.update(dt);
1284        for d in self.debris.iter_mut() {
1285            d.update(dt);
1286        }
1287        self.debris.retain(|d| !d.dead());
1288        self.shockwave.update(dt);
1289        self.damage_number.update(dt);
1290
1291        // Mark consumed when everything is done
1292        if self.camera_shake.finished()
1293            && self.debris.is_empty()
1294            && self.shockwave.finished()
1295            && self.damage_number.dead()
1296        {
1297            self.consumed = true;
1298        }
1299    }
1300}
1301
1302// ============================================================================
1303// ComboTrailIntegration — combo counter affects trail visuals
1304// ============================================================================
1305
1306/// Computes trail visual modifiers based on the current combo count.
1307#[derive(Debug, Clone)]
1308pub struct ComboTrailIntegration {
1309    /// Current combo count.
1310    pub combo_count: u32,
1311    /// Trail width multiplier.
1312    pub width_multiplier: f32,
1313    /// Trail emission multiplier.
1314    pub emission_multiplier: f32,
1315    /// Trail intensity multiplier.
1316    pub intensity_multiplier: f32,
1317    /// Whether a milestone effect should fire.
1318    pub milestone_pending: bool,
1319    /// The milestone tier that was just reached (10, 25, 50, 100).
1320    pub milestone_tier: u32,
1321}
1322
1323impl ComboTrailIntegration {
1324    pub fn new() -> Self {
1325        Self {
1326            combo_count: 0,
1327            width_multiplier: 1.0,
1328            emission_multiplier: 1.0,
1329            intensity_multiplier: 1.0,
1330            milestone_pending: false,
1331            milestone_tier: 0,
1332        }
1333    }
1334
1335    /// Update the combo count and recompute modifiers.
1336    pub fn set_combo(&mut self, count: u32) {
1337        let prev = self.combo_count;
1338        self.combo_count = count;
1339
1340        // Scale modifiers with combo count (log scale for diminishing returns)
1341        let factor = 1.0 + (count as f32).ln().max(0.0) * 0.3;
1342        self.width_multiplier = factor.min(3.0);
1343        self.emission_multiplier = factor.min(4.0);
1344        self.intensity_multiplier = factor.min(5.0);
1345
1346        // Check milestones
1347        self.milestone_pending = false;
1348        for &milestone in &[10u32, 25, 50, 100] {
1349            if prev < milestone && count >= milestone {
1350                self.milestone_pending = true;
1351                self.milestone_tier = milestone;
1352            }
1353        }
1354    }
1355
1356    /// Consume and return the pending milestone tier, if any.
1357    pub fn take_milestone(&mut self) -> Option<u32> {
1358        if self.milestone_pending {
1359            self.milestone_pending = false;
1360            Some(self.milestone_tier)
1361        } else {
1362            None
1363        }
1364    }
1365
1366    /// Apply modifiers to a weapon trail.
1367    pub fn apply_to_trail(&self, trail: &mut WeaponTrail) {
1368        trail.set_combo_intensity(self.intensity_multiplier);
1369    }
1370}
1371
1372impl Default for ComboTrailIntegration {
1373    fn default() -> Self {
1374        Self::new()
1375    }
1376}
1377
1378// ============================================================================
1379// ComboMilestoneEffect
1380// ============================================================================
1381
1382/// Describes the special visual effect triggered at a combo milestone.
1383#[derive(Debug, Clone)]
1384pub struct ComboMilestoneEffect {
1385    /// The milestone tier.
1386    pub tier: u32,
1387    /// Particle burst count (scales with tier).
1388    pub particle_count: usize,
1389    /// Colour of the burst.
1390    pub color: [f32; 4],
1391    /// Emission strength.
1392    pub emission: f32,
1393    /// Shockwave radius.
1394    pub shockwave_radius: f32,
1395    /// Screen flash intensity.
1396    pub flash_intensity: f32,
1397    /// Duration of the effect.
1398    pub duration: f32,
1399    /// Remaining time.
1400    pub remaining: f32,
1401}
1402
1403impl ComboMilestoneEffect {
1404    /// Create a milestone effect for the given tier.
1405    pub fn for_tier(tier: u32) -> Self {
1406        let scale = match tier {
1407            10  => 1.0,
1408            25  => 1.8,
1409            50  => 3.0,
1410            100 => 5.0,
1411            _   => 1.0,
1412        };
1413        let duration = 0.5 + scale * 0.2;
1414        Self {
1415            tier,
1416            particle_count: (20.0 * scale) as usize,
1417            color: match tier {
1418                10  => [1.0, 0.8, 0.2, 1.0],  // Gold
1419                25  => [0.2, 0.8, 1.0, 1.0],  // Cyan
1420                50  => [1.0, 0.3, 0.8, 1.0],  // Magenta
1421                100 => [1.0, 1.0, 1.0, 1.0],  // White (all elements)
1422                _   => [1.0, 1.0, 1.0, 1.0],
1423            },
1424            emission: 3.0 * scale,
1425            shockwave_radius: 0.5 * scale,
1426            flash_intensity: 0.3 * scale,
1427            duration,
1428            remaining: duration,
1429        }
1430    }
1431
1432    /// Advance the effect.
1433    pub fn update(&mut self, dt: f32) {
1434        self.remaining -= dt;
1435    }
1436
1437    /// Whether the effect has finished.
1438    pub fn finished(&self) -> bool {
1439        self.remaining <= 0.0
1440    }
1441}
1442
1443// ============================================================================
1444// BlockEffect
1445// ============================================================================
1446
1447/// Visual and physical feedback when an attack is blocked.
1448#[derive(Debug, Clone)]
1449pub struct BlockEffect {
1450    /// Contact point where weapons met.
1451    pub contact_point: Vec3,
1452    /// Spark particles.
1453    pub sparks: Vec<SparkParticle>,
1454    /// Direction the defender is pushed back.
1455    pub pushback_direction: Vec3,
1456    /// Pushback force magnitude.
1457    pub pushback_force: f32,
1458    /// Whether the attacker's trail should reverse.
1459    pub trail_bounce: bool,
1460    /// Remaining duration of the effect.
1461    pub duration: f32,
1462    /// Max duration.
1463    pub max_duration: f32,
1464}
1465
1466/// A single spark particle emitted on block.
1467#[derive(Debug, Clone)]
1468pub struct SparkParticle {
1469    pub position: Vec3,
1470    pub velocity: Vec3,
1471    pub color: [f32; 4],
1472    pub lifetime: f32,
1473    pub max_lifetime: f32,
1474    pub size: f32,
1475}
1476
1477impl SparkParticle {
1478    pub fn spawn(origin: Vec3, index: usize) -> Self {
1479        let angle = (index as f32) * 0.7;
1480        let speed = 4.0 + (index as f32) * 0.5;
1481        Self {
1482            position: origin,
1483            velocity: Vec3::new(
1484                angle.cos() * speed,
1485                2.0 + (index as f32 % 3.0) * 1.5,
1486                angle.sin() * speed,
1487            ),
1488            color: [1.0, 0.9, 0.3, 1.0],
1489            lifetime: 0.3 + (index as f32) * 0.02,
1490            max_lifetime: 0.3 + (index as f32) * 0.02,
1491            size: 0.05 + (index as f32) * 0.005,
1492        }
1493    }
1494
1495    pub fn update(&mut self, dt: f32) {
1496        self.lifetime -= dt;
1497        self.position += self.velocity * dt;
1498        self.velocity.y -= GRAVITY * dt;
1499        self.velocity *= (1.0 - 4.0 * dt).max(0.0);
1500        self.color[3] = (self.lifetime / self.max_lifetime).clamp(0.0, 1.0);
1501        self.size *= (1.0 - 2.0 * dt).max(0.01);
1502    }
1503
1504    pub fn dead(&self) -> bool {
1505        self.lifetime <= 0.0
1506    }
1507}
1508
1509impl BlockEffect {
1510    /// Create a block effect from an attack hitting a defender's guard.
1511    pub fn generate(
1512        contact_point: Vec3,
1513        attacker_direction: Vec3,
1514        weapon_mass: f32,
1515        velocity_magnitude: f32,
1516    ) -> Self {
1517        let spark_count = (8.0 + weapon_mass * 2.0).min(20.0) as usize;
1518        let sparks: Vec<SparkParticle> = (0..spark_count)
1519            .map(|i| SparkParticle::spawn(contact_point, i))
1520            .collect();
1521        let pushback = -attacker_direction.normalize_or_zero();
1522        let force = (weapon_mass * velocity_magnitude * 0.05).clamp(0.5, 5.0);
1523        let dur = 0.3 + force * 0.05;
1524        Self {
1525            contact_point,
1526            sparks,
1527            pushback_direction: pushback,
1528            pushback_force: force,
1529            trail_bounce: true,
1530            duration: dur,
1531            max_duration: dur,
1532        }
1533    }
1534
1535    /// Advance the block effect.
1536    pub fn update(&mut self, dt: f32) {
1537        self.duration -= dt;
1538        for s in self.sparks.iter_mut() {
1539            s.update(dt);
1540        }
1541        self.sparks.retain(|s| !s.dead());
1542        // Decay pushback over time
1543        let decay = (self.duration / self.max_duration).clamp(0.0, 1.0);
1544        self.pushback_force *= decay;
1545    }
1546
1547    /// Whether the block effect has fully expired.
1548    pub fn finished(&self) -> bool {
1549        self.duration <= 0.0 && self.sparks.is_empty()
1550    }
1551}
1552
1553// ============================================================================
1554// ParryEffect
1555// ============================================================================
1556
1557/// Visual and gameplay feedback for a perfect-timing parry.
1558#[derive(Debug, Clone)]
1559pub struct ParryEffect {
1560    /// Contact point in world space.
1561    pub contact_point: Vec3,
1562    /// Current time-scale (starts at `PARRY_TIME_SCALE`, returns to 1.0).
1563    pub time_scale: f32,
1564    /// Duration the slow-motion effect persists.
1565    pub slow_duration: f32,
1566    /// Elapsed time since the parry.
1567    pub elapsed: f32,
1568    /// Flash intensity (starts high, decays quickly).
1569    pub flash_intensity: f32,
1570    /// Whether the attacker should be stunned.
1571    pub attacker_stunned: bool,
1572    /// Duration of the attacker stun.
1573    pub stun_duration: f32,
1574    /// Particle burst spawned at parry.
1575    pub burst_particles: Vec<ParryBurstParticle>,
1576    /// Whether the effect has been fully applied.
1577    pub consumed: bool,
1578}
1579
1580/// A single particle from the parry burst.
1581#[derive(Debug, Clone)]
1582pub struct ParryBurstParticle {
1583    pub position: Vec3,
1584    pub velocity: Vec3,
1585    pub color: [f32; 4],
1586    pub lifetime: f32,
1587    pub max_lifetime: f32,
1588    pub size: f32,
1589    pub emission: f32,
1590}
1591
1592impl ParryBurstParticle {
1593    pub fn spawn(origin: Vec3, index: usize) -> Self {
1594        let angle = (index as f32) * 0.5;
1595        let elevation = ((index as f32) * 0.37).sin() * 0.8;
1596        let speed = 6.0 + (index as f32) * 0.3;
1597        let lifetime = 0.5 + (index as f32) * 0.03;
1598        Self {
1599            position: origin,
1600            velocity: Vec3::new(
1601                angle.cos() * speed,
1602                elevation * speed,
1603                angle.sin() * speed,
1604            ),
1605            color: [1.0, 1.0, 0.9, 1.0],
1606            lifetime,
1607            max_lifetime: lifetime,
1608            size: 0.08,
1609            emission: 5.0,
1610        }
1611    }
1612
1613    pub fn update(&mut self, dt: f32) {
1614        self.lifetime -= dt;
1615        self.position += self.velocity * dt;
1616        self.velocity *= (1.0 - 3.0 * dt).max(0.0);
1617        let age_ratio = (self.lifetime / self.max_lifetime).clamp(0.0, 1.0);
1618        self.color[3] = age_ratio;
1619        self.emission *= age_ratio;
1620        self.size *= (1.0 - 1.5 * dt).max(0.01);
1621    }
1622
1623    pub fn dead(&self) -> bool {
1624        self.lifetime <= 0.0
1625    }
1626}
1627
1628impl ParryEffect {
1629    /// Create a parry effect at the given contact point.
1630    pub fn generate(contact_point: Vec3) -> Self {
1631        let particle_count = 30;
1632        let burst: Vec<ParryBurstParticle> = (0..particle_count)
1633            .map(|i| ParryBurstParticle::spawn(contact_point, i))
1634            .collect();
1635        Self {
1636            contact_point,
1637            time_scale: PARRY_TIME_SCALE,
1638            slow_duration: PARRY_SLOW_DURATION,
1639            elapsed: 0.0,
1640            flash_intensity: 3.0,
1641            attacker_stunned: true,
1642            stun_duration: 1.0,
1643            burst_particles: burst,
1644            consumed: false,
1645        }
1646    }
1647
1648    /// Advance the parry effect. Note: `dt` here is real-time delta, not
1649    /// affected by the slow-mo.
1650    pub fn update(&mut self, dt: f32) {
1651        self.elapsed += dt;
1652
1653        // Flash decays quickly
1654        self.flash_intensity = (self.flash_intensity - dt * 10.0).max(0.0);
1655
1656        // Time scale ramps back to 1.0 after slow_duration
1657        if self.elapsed >= self.slow_duration {
1658            let ramp = ((self.elapsed - self.slow_duration) / 0.3).clamp(0.0, 1.0);
1659            self.time_scale = PARRY_TIME_SCALE + (1.0 - PARRY_TIME_SCALE) * ramp;
1660        }
1661
1662        // Update burst particles (in real-time, not slowed)
1663        for p in self.burst_particles.iter_mut() {
1664            p.update(dt);
1665        }
1666        self.burst_particles.retain(|p| !p.dead());
1667
1668        // Stun countdown
1669        if self.attacker_stunned {
1670            self.stun_duration -= dt;
1671            if self.stun_duration <= 0.0 {
1672                self.attacker_stunned = false;
1673            }
1674        }
1675
1676        // Consumed when all effects are done
1677        if self.time_scale >= 0.99
1678            && self.flash_intensity <= 0.0
1679            && self.burst_particles.is_empty()
1680            && !self.attacker_stunned
1681        {
1682            self.consumed = true;
1683        }
1684    }
1685
1686    /// The current time scale to apply to the game simulation.
1687    pub fn current_time_scale(&self) -> f32 {
1688        self.time_scale
1689    }
1690
1691    /// Whether the parry effect has completed.
1692    pub fn finished(&self) -> bool {
1693        self.consumed
1694    }
1695}
1696
1697// ============================================================================
1698// WeaponPhysicsSystem — top-level orchestrator
1699// ============================================================================
1700
1701/// Top-level system that ties together weapon trails, impact effects, damage
1702/// numbers, and combo integration.
1703#[derive(Debug, Clone)]
1704pub struct WeaponPhysicsSystem {
1705    /// The active weapon trail.
1706    pub trail: WeaponTrail,
1707    /// Active impact effects.
1708    pub impacts: Vec<ImpactEffect>,
1709    /// Damage number manager (pooled).
1710    pub damage_numbers: DamageNumberManager,
1711    /// Combo trail integration.
1712    pub combo_integration: ComboTrailIntegration,
1713    /// Active combo milestone effects.
1714    pub milestone_effects: Vec<ComboMilestoneEffect>,
1715    /// Active block effects.
1716    pub block_effects: Vec<BlockEffect>,
1717    /// Active parry effect (at most one at a time).
1718    pub parry_effect: Option<ParryEffect>,
1719    /// Global time scale (affected by parry slow-mo).
1720    pub time_scale: f32,
1721}
1722
1723impl WeaponPhysicsSystem {
1724    /// Create a new system for the given weapon type.
1725    pub fn new(weapon_type: WeaponType) -> Self {
1726        let profile = WeaponProfiles::get(weapon_type);
1727        Self {
1728            trail: WeaponTrail::new(profile),
1729            impacts: Vec::new(),
1730            damage_numbers: DamageNumberManager::default(),
1731            combo_integration: ComboTrailIntegration::new(),
1732            milestone_effects: Vec::new(),
1733            block_effects: Vec::new(),
1734            parry_effect: None,
1735            time_scale: 1.0,
1736        }
1737    }
1738
1739    /// Switch to a different weapon type, resetting the trail.
1740    pub fn switch_weapon(&mut self, weapon_type: WeaponType) {
1741        let profile = WeaponProfiles::get(weapon_type);
1742        self.trail = WeaponTrail::new(profile);
1743    }
1744
1745    /// Begin a swing with the given arc parameters.
1746    pub fn begin_swing(&mut self, arc: SwingArc) {
1747        self.trail.begin_swing(arc);
1748    }
1749
1750    /// Handle a weapon hitting an entity.
1751    pub fn on_hit(
1752        &mut self,
1753        contact_point: Vec3,
1754        velocity_magnitude: f32,
1755        damage: i32,
1756        crit: bool,
1757        screen_pos: Vec2,
1758        hit_direction: Vec3,
1759    ) {
1760        // Trail compression
1761        self.trail.on_impact(contact_point);
1762
1763        // Generate full impact effect
1764        let impact = ImpactEffect::generate(
1765            &self.trail.profile,
1766            contact_point,
1767            velocity_magnitude,
1768            damage,
1769            crit,
1770            screen_pos,
1771            hit_direction,
1772        );
1773        self.impacts.push(impact);
1774
1775        // Damage number
1776        self.damage_numbers.spawn(damage, contact_point + Vec3::Y * 0.5, crit, self.trail.profile.element);
1777    }
1778
1779    /// Handle an attack being blocked.
1780    pub fn on_block(
1781        &mut self,
1782        contact_point: Vec3,
1783        attacker_direction: Vec3,
1784        velocity_magnitude: f32,
1785    ) {
1786        let block = BlockEffect::generate(
1787            contact_point,
1788            attacker_direction,
1789            self.trail.profile.mass,
1790            velocity_magnitude,
1791        );
1792        self.block_effects.push(block);
1793    }
1794
1795    /// Handle a perfect parry.
1796    pub fn on_parry(&mut self, contact_point: Vec3) {
1797        let parry = ParryEffect::generate(contact_point);
1798        self.parry_effect = Some(parry);
1799    }
1800
1801    /// Update the combo count (from the combo tracker).
1802    pub fn update_combo(&mut self, combo_count: u32) {
1803        self.combo_integration.set_combo(combo_count);
1804        self.combo_integration.apply_to_trail(&mut self.trail);
1805        if let Some(tier) = self.combo_integration.take_milestone() {
1806            self.milestone_effects.push(ComboMilestoneEffect::for_tier(tier));
1807        }
1808    }
1809
1810    /// Tick all systems by `dt` (real-time).
1811    pub fn update(&mut self, dt: f32) {
1812        // Apply parry time scale
1813        self.time_scale = if let Some(ref parry) = self.parry_effect {
1814            parry.current_time_scale()
1815        } else {
1816            1.0
1817        };
1818        let game_dt = dt * self.time_scale;
1819
1820        // Trail
1821        self.trail.update(game_dt);
1822
1823        // Impact effects
1824        for impact in self.impacts.iter_mut() {
1825            impact.update(game_dt);
1826        }
1827        self.impacts.retain(|i| !i.consumed);
1828
1829        // Damage numbers
1830        self.damage_numbers.update(game_dt);
1831
1832        // Milestone effects
1833        for m in self.milestone_effects.iter_mut() {
1834            m.update(game_dt);
1835        }
1836        self.milestone_effects.retain(|m| !m.finished());
1837
1838        // Block effects
1839        for b in self.block_effects.iter_mut() {
1840            b.update(game_dt);
1841        }
1842        self.block_effects.retain(|b| !b.finished());
1843
1844        // Parry effect (uses real dt, not game dt)
1845        if let Some(ref mut parry) = self.parry_effect {
1846            parry.update(dt);
1847            if parry.finished() {
1848                self.parry_effect = None;
1849            }
1850        }
1851    }
1852
1853    /// Get trail render data for the GPU.
1854    pub fn trail_vertices(&self) -> Vec<TrailVertex> {
1855        self.trail.get_render_data()
1856    }
1857}
1858
1859// ============================================================================
1860// Pseudo-random helpers (deterministic, no external crate needed)
1861// ============================================================================
1862
1863/// Deterministic pseudo-random float in [0, 1] from a Vec3 position.
1864fn pseudo_random_from_pos(p: Vec3) -> f32 {
1865    let seed = (p.x * 12.9898 + p.y * 78.233 + p.z * 45.164).sin() * 43758.5453;
1866    seed.fract().abs()
1867}
1868
1869/// Deterministic pseudo-random float in [-1, 1] from a single f32.
1870fn pseudo_random_component(v: f32) -> f32 {
1871    let seed = (v * 127.1 + 311.7).sin() * 43758.5453;
1872    seed.fract() * 2.0 - 1.0
1873}
1874
1875// ============================================================================
1876// Unit Tests
1877// ============================================================================
1878
1879#[cfg(test)]
1880mod tests {
1881    use super::*;
1882    use std::f32::consts::PI;
1883
1884    // ── SwingArc tests ───────────────────────────────────────────────────
1885
1886    #[test]
1887    fn swing_arc_sample_start_and_end() {
1888        let arc = SwingArc::new(0.0, PI, 1.0, Vec3::ZERO, 2.0);
1889        let start = arc.sample(0.0);
1890        let end = arc.sample(1.0);
1891        // At t=0, angle=0 => position = (radius, 0, 0)
1892        assert!((start.x - 2.0).abs() < 0.001);
1893        assert!(start.z.abs() < 0.001);
1894        // At t=1, angle=PI => position = (-radius, 0, ~0)
1895        assert!((end.x + 2.0).abs() < 0.001);
1896        assert!(end.z.abs() < 0.01);
1897    }
1898
1899    #[test]
1900    fn swing_arc_sample_with_origin() {
1901        let origin = Vec3::new(5.0, 0.0, 3.0);
1902        let arc = SwingArc::new(0.0, PI, 1.0, origin, 1.0);
1903        let mid = arc.sample(0.5);
1904        // At t=0.5, angle=PI/2 => x = cos(PI/2)=0, z = sin(PI/2)=1
1905        assert!((mid.x - 5.0).abs() < 0.01);
1906        assert!((mid.z - 4.0).abs() < 0.01);
1907    }
1908
1909    #[test]
1910    fn swing_arc_velocity_direction() {
1911        let arc = SwingArc::new(0.0, PI, 1.0, Vec3::ZERO, 2.0);
1912        let vel = arc.velocity_at(0.0);
1913        // At angle=0 tangent is (−sin(0), 0, cos(0)) * speed = (0, 0, 1)*speed
1914        // angular_vel = PI/1 = PI, speed = 2*PI
1915        assert!(vel.x.abs() < 0.01);
1916        assert!((vel.z - 2.0 * PI).abs() < 0.1);
1917    }
1918
1919    #[test]
1920    fn swing_arc_progress_clamp() {
1921        let mut arc = SwingArc::new(0.0, PI, 1.0, Vec3::ZERO, 1.0);
1922        assert!((arc.progress() - 0.0).abs() < 0.001);
1923        arc.elapsed = 0.5;
1924        assert!((arc.progress() - 0.5).abs() < 0.001);
1925        arc.elapsed = 2.0;
1926        assert!((arc.progress() - 1.0).abs() < 0.001);
1927    }
1928
1929    #[test]
1930    fn swing_arc_tick_finishes() {
1931        let mut arc = SwingArc::new(0.0, PI, 0.5, Vec3::ZERO, 1.0);
1932        assert!(arc.tick(0.3));
1933        assert!(!arc.finished());
1934        assert!(!arc.tick(0.3));
1935        assert!(arc.finished());
1936    }
1937
1938    // ── WeaponTrailSegment tests ─────────────────────────────────────────
1939
1940    #[test]
1941    fn trail_segment_ages() {
1942        let mut seg = WeaponTrailSegment::new(
1943            Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0), 0.1, [1.0; 4], 1.0,
1944        );
1945        assert!((seg.age - 0.0).abs() < 0.001);
1946        seg.update(0.1);
1947        assert!((seg.age - 0.1).abs() < 0.001);
1948        // Position should have moved
1949        assert!(seg.position.x > 0.0);
1950    }
1951
1952    #[test]
1953    fn trail_segment_alpha_fades() {
1954        let mut seg = WeaponTrailSegment::new(
1955            Vec3::ZERO, Vec3::ZERO, 0.1, [1.0; 4], 1.0,
1956        );
1957        assert!((seg.alpha(1.0) - 1.0).abs() < 0.001);
1958        seg.age = 0.5;
1959        assert!((seg.alpha(1.0) - 0.5).abs() < 0.001);
1960        seg.age = 1.0;
1961        assert!((seg.alpha(1.0) - 0.0).abs() < 0.001);
1962    }
1963
1964    #[test]
1965    fn trail_segment_velocity_dampens() {
1966        let mut seg = WeaponTrailSegment::new(
1967            Vec3::ZERO, Vec3::new(10.0, 0.0, 0.0), 0.1, [1.0; 4], 1.0,
1968        );
1969        let initial_speed = seg.velocity.length();
1970        seg.update(0.1);
1971        assert!(seg.velocity.length() < initial_speed);
1972    }
1973
1974    // ── DamageNumber tests ───────────────────────────────────────────────
1975
1976    #[test]
1977    fn damage_number_moves_upward_initially() {
1978        let mut dmg = DamageNumber::new(100, Vec3::ZERO, false, None);
1979        let initial_y = dmg.position.y;
1980        dmg.update(0.05);
1981        assert!(dmg.position.y > initial_y);
1982    }
1983
1984    #[test]
1985    fn damage_number_arcs_back_down() {
1986        let mut dmg = DamageNumber::new(100, Vec3::ZERO, false, None);
1987        // Let it rise
1988        for _ in 0..10 {
1989            dmg.update(0.05);
1990        }
1991        let peak_y = dmg.position.y;
1992        // Continue and it should come back down (gravity)
1993        for _ in 0..30 {
1994            dmg.update(0.05);
1995        }
1996        assert!(dmg.position.y < peak_y);
1997    }
1998
1999    #[test]
2000    fn damage_number_crit_is_larger() {
2001        let normal = DamageNumber::new(100, Vec3::ZERO, false, None);
2002        let crit = DamageNumber::new(100, Vec3::ZERO, true, None);
2003        assert!(crit.scale > normal.scale);
2004    }
2005
2006    #[test]
2007    fn damage_number_fades_alpha() {
2008        let mut dmg = DamageNumber::new(50, Vec3::ZERO, false, None);
2009        assert!((dmg.color[3] - 1.0).abs() < 0.01);
2010        for _ in 0..20 {
2011            dmg.update(0.05);
2012        }
2013        assert!(dmg.color[3] < 1.0);
2014    }
2015
2016    #[test]
2017    fn damage_number_dies_after_lifetime() {
2018        let mut dmg = DamageNumber::new(50, Vec3::ZERO, false, None);
2019        assert!(!dmg.dead());
2020        for _ in 0..100 {
2021            dmg.update(0.05);
2022        }
2023        assert!(dmg.dead());
2024    }
2025
2026    // ── DamageNumberManager tests ────────────────────────────────────────
2027
2028    #[test]
2029    fn damage_manager_spawns_and_updates() {
2030        let mut mgr = DamageNumberManager::new(5);
2031        mgr.spawn(100, Vec3::ZERO, false, None);
2032        mgr.spawn(200, Vec3::ONE, true, Some(Element::Fire));
2033        assert_eq!(mgr.active_count(), 2);
2034        // Update until they expire
2035        for _ in 0..100 {
2036            mgr.update(0.05);
2037        }
2038        assert_eq!(mgr.active_count(), 0);
2039    }
2040
2041    #[test]
2042    fn damage_manager_replaces_oldest_when_full() {
2043        let mut mgr = DamageNumberManager::new(3);
2044        mgr.spawn(1, Vec3::ZERO, false, None);
2045        mgr.spawn(2, Vec3::ZERO, false, None);
2046        mgr.spawn(3, Vec3::ZERO, false, None);
2047        assert_eq!(mgr.active_count(), 3);
2048        // Pool is full, next spawn replaces oldest
2049        mgr.update(0.5); // age them so there's a "least lifetime"
2050        mgr.spawn(4, Vec3::ZERO, false, None);
2051        assert_eq!(mgr.active_count(), 3);
2052    }
2053
2054    // ── WeaponTrail tests ────────────────────────────────────────────────
2055
2056    #[test]
2057    fn trail_spawns_segments_during_swing() {
2058        let profile = WeaponProfiles::get(WeaponType::Sword);
2059        let mut trail = WeaponTrail::new(profile);
2060        let arc = SwingArc::new(0.0, PI, 0.5, Vec3::ZERO, 1.0);
2061        trail.begin_swing(arc);
2062        // Run several update ticks
2063        for _ in 0..20 {
2064            trail.update(0.025);
2065        }
2066        let verts = trail.get_render_data();
2067        // Should have generated some vertices
2068        assert!(!verts.is_empty());
2069    }
2070
2071    #[test]
2072    fn trail_impact_modifies_segments() {
2073        let profile = WeaponProfiles::get(WeaponType::Axe);
2074        let mut trail = WeaponTrail::new(profile);
2075        let arc = SwingArc::new(0.0, PI, 0.5, Vec3::ZERO, 1.0);
2076        trail.begin_swing(arc);
2077        for _ in 0..10 {
2078            trail.update(0.025);
2079        }
2080        trail.on_impact(Vec3::new(0.5, 0.0, 0.5));
2081        // After impact, further updates should not panic
2082        for _ in 0..10 {
2083            trail.update(0.025);
2084        }
2085    }
2086
2087    // ── TrailRibbon tests ────────────────────────────────────────────────
2088
2089    #[test]
2090    fn trail_ribbon_vertex_pairs() {
2091        let profile = WeaponProfiles::get(WeaponType::Sword);
2092        let mut trail = WeaponTrail::new(profile);
2093        let arc = SwingArc::new(0.0, PI, 0.3, Vec3::ZERO, 1.0);
2094        trail.begin_swing(arc);
2095        for _ in 0..15 {
2096            trail.update(0.02);
2097        }
2098        let verts = trail.get_render_data();
2099        // Vertex count should be even (pairs)
2100        assert_eq!(verts.len() % 2, 0);
2101    }
2102
2103    #[test]
2104    fn trail_ribbon_indices_valid() {
2105        let indices = TrailRibbon::build_indices(8);
2106        assert!(!indices.is_empty());
2107        // 4 quads * 6 = should be 3 quads * 6 = 18
2108        assert_eq!(indices.len(), 18);
2109        for &idx in &indices {
2110            assert!(idx < 8);
2111        }
2112    }
2113
2114    // ── CameraShake tests ────────────────────────────────────────────────
2115
2116    #[test]
2117    fn camera_shake_decays() {
2118        let mut shake = CameraShake::from_impact(3.0, 10.0);
2119        assert!(!shake.finished());
2120        let initial_intensity = shake.intensity;
2121        for _ in 0..50 {
2122            shake.update(0.02);
2123        }
2124        assert!(shake.finished() || shake.offset.length() < initial_intensity);
2125    }
2126
2127    #[test]
2128    fn camera_shake_zero_mass() {
2129        let shake = CameraShake::from_impact(0.0, 0.0);
2130        assert!(shake.intensity <= 0.01);
2131    }
2132
2133    // ── ComboTrailIntegration tests ──────────────────────────────────────
2134
2135    #[test]
2136    fn combo_integration_milestones() {
2137        let mut combo = ComboTrailIntegration::new();
2138        combo.set_combo(9);
2139        assert!(!combo.milestone_pending);
2140        combo.set_combo(10);
2141        assert!(combo.milestone_pending);
2142        assert_eq!(combo.milestone_tier, 10);
2143        let tier = combo.take_milestone();
2144        assert_eq!(tier, Some(10));
2145        assert!(!combo.milestone_pending);
2146    }
2147
2148    #[test]
2149    fn combo_integration_scaling() {
2150        let mut combo = ComboTrailIntegration::new();
2151        combo.set_combo(1);
2152        let w1 = combo.width_multiplier;
2153        combo.set_combo(50);
2154        let w50 = combo.width_multiplier;
2155        assert!(w50 > w1);
2156    }
2157
2158    // ── ImpactEffect tests ───────────────────────────────────────────────
2159
2160    #[test]
2161    fn impact_effect_generates_all_components() {
2162        let profile = WeaponProfiles::get(WeaponType::Mace);
2163        let impact = ImpactEffect::generate(
2164            &profile,
2165            Vec3::new(1.0, 0.0, 1.0),
2166            15.0,
2167            250,
2168            true,
2169            Vec2::new(0.5, 0.5),
2170            Vec3::new(1.0, 0.0, 0.0),
2171        );
2172        assert!(!impact.consumed);
2173        assert!(!impact.debris.is_empty());
2174        assert!(impact.damage_number.crit);
2175        assert_eq!(impact.damage_number.value, 250);
2176    }
2177
2178    #[test]
2179    fn impact_effect_eventually_consumed() {
2180        let profile = WeaponProfiles::get(WeaponType::Dagger);
2181        let mut impact = ImpactEffect::generate(
2182            &profile,
2183            Vec3::ZERO,
2184            5.0,
2185            30,
2186            false,
2187            Vec2::new(0.5, 0.5),
2188            Vec3::X,
2189        );
2190        for _ in 0..200 {
2191            impact.update(0.05);
2192        }
2193        assert!(impact.consumed);
2194    }
2195
2196    // ── BlockEffect tests ────────────────────────────────────────────────
2197
2198    #[test]
2199    fn block_effect_pushback_direction() {
2200        let block = BlockEffect::generate(
2201            Vec3::ZERO,
2202            Vec3::new(1.0, 0.0, 0.0),
2203            2.0,
2204            10.0,
2205        );
2206        // Pushback should be opposite to attacker direction
2207        assert!(block.pushback_direction.x < 0.0);
2208    }
2209
2210    #[test]
2211    fn block_effect_finishes() {
2212        let mut block = BlockEffect::generate(
2213            Vec3::ZERO,
2214            Vec3::X,
2215            1.0,
2216            5.0,
2217        );
2218        for _ in 0..100 {
2219            block.update(0.05);
2220        }
2221        assert!(block.finished());
2222    }
2223
2224    // ── ParryEffect tests ────────────────────────────────────────────────
2225
2226    #[test]
2227    fn parry_effect_slows_time() {
2228        let parry = ParryEffect::generate(Vec3::ZERO);
2229        assert!((parry.time_scale - PARRY_TIME_SCALE).abs() < 0.01);
2230        assert!(parry.attacker_stunned);
2231    }
2232
2233    #[test]
2234    fn parry_effect_time_returns_to_normal() {
2235        let mut parry = ParryEffect::generate(Vec3::ZERO);
2236        for _ in 0..200 {
2237            parry.update(0.02);
2238        }
2239        assert!(parry.time_scale > 0.95);
2240    }
2241
2242    #[test]
2243    fn parry_effect_finishes() {
2244        let mut parry = ParryEffect::generate(Vec3::ZERO);
2245        for _ in 0..300 {
2246            parry.update(0.02);
2247        }
2248        assert!(parry.finished());
2249    }
2250
2251    // ── WeaponPhysicsSystem integration tests ────────────────────────────
2252
2253    #[test]
2254    fn system_swing_and_hit() {
2255        let mut sys = WeaponPhysicsSystem::new(WeaponType::Sword);
2256        let arc = SwingArc::new(0.0, PI, 0.3, Vec3::ZERO, 1.0);
2257        sys.begin_swing(arc);
2258        for _ in 0..10 {
2259            sys.update(0.02);
2260        }
2261        sys.on_hit(
2262            Vec3::new(1.0, 0.0, 0.0),
2263            8.0, 100, false,
2264            Vec2::new(0.5, 0.5),
2265            Vec3::X,
2266        );
2267        assert_eq!(sys.impacts.len(), 1);
2268        assert_eq!(sys.damage_numbers.active_count(), 1);
2269    }
2270
2271    #[test]
2272    fn system_combo_milestones() {
2273        let mut sys = WeaponPhysicsSystem::new(WeaponType::Fist);
2274        sys.update_combo(9);
2275        assert!(sys.milestone_effects.is_empty());
2276        sys.update_combo(10);
2277        assert_eq!(sys.milestone_effects.len(), 1);
2278        assert_eq!(sys.milestone_effects[0].tier, 10);
2279    }
2280
2281    #[test]
2282    fn system_parry_slows_game() {
2283        let mut sys = WeaponPhysicsSystem::new(WeaponType::Sword);
2284        sys.on_parry(Vec3::ZERO);
2285        sys.update(0.01);
2286        assert!(sys.time_scale < 1.0);
2287    }
2288
2289    // ── WeaponProfiles tests ─────────────────────────────────────────────
2290
2291    #[test]
2292    fn all_weapon_profiles_valid() {
2293        for &wt in WeaponType::all() {
2294            let p = WeaponProfiles::get(wt);
2295            assert!(p.mass > 0.0, "{:?} mass must be positive", wt);
2296            assert!(p.length > 0.0, "{:?} length must be positive", wt);
2297            assert!(p.swing_speed > 0.0, "{:?} swing_speed must be positive", wt);
2298            assert!(p.impact_force > 0.0, "{:?} impact_force must be positive", wt);
2299            assert!(p.trail_width > 0.0, "{:?} trail_width must be positive", wt);
2300            assert!(p.trail_segments > 0, "{:?} trail_segments must be > 0", wt);
2301        }
2302    }
2303
2304    #[test]
2305    fn sword_is_faster_than_axe() {
2306        let sword = WeaponProfiles::get(WeaponType::Sword);
2307        let axe = WeaponProfiles::get(WeaponType::Axe);
2308        assert!(sword.swing_speed > axe.swing_speed);
2309        assert!(sword.mass < axe.mass);
2310    }
2311
2312    #[test]
2313    fn dagger_is_fastest() {
2314        let dagger = WeaponProfiles::get(WeaponType::Dagger);
2315        for &wt in WeaponType::all() {
2316            if wt == WeaponType::Dagger || wt == WeaponType::Fist {
2317                continue;
2318            }
2319            let p = WeaponProfiles::get(wt);
2320            assert!(
2321                dagger.swing_speed >= p.swing_speed,
2322                "Dagger should be faster than {:?}", wt
2323            );
2324        }
2325    }
2326
2327    #[test]
2328    fn element_effects_for_all_elements() {
2329        let elements = [
2330            Element::Physical, Element::Fire, Element::Ice, Element::Lightning,
2331            Element::Void, Element::Entropy, Element::Gravity, Element::Radiant,
2332            Element::Shadow, Element::Temporal,
2333        ];
2334        for el in &elements {
2335            let eff = ElementEffect::for_element(*el);
2336            assert!(eff.particle_count > 0);
2337            assert!(eff.duration > 0.0);
2338        }
2339    }
2340
2341    #[test]
2342    fn shockwave_ring_expands() {
2343        let mut ring = ShockwaveRing::new(Vec2::new(0.5, 0.5), 1.0);
2344        let r0 = ring.radius;
2345        ring.update(0.1);
2346        assert!(ring.radius > r0);
2347    }
2348
2349    #[test]
2350    fn shockwave_ring_finishes() {
2351        let mut ring = ShockwaveRing::new(Vec2::new(0.5, 0.5), 1.0);
2352        for _ in 0..100 {
2353            ring.update(0.05);
2354        }
2355        assert!(ring.finished());
2356    }
2357}