Skip to main content

proof_engine/game/
debris.rs

1//! Combat debris physics system for Chaos RPG.
2//!
3//! When an entity dies its constituent glyphs become rigid-body debris that
4//! scatter, bounce, settle and fade according to the element of the killing
5//! blow. Each `DebrisType` variant encodes unique physical behaviour (fire
6//! debris floats upward, ice shatters on impact, etc.).
7//!
8//! The system is built around a pre-allocated `DebrisPool` of 500 particles
9//! that are recycled as they expire, a `DebrisSimulator` that steps physics
10//! each frame, and a `DebrisRenderer` that exports live debris as
11//! `GlyphInstance` data for GPU instanced rendering.
12
13use glam::{Vec2, Vec3, Vec4, Quat};
14use crate::glyph::batch::GlyphInstance;
15use crate::entity::AmorphousEntity;
16use crate::procedural::Rng;
17
18// ─── Constants ───────────────────────────────────────────────────────────────
19
20/// Default gravitational acceleration (m/s^2, pointing downward).
21const GRAVITY: Vec3 = Vec3::new(0.0, -9.81, 0.0);
22
23/// Maximum number of debris particles alive at any one time.
24const POOL_CAPACITY: usize = 500;
25
26/// Default arena half-extents (axis-aligned bounding box).
27const DEFAULT_ARENA_HALF: Vec3 = Vec3::new(50.0, 50.0, 50.0);
28
29/// Minimum velocity magnitude below which a grounded particle is considered at
30/// rest and friction zeroes it out.
31const REST_VELOCITY_THRESHOLD: f32 = 0.08;
32
33/// Duration (seconds) over which settled debris fades before being recycled.
34const SETTLE_FADE_DURATION: f32 = 1.0;
35
36/// Duration (seconds) debris lives before it starts fading.
37const SETTLE_ALIVE_MIN: f32 = 2.0;
38const SETTLE_ALIVE_MAX: f32 = 3.0;
39
40/// Small epsilon to avoid floating point issues in collision resolution.
41const COLLISION_EPSILON: f32 = 0.001;
42
43/// Radius used for inter-particle sphere overlap checks.
44const PARTICLE_COLLISION_RADIUS: f32 = 0.15;
45
46/// Buoyancy acceleration magnitude for fire debris.
47const FIRE_BUOYANCY: f32 = 6.0;
48
49/// Drag coefficient for slow-drifting debris types (Poison, Dark).
50const SLOW_DRAG: f32 = 3.0;
51
52/// Minimum number of particles spawned on entity death.
53const SPAWN_MIN: usize = 10;
54
55/// Maximum number of particles spawned on entity death.
56const SPAWN_MAX: usize = 50;
57
58// ─── DebrisType ──────────────────────────────────────────────────────────────
59
60/// Element-specific debris behaviour.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum DebrisType {
63    /// Standard physics — no special modifiers.
64    Normal,
65    /// Floats upward with buoyancy; emits orange glow.
66    Fire,
67    /// Shatters into smaller sub-pieces on first impact.
68    Ice,
69    /// Extreme velocity scatter with electric arc trails.
70    Lightning,
71    /// Slow drift with green mist trail.
72    Poison,
73    /// Floats upward, fades with golden glow.
74    Holy,
75    /// Sinks into the floor, pooling as a dark stain.
76    Dark,
77    /// Drips downward with red tint.
78    Bleed,
79}
80
81impl DebrisType {
82    /// Default restitution coefficient for this debris type.
83    pub fn default_restitution(self) -> f32 {
84        match self {
85            DebrisType::Normal    => 0.4,
86            DebrisType::Fire      => 0.2,
87            DebrisType::Ice       => 0.6,
88            DebrisType::Lightning => 0.7,
89            DebrisType::Poison    => 0.1,
90            DebrisType::Holy      => 0.15,
91            DebrisType::Dark      => 0.05,
92            DebrisType::Bleed     => 0.25,
93        }
94    }
95
96    /// Default friction coefficient for this debris type.
97    pub fn default_friction(self) -> f32 {
98        match self {
99            DebrisType::Normal    => 0.5,
100            DebrisType::Fire      => 0.2,
101            DebrisType::Ice       => 0.15,
102            DebrisType::Lightning => 0.3,
103            DebrisType::Poison    => 0.8,
104            DebrisType::Holy      => 0.1,
105            DebrisType::Dark      => 0.9,
106            DebrisType::Bleed     => 0.6,
107        }
108    }
109
110    /// Base outward velocity multiplier when spawning debris.
111    pub fn velocity_multiplier(self) -> f32 {
112        match self {
113            DebrisType::Normal    => 1.0,
114            DebrisType::Fire      => 0.8,
115            DebrisType::Ice       => 1.2,
116            DebrisType::Lightning => 3.0,
117            DebrisType::Poison    => 0.4,
118            DebrisType::Holy      => 0.6,
119            DebrisType::Dark      => 0.5,
120            DebrisType::Bleed     => 0.7,
121        }
122    }
123
124    /// Whether this type applies buoyancy (upward force).
125    pub fn has_buoyancy(self) -> bool {
126        matches!(self, DebrisType::Fire | DebrisType::Holy)
127    }
128
129    /// Whether this type applies heavy drag (slow drift).
130    pub fn has_heavy_drag(self) -> bool {
131        matches!(self, DebrisType::Poison | DebrisType::Dark)
132    }
133
134    /// Whether this type shatters into sub-pieces on first impact.
135    pub fn shatters_on_impact(self) -> bool {
136        matches!(self, DebrisType::Ice)
137    }
138
139    /// Whether this type sinks below the floor plane.
140    pub fn sinks(self) -> bool {
141        matches!(self, DebrisType::Dark)
142    }
143
144    /// Whether this type drips downward with extra gravity.
145    pub fn drips(self) -> bool {
146        matches!(self, DebrisType::Bleed)
147    }
148}
149
150// ─── DebrisParticle ──────────────────────────────────────────────────────────
151
152/// A single piece of combat debris — a glyph char with rigid-body dynamics.
153#[derive(Clone, Debug)]
154pub struct DebrisParticle {
155    /// The glyph character this debris displays.
156    pub glyph: char,
157
158    /// World position.
159    pub position: Vec3,
160
161    /// Linear velocity (m/s).
162    pub velocity: Vec3,
163
164    /// Angular velocity (rad/s) around each axis.
165    pub angular_velocity: Vec3,
166
167    /// Mass (kg). Affects inter-particle collision response.
168    pub mass: f32,
169
170    /// Coefficient of restitution [0, 1]. 0 = perfectly inelastic, 1 = elastic.
171    pub restitution: f32,
172
173    /// Surface friction coefficient [0, 1].
174    pub friction: f32,
175
176    /// Time this particle has been alive (seconds).
177    pub lifetime: f32,
178
179    /// Maximum time before the particle begins settling/fading.
180    pub max_lifetime: f32,
181
182    /// RGBA colour.
183    pub color: [f32; 4],
184
185    /// Uniform scale factor.
186    pub scale: f32,
187
188    /// Current rotation (Euler-ish, stored as quaternion for blending).
189    pub rotation: Quat,
190
191    /// The elemental type driving behaviour.
192    pub debris_type: DebrisType,
193
194    /// Whether this particle is alive and should be simulated/rendered.
195    pub alive: bool,
196
197    /// Whether this particle has already shattered (Ice only — prevents
198    /// infinite recursion).
199    pub has_shattered: bool,
200
201    /// Emission intensity (glow).
202    pub emission: f32,
203
204    /// Glow colour (RGB).
205    pub glow_color: Vec3,
206
207    /// True when the particle is on the ground and mostly at rest.
208    pub settled: bool,
209
210    /// Time spent in the settled/fading state.
211    pub fade_time: f32,
212}
213
214impl Default for DebrisParticle {
215    fn default() -> Self {
216        Self {
217            glyph: ' ',
218            position: Vec3::ZERO,
219            velocity: Vec3::ZERO,
220            angular_velocity: Vec3::ZERO,
221            mass: 1.0,
222            restitution: 0.4,
223            friction: 0.5,
224            lifetime: 0.0,
225            max_lifetime: 3.0,
226            color: [1.0, 1.0, 1.0, 1.0],
227            scale: 1.0,
228            rotation: Quat::IDENTITY,
229            debris_type: DebrisType::Normal,
230            alive: false,
231            has_shattered: false,
232            emission: 0.0,
233            glow_color: Vec3::ZERO,
234            settled: false,
235            fade_time: 0.0,
236        }
237    }
238}
239
240impl DebrisParticle {
241    /// Create a new debris particle with the given character and type.
242    pub fn new(glyph: char, debris_type: DebrisType) -> Self {
243        Self {
244            glyph,
245            restitution: debris_type.default_restitution(),
246            friction: debris_type.default_friction(),
247            debris_type,
248            alive: true,
249            ..Default::default()
250        }
251    }
252
253    /// Returns the alpha after applying fade.
254    pub fn effective_alpha(&self) -> f32 {
255        if self.settled {
256            let fade_frac = (self.fade_time / SETTLE_FADE_DURATION).clamp(0.0, 1.0);
257            self.color[3] * (1.0 - fade_frac)
258        } else {
259            self.color[3]
260        }
261    }
262
263    /// True when the particle has finished fading and can be recycled.
264    pub fn is_expired(&self) -> bool {
265        !self.alive || (self.settled && self.fade_time >= SETTLE_FADE_DURATION)
266    }
267
268    /// Kill this particle immediately.
269    pub fn kill(&mut self) {
270        self.alive = false;
271    }
272}
273
274// ─── EntityDeathEvent ────────────────────────────────────────────────────────
275
276/// Information about an entity death, used by `DebrisSpawner`.
277#[derive(Clone, Debug)]
278pub struct EntityDeathEvent {
279    /// World position of the dying entity.
280    pub position: Vec3,
281
282    /// The glyphs (characters) that composed the entity.
283    pub glyphs: Vec<char>,
284
285    /// Colours that corresponded to each glyph slot.
286    pub colors: Vec<[f32; 4]>,
287
288    /// The element of the killing blow.
289    pub death_type: DebrisType,
290}
291
292impl EntityDeathEvent {
293    /// Build a death event from an `AmorphousEntity` and the elemental type of
294    /// the killing blow.
295    pub fn from_entity(entity: &AmorphousEntity, death_type: DebrisType) -> Self {
296        let colors: Vec<[f32; 4]> = entity.formation_colors.iter().map(|c| {
297            [c.x, c.y, c.z, c.w]
298        }).collect();
299        Self {
300            position: entity.position,
301            glyphs: entity.formation_chars.clone(),
302            colors,
303            death_type,
304        }
305    }
306}
307
308// ─── DebrisSpawner ───────────────────────────────────────────────────────────
309
310/// Spawns debris particles into a `DebrisPool` from entity death events.
311pub struct DebrisSpawner {
312    rng: Rng,
313}
314
315impl DebrisSpawner {
316    pub fn new(seed: u64) -> Self {
317        Self { rng: Rng::new(seed) }
318    }
319
320    /// Spawn debris for an entity death, returning the number of particles
321    /// actually spawned (limited by pool capacity).
322    pub fn spawn(&mut self, event: &EntityDeathEvent, pool: &mut DebrisPool) -> usize {
323        if event.glyphs.is_empty() {
324            return 0;
325        }
326
327        let total = self.rng.range_i32(SPAWN_MIN as i32, SPAWN_MAX as i32) as usize;
328        let mut spawned = 0usize;
329
330        for i in 0..total {
331            let idx = i % event.glyphs.len();
332            let ch = event.glyphs[idx];
333            let color = if idx < event.colors.len() {
334                event.colors[idx]
335            } else {
336                [1.0, 1.0, 1.0, 1.0]
337            };
338
339            let mut particle = DebrisParticle::new(ch, event.death_type);
340            particle.position = event.position;
341            particle.color = color;
342            particle.scale = self.rng.range_f32(0.6, 1.2);
343            particle.max_lifetime = self.rng.range_f32(SETTLE_ALIVE_MIN, SETTLE_ALIVE_MAX);
344
345            // Set element-specific visual properties.
346            apply_element_visuals(&mut particle, event.death_type);
347
348            // Outward radial velocity.
349            let angle = self.rng.range_f32(0.0, std::f32::consts::TAU);
350            let elevation = self.rng.range_f32(-0.3, 1.0);
351            let speed = self.rng.range_f32(3.0, 10.0) * event.death_type.velocity_multiplier();
352            let dir = Vec3::new(angle.cos(), elevation, angle.sin()).normalize_or_zero();
353            particle.velocity = dir * speed;
354
355            // Random angular velocity.
356            particle.angular_velocity = Vec3::new(
357                self.rng.range_f32(-5.0, 5.0),
358                self.rng.range_f32(-5.0, 5.0),
359                self.rng.range_f32(-5.0, 5.0),
360            );
361
362            if pool.spawn(particle) {
363                spawned += 1;
364            }
365        }
366
367        spawned
368    }
369
370    /// Spawn a radial burst — particles fly outward uniformly in a ring.
371    pub fn spawn_radial_burst(
372        &mut self,
373        center: Vec3,
374        glyphs: &[char],
375        colors: &[[f32; 4]],
376        debris_type: DebrisType,
377        count: usize,
378        pool: &mut DebrisPool,
379    ) -> usize {
380        let mut spawned = 0usize;
381        for i in 0..count {
382            let idx = i % glyphs.len().max(1);
383            let ch = if glyphs.is_empty() { '*' } else { glyphs[idx] };
384            let color = if idx < colors.len() { colors[idx] } else { [1.0; 4] };
385
386            let mut particle = DebrisParticle::new(ch, debris_type);
387            particle.position = center;
388            particle.color = color;
389            particle.scale = self.rng.range_f32(0.5, 1.0);
390            particle.max_lifetime = self.rng.range_f32(SETTLE_ALIVE_MIN, SETTLE_ALIVE_MAX);
391
392            apply_element_visuals(&mut particle, debris_type);
393
394            let angle = (i as f32 / count as f32) * std::f32::consts::TAU;
395            let speed = self.rng.range_f32(4.0, 8.0) * debris_type.velocity_multiplier();
396            particle.velocity = Vec3::new(angle.cos() * speed, self.rng.range_f32(2.0, 6.0), angle.sin() * speed);
397
398            particle.angular_velocity = Vec3::new(
399                self.rng.range_f32(-4.0, 4.0),
400                self.rng.range_f32(-4.0, 4.0),
401                self.rng.range_f32(-4.0, 4.0),
402            );
403
404            if pool.spawn(particle) {
405                spawned += 1;
406            }
407        }
408        spawned
409    }
410
411    /// Spawn directional debris — particles fly in a cone along `direction`.
412    pub fn spawn_directional(
413        &mut self,
414        center: Vec3,
415        direction: Vec3,
416        glyphs: &[char],
417        colors: &[[f32; 4]],
418        debris_type: DebrisType,
419        count: usize,
420        cone_half_angle: f32,
421        pool: &mut DebrisPool,
422    ) -> usize {
423        let dir_norm = direction.normalize_or_zero();
424        let mut spawned = 0usize;
425
426        for i in 0..count {
427            let idx = i % glyphs.len().max(1);
428            let ch = if glyphs.is_empty() { '*' } else { glyphs[idx] };
429            let color = if idx < colors.len() { colors[idx] } else { [1.0; 4] };
430
431            let mut particle = DebrisParticle::new(ch, debris_type);
432            particle.position = center;
433            particle.color = color;
434            particle.scale = self.rng.range_f32(0.5, 1.0);
435            particle.max_lifetime = self.rng.range_f32(SETTLE_ALIVE_MIN, SETTLE_ALIVE_MAX);
436
437            apply_element_visuals(&mut particle, debris_type);
438
439            // Jitter direction within a cone.
440            let jitter_angle = self.rng.range_f32(-cone_half_angle, cone_half_angle);
441            let jitter_elev = self.rng.range_f32(-cone_half_angle, cone_half_angle);
442            let speed = self.rng.range_f32(5.0, 12.0) * debris_type.velocity_multiplier();
443            let jittered = Vec3::new(
444                dir_norm.x + jitter_angle.sin(),
445                dir_norm.y + jitter_elev.sin(),
446                dir_norm.z + jitter_angle.cos() * 0.5,
447            ).normalize_or_zero();
448            particle.velocity = jittered * speed;
449
450            particle.angular_velocity = Vec3::new(
451                self.rng.range_f32(-6.0, 6.0),
452                self.rng.range_f32(-6.0, 6.0),
453                self.rng.range_f32(-6.0, 6.0),
454            );
455
456            if pool.spawn(particle) {
457                spawned += 1;
458            }
459        }
460        spawned
461    }
462
463    /// Spawn shatter debris — each glyph is split into 2-3 sub-pieces that
464    /// fall with high restitution. Typically used for Ice death.
465    pub fn spawn_shatter(
466        &mut self,
467        center: Vec3,
468        glyphs: &[char],
469        colors: &[[f32; 4]],
470        debris_type: DebrisType,
471        pool: &mut DebrisPool,
472    ) -> usize {
473        let shard_chars = ['/', '\\', '|', '-', '.', ',', '`', '\''];
474        let mut spawned = 0usize;
475
476        for (i, &ch) in glyphs.iter().enumerate() {
477            let color = if i < colors.len() { colors[i] } else { [1.0; 4] };
478            let sub_count = self.rng.range_i32(2, 3) as usize;
479
480            for _s in 0..sub_count {
481                let shard_ch = shard_chars[self.rng.range_usize(shard_chars.len())];
482
483                let mut particle = DebrisParticle::new(shard_ch, debris_type);
484                particle.position = center + Vec3::new(
485                    self.rng.range_f32(-0.3, 0.3),
486                    self.rng.range_f32(-0.1, 0.3),
487                    self.rng.range_f32(-0.3, 0.3),
488                );
489                particle.color = color;
490                particle.scale = self.rng.range_f32(0.3, 0.7);
491                particle.max_lifetime = self.rng.range_f32(SETTLE_ALIVE_MIN, SETTLE_ALIVE_MAX);
492                particle.restitution = 0.65;
493                particle.has_shattered = true; // prevent recursive shattering
494
495                apply_element_visuals(&mut particle, debris_type);
496
497                let angle = self.rng.range_f32(0.0, std::f32::consts::TAU);
498                let speed = self.rng.range_f32(2.0, 6.0);
499                particle.velocity = Vec3::new(
500                    angle.cos() * speed,
501                    self.rng.range_f32(1.0, 4.0),
502                    angle.sin() * speed,
503                );
504                particle.angular_velocity = Vec3::new(
505                    self.rng.range_f32(-8.0, 8.0),
506                    self.rng.range_f32(-8.0, 8.0),
507                    self.rng.range_f32(-8.0, 8.0),
508                );
509
510                if pool.spawn(particle) {
511                    spawned += 1;
512                }
513            }
514
515            // Also spawn the original glyph as a larger piece.
516            let mut main = DebrisParticle::new(ch, debris_type);
517            main.position = center;
518            main.color = color;
519            main.scale = self.rng.range_f32(0.7, 1.0);
520            main.max_lifetime = self.rng.range_f32(SETTLE_ALIVE_MIN, SETTLE_ALIVE_MAX);
521            main.has_shattered = true;
522            apply_element_visuals(&mut main, debris_type);
523            let a2 = self.rng.range_f32(0.0, std::f32::consts::TAU);
524            let sp = self.rng.range_f32(1.5, 4.0);
525            main.velocity = Vec3::new(a2.cos() * sp, self.rng.range_f32(2.0, 5.0), a2.sin() * sp);
526            main.angular_velocity = Vec3::splat(self.rng.range_f32(-3.0, 3.0));
527            if pool.spawn(main) {
528                spawned += 1;
529            }
530        }
531        spawned
532    }
533}
534
535/// Apply element-specific visual properties (emission, glow colour) to a
536/// particle based on its `DebrisType`.
537fn apply_element_visuals(p: &mut DebrisParticle, dt: DebrisType) {
538    match dt {
539        DebrisType::Fire => {
540            p.emission = 1.2;
541            p.glow_color = Vec3::new(1.0, 0.5, 0.1); // orange
542            p.color = blend_color(p.color, [1.0, 0.6, 0.2, 1.0], 0.3);
543        }
544        DebrisType::Ice => {
545            p.emission = 0.3;
546            p.glow_color = Vec3::new(0.5, 0.8, 1.0); // pale blue
547            p.color = blend_color(p.color, [0.7, 0.9, 1.0, 1.0], 0.2);
548        }
549        DebrisType::Lightning => {
550            p.emission = 2.0;
551            p.glow_color = Vec3::new(0.8, 0.8, 1.0); // white-blue
552            p.color = blend_color(p.color, [0.9, 0.9, 1.0, 1.0], 0.4);
553        }
554        DebrisType::Poison => {
555            p.emission = 0.6;
556            p.glow_color = Vec3::new(0.2, 1.0, 0.3); // green
557            p.color = blend_color(p.color, [0.3, 0.9, 0.2, 1.0], 0.3);
558        }
559        DebrisType::Holy => {
560            p.emission = 1.5;
561            p.glow_color = Vec3::new(1.0, 0.95, 0.6); // golden
562            p.color = blend_color(p.color, [1.0, 0.95, 0.7, 1.0], 0.3);
563        }
564        DebrisType::Dark => {
565            p.emission = 0.1;
566            p.glow_color = Vec3::new(0.15, 0.0, 0.2); // dark purple
567            p.color = blend_color(p.color, [0.1, 0.0, 0.15, 1.0], 0.5);
568        }
569        DebrisType::Bleed => {
570            p.emission = 0.4;
571            p.glow_color = Vec3::new(0.8, 0.1, 0.1); // red
572            p.color = blend_color(p.color, [0.9, 0.15, 0.1, 1.0], 0.4);
573        }
574        DebrisType::Normal => {
575            p.emission = 0.0;
576            p.glow_color = Vec3::ZERO;
577        }
578    }
579}
580
581/// Linearly blend two RGBA colours.
582fn blend_color(base: [f32; 4], target: [f32; 4], t: f32) -> [f32; 4] {
583    [
584        base[0] + (target[0] - base[0]) * t,
585        base[1] + (target[1] - base[1]) * t,
586        base[2] + (target[2] - base[2]) * t,
587        base[3] + (target[3] - base[3]) * t,
588    ]
589}
590
591// ─── ArenaCollider ───────────────────────────────────────────────────────────
592
593/// Collision result from testing a particle against the arena.
594#[derive(Debug, Clone, Copy)]
595pub struct CollisionResult {
596    /// Surface normal at the collision point (points away from the surface).
597    pub normal: Vec3,
598    /// How far the particle has penetrated the surface (positive value).
599    pub penetration: f32,
600}
601
602/// Defines the arena boundaries as six axis-aligned planes (floor, ceiling,
603/// four walls).
604#[derive(Clone, Debug)]
605pub struct ArenaCollider {
606    /// Minimum corner of the AABB arena.
607    pub min: Vec3,
608    /// Maximum corner of the AABB arena.
609    pub max: Vec3,
610    /// Y position of the floor plane (usually 0).
611    pub floor_y: f32,
612    /// Y position of the ceiling plane.
613    pub ceiling_y: f32,
614}
615
616impl Default for ArenaCollider {
617    fn default() -> Self {
618        Self {
619            min: -DEFAULT_ARENA_HALF,
620            max: DEFAULT_ARENA_HALF,
621            floor_y: 0.0,
622            ceiling_y: DEFAULT_ARENA_HALF.y,
623        }
624    }
625}
626
627impl ArenaCollider {
628    /// Create an arena from explicit bounds.
629    pub fn new(min: Vec3, max: Vec3) -> Self {
630        Self {
631            min,
632            max,
633            floor_y: min.y,
634            ceiling_y: max.y,
635        }
636    }
637
638    /// Test a particle position against all arena planes. Returns the deepest
639    /// penetrating collision (if any).
640    pub fn test_particle(&self, position: Vec3) -> Option<CollisionResult> {
641        let mut deepest: Option<CollisionResult> = None;
642
643        // Floor (y = floor_y, normal pointing up).
644        let floor_pen = self.floor_y - position.y;
645        if floor_pen > 0.0 {
646            deepest = Some(deeper(deepest, CollisionResult {
647                normal: Vec3::Y,
648                penetration: floor_pen,
649            }));
650        }
651
652        // Ceiling (y = ceiling_y, normal pointing down).
653        let ceil_pen = position.y - self.ceiling_y;
654        if ceil_pen > 0.0 {
655            deepest = Some(deeper(deepest, CollisionResult {
656                normal: Vec3::NEG_Y,
657                penetration: ceil_pen,
658            }));
659        }
660
661        // Left wall (x = min.x, normal pointing +X).
662        let left_pen = self.min.x - position.x;
663        if left_pen > 0.0 {
664            deepest = Some(deeper(deepest, CollisionResult {
665                normal: Vec3::X,
666                penetration: left_pen,
667            }));
668        }
669
670        // Right wall (x = max.x, normal pointing -X).
671        let right_pen = position.x - self.max.x;
672        if right_pen > 0.0 {
673            deepest = Some(deeper(deepest, CollisionResult {
674                normal: Vec3::NEG_X,
675                penetration: right_pen,
676            }));
677        }
678
679        // Back wall (z = min.z, normal pointing +Z).
680        let back_pen = self.min.z - position.z;
681        if back_pen > 0.0 {
682            deepest = Some(deeper(deepest, CollisionResult {
683                normal: Vec3::Z,
684                penetration: back_pen,
685            }));
686        }
687
688        // Front wall (z = max.z, normal pointing -Z).
689        let front_pen = position.z - self.max.z;
690        if front_pen > 0.0 {
691            deepest = Some(deeper(deepest, CollisionResult {
692                normal: Vec3::NEG_Z,
693                penetration: front_pen,
694            }));
695        }
696
697        deepest
698    }
699
700    /// Check floor-only collision (fast path for settling check).
701    pub fn on_floor(&self, position: Vec3) -> bool {
702        position.y <= self.floor_y + COLLISION_EPSILON
703    }
704}
705
706/// Return the collision with the greater penetration depth.
707fn deeper(existing: Option<CollisionResult>, candidate: CollisionResult) -> CollisionResult {
708    match existing {
709        Some(e) if e.penetration >= candidate.penetration => e,
710        _ => candidate,
711    }
712}
713
714// ─── DebrisPool ──────────────────────────────────────────────────────────────
715
716/// Pre-allocated pool of debris particles. Dead particles are recycled.
717pub struct DebrisPool {
718    particles: Vec<DebrisParticle>,
719    /// Number of currently alive particles.
720    alive_count: usize,
721}
722
723impl DebrisPool {
724    /// Create a pool with the default capacity (500).
725    pub fn new() -> Self {
726        Self::with_capacity(POOL_CAPACITY)
727    }
728
729    /// Create a pool with a specific capacity.
730    pub fn with_capacity(capacity: usize) -> Self {
731        let mut particles = Vec::with_capacity(capacity);
732        for _ in 0..capacity {
733            particles.push(DebrisParticle::default());
734        }
735        Self {
736            particles,
737            alive_count: 0,
738        }
739    }
740
741    /// Try to spawn a particle. Returns `true` if there was a free slot.
742    pub fn spawn(&mut self, particle: DebrisParticle) -> bool {
743        // Find a dead slot.
744        for slot in self.particles.iter_mut() {
745            if !slot.alive {
746                *slot = particle;
747                slot.alive = true;
748                self.alive_count += 1;
749                return true;
750            }
751        }
752        false
753    }
754
755    /// Number of currently alive particles.
756    pub fn alive_count(&self) -> usize {
757        self.alive_count
758    }
759
760    /// Total pool capacity.
761    pub fn capacity(&self) -> usize {
762        self.particles.len()
763    }
764
765    /// Iterate over all alive particles (immutable).
766    pub fn iter_alive(&self) -> impl Iterator<Item = &DebrisParticle> {
767        self.particles.iter().filter(|p| p.alive)
768    }
769
770    /// Iterate over all alive particles (mutable).
771    pub fn iter_alive_mut(&mut self) -> impl Iterator<Item = &mut DebrisParticle> {
772        self.particles.iter_mut().filter(|p| p.alive)
773    }
774
775    /// Access the raw particle slice (including dead particles).
776    pub fn particles(&self) -> &[DebrisParticle] {
777        &self.particles
778    }
779
780    /// Access the raw particle slice mutably.
781    pub fn particles_mut(&mut self) -> &mut [DebrisParticle] {
782        &mut self.particles
783    }
784
785    /// Reclaim dead particles and update the alive count.
786    pub fn reclaim_dead(&mut self) {
787        let mut count = 0usize;
788        for p in self.particles.iter_mut() {
789            if p.alive && p.is_expired() {
790                p.alive = false;
791            }
792            if p.alive {
793                count += 1;
794            }
795        }
796        self.alive_count = count;
797    }
798
799    /// Kill all particles.
800    pub fn clear(&mut self) {
801        for p in self.particles.iter_mut() {
802            p.alive = false;
803        }
804        self.alive_count = 0;
805    }
806}
807
808impl Default for DebrisPool {
809    fn default() -> Self {
810        Self::new()
811    }
812}
813
814// ─── DebrisSimulator ─────────────────────────────────────────────────────────
815
816/// Steps all debris particles each frame with gravity, collision, friction,
817/// buoyancy, drag, and settling logic.
818pub struct DebrisSimulator {
819    /// Arena collision geometry.
820    pub arena: ArenaCollider,
821
822    /// Gravity vector (defaults to standard downward).
823    pub gravity: Vec3,
824
825    /// Enable inter-particle collision.
826    pub enable_particle_collision: bool,
827
828    /// Temporary buffer for shatter spawning (avoids borrow issues).
829    shatter_queue: Vec<ShatterRequest>,
830}
831
832/// Internal request to spawn shatter sub-debris during a simulation step.
833#[derive(Clone)]
834struct ShatterRequest {
835    position: Vec3,
836    glyph: char,
837    color: [f32; 4],
838    debris_type: DebrisType,
839}
840
841impl Default for DebrisSimulator {
842    fn default() -> Self {
843        Self {
844            arena: ArenaCollider::default(),
845            gravity: GRAVITY,
846            enable_particle_collision: true,
847            shatter_queue: Vec::new(),
848        }
849    }
850}
851
852impl DebrisSimulator {
853    pub fn new(arena: ArenaCollider) -> Self {
854        Self {
855            arena,
856            ..Default::default()
857        }
858    }
859
860    /// Advance the simulation by `dt` seconds.
861    pub fn step(&mut self, dt: f32, pool: &mut DebrisPool) {
862        self.shatter_queue.clear();
863        let particles = pool.particles_mut();
864        let len = particles.len();
865
866        // ── Per-particle physics ─────────────────────────────────────────
867        for i in 0..len {
868            if !particles[i].alive {
869                continue;
870            }
871
872            let p = &mut particles[i];
873
874            // Advance lifetime.
875            p.lifetime += dt;
876
877            // Check if should start settling.
878            if !p.settled && p.lifetime >= p.max_lifetime {
879                p.settled = true;
880            }
881
882            // If settled, advance fade timer and skip heavy physics.
883            if p.settled {
884                p.fade_time += dt;
885                // Apply gentle gravity to keep it on the floor.
886                p.velocity.y -= 1.0 * dt;
887                p.velocity *= (1.0 - 2.0 * dt).max(0.0);
888                p.position += p.velocity * dt;
889                // Clamp to floor.
890                if p.position.y < self.arena.floor_y {
891                    p.position.y = self.arena.floor_y;
892                    p.velocity.y = 0.0;
893                }
894                continue;
895            }
896
897            // ── Forces ───────────────────────────────────────────────────
898
899            // Gravity (or reduced gravity for buoyant types).
900            if p.debris_type.has_buoyancy() {
901                // Buoyancy counteracts gravity and adds lift.
902                let buoyancy = Vec3::new(0.0, FIRE_BUOYANCY, 0.0);
903                p.velocity += (self.gravity + buoyancy) * dt;
904            } else if p.debris_type.sinks() {
905                // Dark debris has extra downward pull.
906                p.velocity += self.gravity * 1.5 * dt;
907            } else if p.debris_type.drips() {
908                // Bleed debris has extra downward gravity.
909                p.velocity += self.gravity * 1.3 * dt;
910            } else {
911                p.velocity += self.gravity * dt;
912            }
913
914            // Heavy drag for slow types.
915            if p.debris_type.has_heavy_drag() {
916                let drag_force = -p.velocity * SLOW_DRAG * dt;
917                p.velocity += drag_force;
918            }
919
920            // Integrate position.
921            p.position += p.velocity * dt;
922
923            // Integrate rotation.
924            let ang = p.angular_velocity * dt;
925            let ang_len = ang.length();
926            if ang_len > 1e-6 {
927                let dq = Quat::from_axis_angle(ang / ang_len, ang_len);
928                p.rotation = (dq * p.rotation).normalize();
929            }
930
931            // Damp angular velocity.
932            p.angular_velocity *= (1.0 - 0.5 * dt).max(0.0);
933
934            // ── Arena collision ───────────────────────────────────────────
935            if let Some(hit) = self.arena.test_particle(p.position) {
936                // Push out of surface.
937                p.position += hit.normal * (hit.penetration + COLLISION_EPSILON);
938
939                // Reflect velocity.
940                let vn = p.velocity.dot(hit.normal);
941                if vn < 0.0 {
942                    // Bounce.
943                    p.velocity -= hit.normal * vn * (1.0 + p.restitution);
944
945                    // Ground friction — reduce tangential velocity.
946                    if hit.normal.y > 0.5 {
947                        // On floor.
948                        let tangential = p.velocity - hit.normal * p.velocity.dot(hit.normal);
949                        p.velocity -= tangential * p.friction * dt * 10.0;
950
951                        // If velocity is tiny, stop.
952                        if p.velocity.length_squared() < REST_VELOCITY_THRESHOLD * REST_VELOCITY_THRESHOLD {
953                            p.velocity = Vec3::ZERO;
954                        }
955                    }
956
957                    // Reduce angular velocity on bounce.
958                    p.angular_velocity *= 0.7;
959
960                    // Ice shatter on first impact.
961                    if p.debris_type.shatters_on_impact() && !p.has_shattered {
962                        p.has_shattered = true;
963                        self.shatter_queue.push(ShatterRequest {
964                            position: p.position,
965                            glyph: p.glyph,
966                            color: p.color,
967                            debris_type: p.debris_type,
968                        });
969                    }
970                }
971            }
972
973            // Dark debris can sink below the floor.
974            if p.debris_type.sinks() && p.position.y < self.arena.floor_y {
975                // Gradually sink, then settle.
976                let sink_depth = self.arena.floor_y - p.position.y;
977                if sink_depth > 0.5 {
978                    p.settled = true;
979                    p.position.y = self.arena.floor_y - 0.5;
980                    p.velocity = Vec3::ZERO;
981                }
982            }
983        }
984
985        // ── Inter-particle collision ─────────────────────────────────────
986        if self.enable_particle_collision {
987            self.solve_particle_collisions(pool.particles_mut());
988        }
989
990        // ── Process shatter queue ────────────────────────────────────────
991        let shatter_requests: Vec<ShatterRequest> = self.shatter_queue.drain(..).collect();
992        let shard_chars = ['/', '\\', '|', '-', '.', ','];
993        let mut rng_state: u64 = 0xDEAD_CAFE;
994        for req in &shatter_requests {
995            let sub_count = 2 + (lcg_u32(&mut rng_state) % 2) as usize;
996            for _ in 0..sub_count {
997                let shard_idx = (lcg_u32(&mut rng_state) % shard_chars.len() as u32) as usize;
998                let ch = shard_chars[shard_idx];
999                let mut sp = DebrisParticle::new(ch, req.debris_type);
1000                sp.position = req.position + Vec3::new(
1001                    lcg_f32(&mut rng_state) * 0.4 - 0.2,
1002                    lcg_f32(&mut rng_state) * 0.3,
1003                    lcg_f32(&mut rng_state) * 0.4 - 0.2,
1004                );
1005                sp.color = req.color;
1006                sp.scale = 0.3 + lcg_f32(&mut rng_state) * 0.3;
1007                sp.max_lifetime = SETTLE_ALIVE_MIN + lcg_f32(&mut rng_state) * (SETTLE_ALIVE_MAX - SETTLE_ALIVE_MIN);
1008                sp.has_shattered = true;
1009                sp.restitution = 0.6;
1010                apply_element_visuals(&mut sp, req.debris_type);
1011
1012                let angle = lcg_f32(&mut rng_state) * std::f32::consts::TAU;
1013                let speed = 2.0 + lcg_f32(&mut rng_state) * 4.0;
1014                sp.velocity = Vec3::new(angle.cos() * speed, 1.0 + lcg_f32(&mut rng_state) * 3.0, angle.sin() * speed);
1015                sp.angular_velocity = Vec3::splat(lcg_f32(&mut rng_state) * 6.0 - 3.0);
1016                let _ = pool.spawn(sp);
1017            }
1018        }
1019
1020        // ── Reclaim expired particles ────────────────────────────────────
1021        pool.reclaim_dead();
1022    }
1023
1024    /// Simple O(n^2) inter-particle collision with sphere overlap.
1025    fn solve_particle_collisions(&self, particles: &mut [DebrisParticle]) {
1026        let len = particles.len();
1027        for i in 0..len {
1028            if !particles[i].alive || particles[i].settled {
1029                continue;
1030            }
1031            for j in (i + 1)..len {
1032                if !particles[j].alive || particles[j].settled {
1033                    continue;
1034                }
1035
1036                let diff = particles[i].position - particles[j].position;
1037                let dist_sq = diff.length_squared();
1038                let min_dist = PARTICLE_COLLISION_RADIUS * 2.0;
1039
1040                if dist_sq < min_dist * min_dist && dist_sq > 1e-8 {
1041                    let dist = dist_sq.sqrt();
1042                    let normal = diff / dist;
1043                    let overlap = min_dist - dist;
1044
1045                    // Separate particles.
1046                    let total_mass = particles[i].mass + particles[j].mass;
1047                    let ratio_i = particles[j].mass / total_mass;
1048                    let ratio_j = particles[i].mass / total_mass;
1049
1050                    particles[i].position += normal * overlap * ratio_i * 0.5;
1051                    particles[j].position -= normal * overlap * ratio_j * 0.5;
1052
1053                    // Elastic impulse.
1054                    let rel_vel = particles[i].velocity - particles[j].velocity;
1055                    let vn = rel_vel.dot(normal);
1056                    if vn < 0.0 {
1057                        let restitution = (particles[i].restitution + particles[j].restitution) * 0.5;
1058                        let impulse = -(1.0 + restitution) * vn / total_mass;
1059                        particles[i].velocity += normal * impulse * particles[j].mass;
1060                        particles[j].velocity -= normal * impulse * particles[i].mass;
1061                    }
1062                }
1063            }
1064        }
1065    }
1066}
1067
1068// ─── Simple LCG helpers (no-dependency RNG for inline use) ───────────────────
1069
1070fn lcg_u32(state: &mut u64) -> u32 {
1071    *state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1072    ((*state >> 33) ^ *state) as u32
1073}
1074
1075fn lcg_f32(state: &mut u64) -> f32 {
1076    (lcg_u32(state) & 0x00FF_FFFF) as f32 / 16777216.0
1077}
1078
1079// ─── DebrisRenderer ──────────────────────────────────────────────────────────
1080
1081/// Converts live debris particles into `GlyphInstance` data suitable for GPU
1082/// instanced rendering.
1083pub struct DebrisRenderer {
1084    /// Scratch buffer reused each frame.
1085    instances: Vec<GlyphInstance>,
1086}
1087
1088impl Default for DebrisRenderer {
1089    fn default() -> Self {
1090        Self::new()
1091    }
1092}
1093
1094impl DebrisRenderer {
1095    pub fn new() -> Self {
1096        Self {
1097            instances: Vec::with_capacity(POOL_CAPACITY),
1098        }
1099    }
1100
1101    /// Build glyph instance data from the current pool state. Returns a slice
1102    /// of `GlyphInstance` ready for upload to the GPU.
1103    pub fn build_instances(&mut self, pool: &DebrisPool) -> &[GlyphInstance] {
1104        self.instances.clear();
1105
1106        for p in pool.iter_alive() {
1107            let alpha = p.effective_alpha();
1108            if alpha <= 0.001 {
1109                continue;
1110            }
1111
1112            // Extract Euler-angle Z rotation for the 2D glyph rotation.
1113            let (_, rot_y, _) = quat_to_euler(p.rotation);
1114            let _ = rot_y; // keep compiler happy; we use the z component
1115            let (_, _, rot_z) = quat_to_euler(p.rotation);
1116
1117            let inst = GlyphInstance {
1118                position: [p.position.x, p.position.y, p.position.z],
1119                scale: [p.scale, p.scale],
1120                rotation: rot_z,
1121                color: [p.color[0], p.color[1], p.color[2], alpha],
1122                emission: p.emission,
1123                glow_color: [p.glow_color.x, p.glow_color.y, p.glow_color.z],
1124                glow_radius: p.emission * 0.5,
1125                uv_offset: [0.0, 0.0],
1126                uv_size: [1.0, 1.0],
1127                _pad: [0.0, 0.0],
1128            };
1129            self.instances.push(inst);
1130        }
1131
1132        &self.instances
1133    }
1134
1135    /// Number of instances produced by the last `build_instances` call.
1136    pub fn instance_count(&self) -> usize {
1137        self.instances.len()
1138    }
1139}
1140
1141/// Convert a quaternion to (pitch, yaw, roll) Euler angles.
1142fn quat_to_euler(q: Quat) -> (f32, f32, f32) {
1143    let (x, y, z, w) = (q.x, q.y, q.z, q.w);
1144
1145    // Roll (x-axis).
1146    let sinr = 2.0 * (w * x + y * z);
1147    let cosr = 1.0 - 2.0 * (x * x + y * y);
1148    let roll = sinr.atan2(cosr);
1149
1150    // Pitch (y-axis).
1151    let sinp = 2.0 * (w * y - z * x);
1152    let pitch = if sinp.abs() >= 1.0 {
1153        std::f32::consts::FRAC_PI_2.copysign(sinp)
1154    } else {
1155        sinp.asin()
1156    };
1157
1158    // Yaw (z-axis).
1159    let siny = 2.0 * (w * z + x * y);
1160    let cosy = 1.0 - 2.0 * (y * y + z * z);
1161    let yaw = siny.atan2(cosy);
1162
1163    (pitch, yaw, roll)
1164}
1165
1166// ─── DeathEffect ─────────────────────────────────────────────────────────────
1167
1168/// Camera shake / trauma descriptor emitted alongside debris.
1169#[derive(Clone, Debug)]
1170pub struct CameraTrauma {
1171    /// Current trauma value [0, 1]. Decays over time.
1172    pub trauma: f32,
1173    /// Decay rate (trauma per second).
1174    pub decay_rate: f32,
1175}
1176
1177impl Default for CameraTrauma {
1178    fn default() -> Self {
1179        Self {
1180            trauma: 0.0,
1181            decay_rate: 2.0,
1182        }
1183    }
1184}
1185
1186impl CameraTrauma {
1187    /// Add trauma (clamped to [0, 1]).
1188    pub fn add(&mut self, amount: f32) {
1189        self.trauma = (self.trauma + amount).clamp(0.0, 1.0);
1190    }
1191
1192    /// Screen shake magnitude (trauma squared for a nice curve).
1193    pub fn shake_amount(&self) -> f32 {
1194        self.trauma * self.trauma
1195    }
1196
1197    /// Tick the trauma decay.
1198    pub fn update(&mut self, dt: f32) {
1199        self.trauma = (self.trauma - self.decay_rate * dt).max(0.0);
1200    }
1201}
1202
1203/// Sound cue hint emitted with death effects.
1204#[derive(Clone, Debug)]
1205pub struct SoundCue {
1206    /// Name/id of the sound to play.
1207    pub name: String,
1208    /// Volume [0, 1].
1209    pub volume: f32,
1210    /// Pitch multiplier (1.0 = normal).
1211    pub pitch: f32,
1212}
1213
1214/// Orchestrates a complete entity death sequence: debris spawn, camera trauma,
1215/// sound cue, and element-specific animations.
1216pub struct DeathEffect {
1217    spawner: DebrisSpawner,
1218}
1219
1220impl DeathEffect {
1221    pub fn new(seed: u64) -> Self {
1222        Self {
1223            spawner: DebrisSpawner::new(seed),
1224        }
1225    }
1226
1227    /// Execute a full death effect for the given event. Returns the number of
1228    /// debris particles spawned, the camera trauma to apply, and a sound cue.
1229    pub fn execute(
1230        &mut self,
1231        event: &EntityDeathEvent,
1232        pool: &mut DebrisPool,
1233    ) -> (usize, CameraTrauma, SoundCue) {
1234        let (count, trauma, cue) = match event.death_type {
1235            DebrisType::Fire      => self.fire_death(event, pool),
1236            DebrisType::Ice       => self.ice_death(event, pool),
1237            DebrisType::Lightning => self.lightning_death(event, pool),
1238            DebrisType::Poison    => self.poison_death(event, pool),
1239            DebrisType::Holy      => self.holy_death(event, pool),
1240            DebrisType::Dark      => self.dark_death(event, pool),
1241            DebrisType::Bleed     => self.bleed_death(event, pool),
1242            DebrisType::Normal    => self.normal_death(event, pool),
1243        };
1244        (count, trauma, cue)
1245    }
1246
1247    /// Normal death — standard radial burst.
1248    fn normal_death(
1249        &mut self,
1250        event: &EntityDeathEvent,
1251        pool: &mut DebrisPool,
1252    ) -> (usize, CameraTrauma, SoundCue) {
1253        let count = self.spawner.spawn(event, pool);
1254        let mut trauma = CameraTrauma::default();
1255        trauma.add(0.3);
1256        let cue = SoundCue {
1257            name: "death_normal".into(),
1258            volume: 0.7,
1259            pitch: 1.0,
1260        };
1261        (count, trauma, cue)
1262    }
1263
1264    /// Fire death — debris floats up with orange glow and ember particles.
1265    fn fire_death(
1266        &mut self,
1267        event: &EntityDeathEvent,
1268        pool: &mut DebrisPool,
1269    ) -> (usize, CameraTrauma, SoundCue) {
1270        let count = self.spawner.spawn_radial_burst(
1271            event.position,
1272            &event.glyphs,
1273            &event.colors,
1274            DebrisType::Fire,
1275            30,
1276            pool,
1277        );
1278        // Spawn extra small ember particles.
1279        let ember_chars: Vec<char> = vec!['.', ',', '`', '*'];
1280        let ember_colors: Vec<[f32; 4]> = vec![
1281            [1.0, 0.6, 0.1, 0.8],
1282            [1.0, 0.4, 0.0, 0.7],
1283            [1.0, 0.8, 0.2, 0.9],
1284        ];
1285        let extra = self.spawner.spawn_radial_burst(
1286            event.position,
1287            &ember_chars,
1288            &ember_colors,
1289            DebrisType::Fire,
1290            15,
1291            pool,
1292        );
1293        let mut trauma = CameraTrauma::default();
1294        trauma.add(0.4);
1295        let cue = SoundCue {
1296            name: "death_fire".into(),
1297            volume: 0.85,
1298            pitch: 0.9,
1299        };
1300        (count + extra, trauma, cue)
1301    }
1302
1303    /// Ice death — glyphs crack into 2-3 sub-pieces each, fall with clinking.
1304    fn ice_death(
1305        &mut self,
1306        event: &EntityDeathEvent,
1307        pool: &mut DebrisPool,
1308    ) -> (usize, CameraTrauma, SoundCue) {
1309        let count = self.spawner.spawn_shatter(
1310            event.position,
1311            &event.glyphs,
1312            &event.colors,
1313            DebrisType::Ice,
1314            pool,
1315        );
1316        let mut trauma = CameraTrauma::default();
1317        trauma.add(0.35);
1318        let cue = SoundCue {
1319            name: "death_ice_shatter".into(),
1320            volume: 0.8,
1321            pitch: 1.3,
1322        };
1323        (count, trauma, cue)
1324    }
1325
1326    /// Lightning death — extreme velocity scatter with electric arc trails.
1327    fn lightning_death(
1328        &mut self,
1329        event: &EntityDeathEvent,
1330        pool: &mut DebrisPool,
1331    ) -> (usize, CameraTrauma, SoundCue) {
1332        let count = self.spawner.spawn_radial_burst(
1333            event.position,
1334            &event.glyphs,
1335            &event.colors,
1336            DebrisType::Lightning,
1337            40,
1338            pool,
1339        );
1340        let mut trauma = CameraTrauma::default();
1341        trauma.add(0.6);
1342        let cue = SoundCue {
1343            name: "death_lightning".into(),
1344            volume: 1.0,
1345            pitch: 1.5,
1346        };
1347        (count, trauma, cue)
1348    }
1349
1350    /// Poison death — slow dissolve with green mist.
1351    fn poison_death(
1352        &mut self,
1353        event: &EntityDeathEvent,
1354        pool: &mut DebrisPool,
1355    ) -> (usize, CameraTrauma, SoundCue) {
1356        let count = self.spawner.spawn(event, pool);
1357        // Extra mist particles.
1358        let mist_chars: Vec<char> = vec!['~', '.', '*', 'o'];
1359        let mist_colors: Vec<[f32; 4]> = vec![
1360            [0.2, 0.8, 0.1, 0.5],
1361            [0.3, 0.9, 0.2, 0.4],
1362        ];
1363        let extra = self.spawner.spawn_radial_burst(
1364            event.position,
1365            &mist_chars,
1366            &mist_colors,
1367            DebrisType::Poison,
1368            20,
1369            pool,
1370        );
1371        let mut trauma = CameraTrauma::default();
1372        trauma.add(0.2);
1373        let cue = SoundCue {
1374            name: "death_poison".into(),
1375            volume: 0.6,
1376            pitch: 0.7,
1377        };
1378        (count + extra, trauma, cue)
1379    }
1380
1381    /// Holy death — glyphs rise upward and vanish in golden light.
1382    fn holy_death(
1383        &mut self,
1384        event: &EntityDeathEvent,
1385        pool: &mut DebrisPool,
1386    ) -> (usize, CameraTrauma, SoundCue) {
1387        let count = self.spawner.spawn(event, pool);
1388        // Extra golden sparkle particles.
1389        let sparkle_chars: Vec<char> = vec!['+', '*', '.'];
1390        let sparkle_colors: Vec<[f32; 4]> = vec![
1391            [1.0, 0.95, 0.6, 0.9],
1392            [1.0, 0.9, 0.4, 0.8],
1393        ];
1394        let extra = self.spawner.spawn_radial_burst(
1395            event.position + Vec3::new(0.0, 0.5, 0.0),
1396            &sparkle_chars,
1397            &sparkle_colors,
1398            DebrisType::Holy,
1399            15,
1400            pool,
1401        );
1402        let mut trauma = CameraTrauma::default();
1403        trauma.add(0.25);
1404        let cue = SoundCue {
1405            name: "death_holy".into(),
1406            volume: 0.75,
1407            pitch: 1.2,
1408        };
1409        (count + extra, trauma, cue)
1410    }
1411
1412    /// Dark death — glyphs sink into floor, leaving dark stain.
1413    fn dark_death(
1414        &mut self,
1415        event: &EntityDeathEvent,
1416        pool: &mut DebrisPool,
1417    ) -> (usize, CameraTrauma, SoundCue) {
1418        let count = self.spawner.spawn_directional(
1419            event.position,
1420            Vec3::NEG_Y,
1421            &event.glyphs,
1422            &event.colors,
1423            DebrisType::Dark,
1424            25,
1425            0.8,
1426            pool,
1427        );
1428        let mut trauma = CameraTrauma::default();
1429        trauma.add(0.35);
1430        let cue = SoundCue {
1431            name: "death_dark".into(),
1432            volume: 0.7,
1433            pitch: 0.5,
1434        };
1435        (count, trauma, cue)
1436    }
1437
1438    /// Bleed death — debris drips downward with red tint.
1439    fn bleed_death(
1440        &mut self,
1441        event: &EntityDeathEvent,
1442        pool: &mut DebrisPool,
1443    ) -> (usize, CameraTrauma, SoundCue) {
1444        let count = self.spawner.spawn(event, pool);
1445        // Extra blood drip particles.
1446        let drip_chars: Vec<char> = vec!['.', ',', ':', '|'];
1447        let drip_colors: Vec<[f32; 4]> = vec![
1448            [0.8, 0.05, 0.05, 0.9],
1449            [0.6, 0.0, 0.0, 0.8],
1450        ];
1451        let extra = self.spawner.spawn_directional(
1452            event.position,
1453            Vec3::NEG_Y,
1454            &drip_chars,
1455            &drip_colors,
1456            DebrisType::Bleed,
1457            20,
1458            0.5,
1459            pool,
1460        );
1461        let mut trauma = CameraTrauma::default();
1462        trauma.add(0.4);
1463        let cue = SoundCue {
1464            name: "death_bleed".into(),
1465            volume: 0.75,
1466            pitch: 0.8,
1467        };
1468        (count + extra, trauma, cue)
1469    }
1470}
1471
1472// ─── Tests ───────────────────────────────────────────────────────────────────
1473
1474#[cfg(test)]
1475mod tests {
1476    use super::*;
1477
1478    fn sample_event() -> EntityDeathEvent {
1479        EntityDeathEvent {
1480            position: Vec3::new(5.0, 2.0, 0.0),
1481            glyphs: vec!['A', 'B', 'C', '@'],
1482            colors: vec![
1483                [1.0, 0.0, 0.0, 1.0],
1484                [0.0, 1.0, 0.0, 1.0],
1485                [0.0, 0.0, 1.0, 1.0],
1486                [1.0, 1.0, 0.0, 1.0],
1487            ],
1488            death_type: DebrisType::Normal,
1489        }
1490    }
1491
1492    // ── Pool tests ───────────────────────────────────────────────────────
1493
1494    #[test]
1495    fn pool_starts_empty() {
1496        let pool = DebrisPool::new();
1497        assert_eq!(pool.alive_count(), 0);
1498        assert_eq!(pool.capacity(), POOL_CAPACITY);
1499    }
1500
1501    #[test]
1502    fn pool_spawn_and_count() {
1503        let mut pool = DebrisPool::with_capacity(10);
1504        for i in 0..10 {
1505            let mut p = DebrisParticle::new('X', DebrisType::Normal);
1506            p.position = Vec3::new(i as f32, 0.0, 0.0);
1507            assert!(pool.spawn(p));
1508        }
1509        assert_eq!(pool.alive_count(), 10);
1510        // Pool full — should fail.
1511        let extra = DebrisParticle::new('Y', DebrisType::Normal);
1512        assert!(!pool.spawn(extra));
1513    }
1514
1515    #[test]
1516    fn pool_reclaim_dead() {
1517        let mut pool = DebrisPool::with_capacity(5);
1518        for _ in 0..5 {
1519            let mut p = DebrisParticle::new('Z', DebrisType::Normal);
1520            p.max_lifetime = 0.1;
1521            p.lifetime = 0.2;
1522            p.settled = true;
1523            p.fade_time = SETTLE_FADE_DURATION + 0.1;
1524            assert!(pool.spawn(p));
1525        }
1526        assert_eq!(pool.alive_count(), 5);
1527        pool.reclaim_dead();
1528        assert_eq!(pool.alive_count(), 0);
1529    }
1530
1531    #[test]
1532    fn pool_clear() {
1533        let mut pool = DebrisPool::with_capacity(10);
1534        for _ in 0..5 {
1535            pool.spawn(DebrisParticle::new('A', DebrisType::Normal));
1536        }
1537        pool.clear();
1538        assert_eq!(pool.alive_count(), 0);
1539    }
1540
1541    // ── Spawner tests ────────────────────────────────────────────────────
1542
1543    #[test]
1544    fn spawner_produces_particles() {
1545        let mut spawner = DebrisSpawner::new(42);
1546        let mut pool = DebrisPool::new();
1547        let event = sample_event();
1548        let count = spawner.spawn(&event, &mut pool);
1549        assert!(count >= SPAWN_MIN);
1550        assert!(count <= SPAWN_MAX);
1551        assert_eq!(pool.alive_count(), count);
1552    }
1553
1554    #[test]
1555    fn spawner_empty_glyphs() {
1556        let mut spawner = DebrisSpawner::new(0);
1557        let mut pool = DebrisPool::new();
1558        let event = EntityDeathEvent {
1559            position: Vec3::ZERO,
1560            glyphs: vec![],
1561            colors: vec![],
1562            death_type: DebrisType::Normal,
1563        };
1564        assert_eq!(spawner.spawn(&event, &mut pool), 0);
1565    }
1566
1567    #[test]
1568    fn spawner_radial_burst() {
1569        let mut spawner = DebrisSpawner::new(100);
1570        let mut pool = DebrisPool::new();
1571        let count = spawner.spawn_radial_burst(
1572            Vec3::ZERO,
1573            &['X', 'Y'],
1574            &[[1.0; 4], [0.5; 4]],
1575            DebrisType::Fire,
1576            20,
1577            &mut pool,
1578        );
1579        assert_eq!(count, 20);
1580    }
1581
1582    #[test]
1583    fn spawner_directional() {
1584        let mut spawner = DebrisSpawner::new(200);
1585        let mut pool = DebrisPool::new();
1586        let count = spawner.spawn_directional(
1587            Vec3::new(0.0, 5.0, 0.0),
1588            Vec3::NEG_Y,
1589            &['.', ','],
1590            &[[0.8, 0.1, 0.1, 1.0]],
1591            DebrisType::Bleed,
1592            15,
1593            0.5,
1594            &mut pool,
1595        );
1596        assert_eq!(count, 15);
1597    }
1598
1599    #[test]
1600    fn spawner_shatter() {
1601        let mut spawner = DebrisSpawner::new(300);
1602        let mut pool = DebrisPool::new();
1603        let count = spawner.spawn_shatter(
1604            Vec3::ZERO,
1605            &['#', '%'],
1606            &[[0.5, 0.8, 1.0, 1.0]; 2],
1607            DebrisType::Ice,
1608            &mut pool,
1609        );
1610        // Each glyph produces 2-3 shards + 1 main piece = 3-4 per glyph.
1611        // With 2 glyphs => 6-8.
1612        assert!(count >= 6, "expected >= 6 shatter particles, got {}", count);
1613        assert!(count <= 10, "expected <= 10 shatter particles, got {}", count);
1614    }
1615
1616    // ── Arena collider tests ─────────────────────────────────────────────
1617
1618    #[test]
1619    fn arena_floor_collision() {
1620        let arena = ArenaCollider::new(Vec3::new(-10.0, 0.0, -10.0), Vec3::new(10.0, 20.0, 10.0));
1621        // Below floor.
1622        let result = arena.test_particle(Vec3::new(0.0, -0.5, 0.0));
1623        assert!(result.is_some());
1624        let hit = result.unwrap();
1625        assert!((hit.normal - Vec3::Y).length() < 0.01);
1626        assert!((hit.penetration - 0.5).abs() < 0.01);
1627    }
1628
1629    #[test]
1630    fn arena_no_collision_inside() {
1631        let arena = ArenaCollider::new(Vec3::new(-10.0, 0.0, -10.0), Vec3::new(10.0, 20.0, 10.0));
1632        let result = arena.test_particle(Vec3::new(0.0, 5.0, 0.0));
1633        assert!(result.is_none());
1634    }
1635
1636    #[test]
1637    fn arena_wall_collision() {
1638        let arena = ArenaCollider::new(Vec3::new(-10.0, 0.0, -10.0), Vec3::new(10.0, 20.0, 10.0));
1639        let result = arena.test_particle(Vec3::new(11.0, 5.0, 0.0));
1640        assert!(result.is_some());
1641        let hit = result.unwrap();
1642        assert!((hit.normal - Vec3::NEG_X).length() < 0.01);
1643    }
1644
1645    #[test]
1646    fn arena_on_floor() {
1647        let arena = ArenaCollider::new(Vec3::new(-10.0, 0.0, -10.0), Vec3::new(10.0, 20.0, 10.0));
1648        assert!(arena.on_floor(Vec3::new(0.0, 0.0, 0.0)));
1649        assert!(arena.on_floor(Vec3::new(0.0, COLLISION_EPSILON * 0.5, 0.0)));
1650        assert!(!arena.on_floor(Vec3::new(0.0, 1.0, 0.0)));
1651    }
1652
1653    // ── Simulator tests ──────────────────────────────────────────────────
1654
1655    #[test]
1656    fn simulator_gravity_pulls_down() {
1657        let mut pool = DebrisPool::with_capacity(10);
1658        let mut p = DebrisParticle::new('G', DebrisType::Normal);
1659        p.position = Vec3::new(0.0, 10.0, 0.0);
1660        p.velocity = Vec3::ZERO;
1661        p.max_lifetime = 10.0;
1662        pool.spawn(p);
1663
1664        let arena = ArenaCollider::new(Vec3::new(-50.0, 0.0, -50.0), Vec3::new(50.0, 50.0, 50.0));
1665        let mut sim = DebrisSimulator::new(arena);
1666        sim.enable_particle_collision = false;
1667
1668        // Step 0.1s ten times.
1669        for _ in 0..10 {
1670            sim.step(0.1, &mut pool);
1671        }
1672
1673        let particle = pool.iter_alive().next().unwrap();
1674        // After 1 second of free-fall from y=10, should have fallen.
1675        assert!(particle.position.y < 10.0, "particle should have fallen, y={}", particle.position.y);
1676    }
1677
1678    #[test]
1679    fn simulator_floor_bounce() {
1680        let mut pool = DebrisPool::with_capacity(10);
1681        let mut p = DebrisParticle::new('B', DebrisType::Normal);
1682        p.position = Vec3::new(0.0, 0.5, 0.0);
1683        p.velocity = Vec3::new(0.0, -10.0, 0.0);
1684        p.restitution = 0.8;
1685        p.max_lifetime = 10.0;
1686        pool.spawn(p);
1687
1688        let arena = ArenaCollider::new(Vec3::new(-50.0, 0.0, -50.0), Vec3::new(50.0, 50.0, 50.0));
1689        let mut sim = DebrisSimulator::new(arena);
1690        sim.enable_particle_collision = false;
1691
1692        sim.step(0.1, &mut pool);
1693
1694        let particle = pool.iter_alive().next().unwrap();
1695        // Should have bounced upward.
1696        assert!(particle.velocity.y > 0.0, "particle should bounce up, vy={}", particle.velocity.y);
1697    }
1698
1699    #[test]
1700    fn simulator_fire_buoyancy() {
1701        let mut pool = DebrisPool::with_capacity(10);
1702        let mut p = DebrisParticle::new('F', DebrisType::Fire);
1703        p.position = Vec3::new(0.0, 5.0, 0.0);
1704        p.velocity = Vec3::ZERO;
1705        p.max_lifetime = 10.0;
1706        pool.spawn(p);
1707
1708        let arena = ArenaCollider::new(Vec3::new(-50.0, 0.0, -50.0), Vec3::new(50.0, 50.0, 50.0));
1709        let mut sim = DebrisSimulator::new(arena);
1710        sim.enable_particle_collision = false;
1711
1712        // After a few steps, fire debris should rise (buoyancy > gravity).
1713        for _ in 0..5 {
1714            sim.step(0.1, &mut pool);
1715        }
1716
1717        let particle = pool.iter_alive().next().unwrap();
1718        // Fire buoyancy (6.0) partially counteracts gravity (9.81),
1719        // net downward acceleration is small. Velocity should be less
1720        // negative than pure gravity would produce.
1721        // With buoyancy: net_accel_y = -9.81 + 6.0 = -3.81
1722        // After 0.5s: vy ~ -1.9 (vs -4.9 without buoyancy)
1723        assert!(particle.velocity.y > -3.0,
1724            "fire debris should have reduced downward velocity, vy={}", particle.velocity.y);
1725    }
1726
1727    #[test]
1728    fn simulator_settling() {
1729        let mut pool = DebrisPool::with_capacity(10);
1730        let mut p = DebrisParticle::new('S', DebrisType::Normal);
1731        p.position = Vec3::new(0.0, 0.0, 0.0);
1732        p.velocity = Vec3::ZERO;
1733        p.max_lifetime = 0.1; // very short lifetime to trigger settling quickly
1734        pool.spawn(p);
1735
1736        let arena = ArenaCollider::new(Vec3::new(-50.0, 0.0, -50.0), Vec3::new(50.0, 50.0, 50.0));
1737        let mut sim = DebrisSimulator::new(arena);
1738        sim.enable_particle_collision = false;
1739
1740        // Step past max_lifetime.
1741        for _ in 0..5 {
1742            sim.step(0.1, &mut pool);
1743        }
1744
1745        let particle = pool.iter_alive().next().unwrap();
1746        assert!(particle.settled, "particle should be settled");
1747    }
1748
1749    #[test]
1750    fn simulator_settling_fades_and_expires() {
1751        let mut pool = DebrisPool::with_capacity(10);
1752        let mut p = DebrisParticle::new('E', DebrisType::Normal);
1753        p.position = Vec3::new(0.0, 0.0, 0.0);
1754        p.velocity = Vec3::ZERO;
1755        p.max_lifetime = 0.0; // immediate settling
1756        pool.spawn(p);
1757
1758        let arena = ArenaCollider::new(Vec3::new(-50.0, 0.0, -50.0), Vec3::new(50.0, 50.0, 50.0));
1759        let mut sim = DebrisSimulator::new(arena);
1760        sim.enable_particle_collision = false;
1761
1762        // Step enough to exhaust settle fade duration.
1763        for _ in 0..30 {
1764            sim.step(0.1, &mut pool);
1765        }
1766
1767        // After reclaim, should be dead.
1768        assert_eq!(pool.alive_count(), 0, "expired particle should have been reclaimed");
1769    }
1770
1771    // ── DeathEffect tests ────────────────────────────────────────────────
1772
1773    #[test]
1774    fn death_effect_produces_debris_and_trauma() {
1775        let mut effect = DeathEffect::new(999);
1776        let mut pool = DebrisPool::new();
1777        let event = sample_event();
1778
1779        let (count, trauma, cue) = effect.execute(&event, &mut pool);
1780        assert!(count > 0, "death effect should spawn debris");
1781        assert!(trauma.trauma > 0.0, "death effect should produce camera trauma");
1782        assert!(!cue.name.is_empty(), "death effect should produce a sound cue");
1783    }
1784
1785    #[test]
1786    fn death_effect_fire() {
1787        let mut effect = DeathEffect::new(42);
1788        let mut pool = DebrisPool::new();
1789        let mut event = sample_event();
1790        event.death_type = DebrisType::Fire;
1791
1792        let (count, _trauma, cue) = effect.execute(&event, &mut pool);
1793        assert!(count > 0);
1794        assert_eq!(cue.name, "death_fire");
1795    }
1796
1797    #[test]
1798    fn death_effect_ice() {
1799        let mut effect = DeathEffect::new(42);
1800        let mut pool = DebrisPool::new();
1801        let mut event = sample_event();
1802        event.death_type = DebrisType::Ice;
1803
1804        let (count, _trauma, cue) = effect.execute(&event, &mut pool);
1805        assert!(count > 0);
1806        assert_eq!(cue.name, "death_ice_shatter");
1807    }
1808
1809    #[test]
1810    fn death_effect_lightning() {
1811        let mut effect = DeathEffect::new(42);
1812        let mut pool = DebrisPool::new();
1813        let mut event = sample_event();
1814        event.death_type = DebrisType::Lightning;
1815
1816        let (count, trauma, cue) = effect.execute(&event, &mut pool);
1817        assert!(count > 0);
1818        assert!(trauma.trauma >= 0.5, "lightning should have high trauma");
1819        assert_eq!(cue.name, "death_lightning");
1820    }
1821
1822    #[test]
1823    fn death_effect_all_types() {
1824        let types = [
1825            DebrisType::Normal,
1826            DebrisType::Fire,
1827            DebrisType::Ice,
1828            DebrisType::Lightning,
1829            DebrisType::Poison,
1830            DebrisType::Holy,
1831            DebrisType::Dark,
1832            DebrisType::Bleed,
1833        ];
1834        for dt in types {
1835            let mut effect = DeathEffect::new(42);
1836            let mut pool = DebrisPool::new();
1837            let mut event = sample_event();
1838            event.death_type = dt;
1839            let (count, trauma, cue) = effect.execute(&event, &mut pool);
1840            assert!(count > 0, "death type {:?} should spawn debris", dt);
1841            assert!(trauma.trauma > 0.0, "death type {:?} should produce trauma", dt);
1842            assert!(!cue.name.is_empty(), "death type {:?} should have sound cue", dt);
1843        }
1844    }
1845
1846    // ── Renderer tests ───────────────────────────────────────────────────
1847
1848    #[test]
1849    fn renderer_builds_instances() {
1850        let mut pool = DebrisPool::with_capacity(10);
1851        for i in 0..5 {
1852            let mut p = DebrisParticle::new('R', DebrisType::Normal);
1853            p.position = Vec3::new(i as f32, 1.0, 0.0);
1854            pool.spawn(p);
1855        }
1856
1857        let mut renderer = DebrisRenderer::new();
1858        let instances = renderer.build_instances(&pool);
1859        assert_eq!(instances.len(), 5);
1860    }
1861
1862    #[test]
1863    fn renderer_skips_faded() {
1864        let mut pool = DebrisPool::with_capacity(10);
1865        let mut p = DebrisParticle::new('F', DebrisType::Normal);
1866        p.settled = true;
1867        p.fade_time = SETTLE_FADE_DURATION + 0.1; // fully faded
1868        p.color[3] = 1.0;
1869        pool.spawn(p);
1870
1871        let mut renderer = DebrisRenderer::new();
1872        let instances = renderer.build_instances(&pool);
1873        assert_eq!(instances.len(), 0, "fully faded particle should not render");
1874    }
1875
1876    // ── Camera trauma tests ──────────────────────────────────────────────
1877
1878    #[test]
1879    fn camera_trauma_decays() {
1880        let mut trauma = CameraTrauma::default();
1881        trauma.add(1.0);
1882        assert!((trauma.trauma - 1.0).abs() < 0.01);
1883
1884        trauma.update(0.25);
1885        assert!(trauma.trauma < 1.0);
1886        assert!(trauma.trauma > 0.0);
1887    }
1888
1889    #[test]
1890    fn camera_trauma_clamps() {
1891        let mut trauma = CameraTrauma::default();
1892        trauma.add(0.5);
1893        trauma.add(0.8);
1894        assert!((trauma.trauma - 1.0).abs() < 0.01, "trauma should clamp to 1.0");
1895    }
1896
1897    #[test]
1898    fn camera_shake_quadratic() {
1899        let mut trauma = CameraTrauma::default();
1900        trauma.add(0.5);
1901        let shake = trauma.shake_amount();
1902        assert!((shake - 0.25).abs() < 0.01, "shake should be trauma^2 = 0.25");
1903    }
1904
1905    // ── DebrisType property tests ────────────────────────────────────────
1906
1907    #[test]
1908    fn debris_type_properties() {
1909        assert!(DebrisType::Fire.has_buoyancy());
1910        assert!(DebrisType::Holy.has_buoyancy());
1911        assert!(!DebrisType::Normal.has_buoyancy());
1912
1913        assert!(DebrisType::Poison.has_heavy_drag());
1914        assert!(DebrisType::Dark.has_heavy_drag());
1915
1916        assert!(DebrisType::Ice.shatters_on_impact());
1917        assert!(!DebrisType::Fire.shatters_on_impact());
1918
1919        assert!(DebrisType::Dark.sinks());
1920        assert!(DebrisType::Bleed.drips());
1921
1922        // Lightning should have the highest velocity multiplier.
1923        assert!(DebrisType::Lightning.velocity_multiplier() > DebrisType::Normal.velocity_multiplier());
1924    }
1925
1926    // ── DebrisParticle unit tests ────────────────────────────────────────
1927
1928    #[test]
1929    fn particle_effective_alpha() {
1930        let mut p = DebrisParticle::new('A', DebrisType::Normal);
1931        p.color[3] = 1.0;
1932        assert!((p.effective_alpha() - 1.0).abs() < 0.01);
1933
1934        p.settled = true;
1935        p.fade_time = 0.0;
1936        assert!((p.effective_alpha() - 1.0).abs() < 0.01);
1937
1938        p.fade_time = SETTLE_FADE_DURATION * 0.5;
1939        assert!((p.effective_alpha() - 0.5).abs() < 0.01);
1940
1941        p.fade_time = SETTLE_FADE_DURATION;
1942        assert!(p.effective_alpha() < 0.01);
1943    }
1944
1945    #[test]
1946    fn particle_is_expired() {
1947        let mut p = DebrisParticle::new('E', DebrisType::Normal);
1948        assert!(!p.is_expired());
1949
1950        p.settled = true;
1951        p.fade_time = SETTLE_FADE_DURATION + 0.01;
1952        assert!(p.is_expired());
1953
1954        let mut p2 = DebrisParticle::default(); // alive = false
1955        assert!(p2.is_expired());
1956
1957        p2.alive = true;
1958        p2.settled = false;
1959        assert!(!p2.is_expired());
1960    }
1961
1962    // ── EntityDeathEvent from entity ─────────────────────────────────────
1963
1964    #[test]
1965    fn death_event_from_entity() {
1966        let mut entity = AmorphousEntity::new("TestMob", Vec3::new(1.0, 2.0, 3.0));
1967        entity.formation_chars = vec!['@', '#'];
1968        entity.formation_colors = vec![
1969            Vec4::new(1.0, 0.0, 0.0, 1.0),
1970            Vec4::new(0.0, 1.0, 0.0, 1.0),
1971        ];
1972
1973        let event = EntityDeathEvent::from_entity(&entity, DebrisType::Fire);
1974        assert_eq!(event.position, Vec3::new(1.0, 2.0, 3.0));
1975        assert_eq!(event.glyphs.len(), 2);
1976        assert_eq!(event.colors.len(), 2);
1977        assert_eq!(event.death_type, DebrisType::Fire);
1978    }
1979
1980    // ── Integration: full death → simulate → render ──────────────────────
1981
1982    #[test]
1983    fn full_pipeline_integration() {
1984        let mut effect = DeathEffect::new(12345);
1985        let mut pool = DebrisPool::new();
1986        let event = sample_event();
1987
1988        // Spawn.
1989        let (count, _trauma, _cue) = effect.execute(&event, &mut pool);
1990        assert!(count > 0);
1991
1992        // Simulate several frames.
1993        let arena = ArenaCollider::new(Vec3::new(-50.0, 0.0, -50.0), Vec3::new(50.0, 50.0, 50.0));
1994        let mut sim = DebrisSimulator::new(arena);
1995        for _ in 0..60 {
1996            sim.step(1.0 / 60.0, &mut pool);
1997        }
1998
1999        // Render.
2000        let mut renderer = DebrisRenderer::new();
2001        let initial_count = renderer.build_instances(&pool).len();
2002        assert!(initial_count > 0, "should still have visible debris after 1 second");
2003
2004        // Continue simulating until all debris settles and fades.
2005        for _ in 0..300 {
2006            sim.step(1.0 / 60.0, &mut pool);
2007        }
2008
2009        // After ~5 seconds total, most debris should have expired.
2010        let late_count = renderer.build_instances(&pool).len();
2011        assert!(late_count < initial_count,
2012            "debris count should decrease over time");
2013    }
2014}