1use glam::{Vec2, Vec3, Vec4, Quat};
14use crate::glyph::batch::GlyphInstance;
15use crate::entity::AmorphousEntity;
16use crate::procedural::Rng;
17
18const GRAVITY: Vec3 = Vec3::new(0.0, -9.81, 0.0);
22
23const POOL_CAPACITY: usize = 500;
25
26const DEFAULT_ARENA_HALF: Vec3 = Vec3::new(50.0, 50.0, 50.0);
28
29const REST_VELOCITY_THRESHOLD: f32 = 0.08;
32
33const SETTLE_FADE_DURATION: f32 = 1.0;
35
36const SETTLE_ALIVE_MIN: f32 = 2.0;
38const SETTLE_ALIVE_MAX: f32 = 3.0;
39
40const COLLISION_EPSILON: f32 = 0.001;
42
43const PARTICLE_COLLISION_RADIUS: f32 = 0.15;
45
46const FIRE_BUOYANCY: f32 = 6.0;
48
49const SLOW_DRAG: f32 = 3.0;
51
52const SPAWN_MIN: usize = 10;
54
55const SPAWN_MAX: usize = 50;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum DebrisType {
63 Normal,
65 Fire,
67 Ice,
69 Lightning,
71 Poison,
73 Holy,
75 Dark,
77 Bleed,
79}
80
81impl DebrisType {
82 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 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 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 pub fn has_buoyancy(self) -> bool {
126 matches!(self, DebrisType::Fire | DebrisType::Holy)
127 }
128
129 pub fn has_heavy_drag(self) -> bool {
131 matches!(self, DebrisType::Poison | DebrisType::Dark)
132 }
133
134 pub fn shatters_on_impact(self) -> bool {
136 matches!(self, DebrisType::Ice)
137 }
138
139 pub fn sinks(self) -> bool {
141 matches!(self, DebrisType::Dark)
142 }
143
144 pub fn drips(self) -> bool {
146 matches!(self, DebrisType::Bleed)
147 }
148}
149
150#[derive(Clone, Debug)]
154pub struct DebrisParticle {
155 pub glyph: char,
157
158 pub position: Vec3,
160
161 pub velocity: Vec3,
163
164 pub angular_velocity: Vec3,
166
167 pub mass: f32,
169
170 pub restitution: f32,
172
173 pub friction: f32,
175
176 pub lifetime: f32,
178
179 pub max_lifetime: f32,
181
182 pub color: [f32; 4],
184
185 pub scale: f32,
187
188 pub rotation: Quat,
190
191 pub debris_type: DebrisType,
193
194 pub alive: bool,
196
197 pub has_shattered: bool,
200
201 pub emission: f32,
203
204 pub glow_color: Vec3,
206
207 pub settled: bool,
209
210 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 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 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 pub fn is_expired(&self) -> bool {
265 !self.alive || (self.settled && self.fade_time >= SETTLE_FADE_DURATION)
266 }
267
268 pub fn kill(&mut self) {
270 self.alive = false;
271 }
272}
273
274#[derive(Clone, Debug)]
278pub struct EntityDeathEvent {
279 pub position: Vec3,
281
282 pub glyphs: Vec<char>,
284
285 pub colors: Vec<[f32; 4]>,
287
288 pub death_type: DebrisType,
290}
291
292impl EntityDeathEvent {
293 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
308pub 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 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 apply_element_visuals(&mut particle, event.death_type);
347
348 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 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 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 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 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 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; 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 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
535fn 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); 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); 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); 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); 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); 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); 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); 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
581fn 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#[derive(Debug, Clone, Copy)]
595pub struct CollisionResult {
596 pub normal: Vec3,
598 pub penetration: f32,
600}
601
602#[derive(Clone, Debug)]
605pub struct ArenaCollider {
606 pub min: Vec3,
608 pub max: Vec3,
610 pub floor_y: f32,
612 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 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 pub fn test_particle(&self, position: Vec3) -> Option<CollisionResult> {
641 let mut deepest: Option<CollisionResult> = None;
642
643 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 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 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 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 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 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 pub fn on_floor(&self, position: Vec3) -> bool {
702 position.y <= self.floor_y + COLLISION_EPSILON
703 }
704}
705
706fn deeper(existing: Option<CollisionResult>, candidate: CollisionResult) -> CollisionResult {
708 match existing {
709 Some(e) if e.penetration >= candidate.penetration => e,
710 _ => candidate,
711 }
712}
713
714pub struct DebrisPool {
718 particles: Vec<DebrisParticle>,
719 alive_count: usize,
721}
722
723impl DebrisPool {
724 pub fn new() -> Self {
726 Self::with_capacity(POOL_CAPACITY)
727 }
728
729 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 pub fn spawn(&mut self, particle: DebrisParticle) -> bool {
743 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 pub fn alive_count(&self) -> usize {
757 self.alive_count
758 }
759
760 pub fn capacity(&self) -> usize {
762 self.particles.len()
763 }
764
765 pub fn iter_alive(&self) -> impl Iterator<Item = &DebrisParticle> {
767 self.particles.iter().filter(|p| p.alive)
768 }
769
770 pub fn iter_alive_mut(&mut self) -> impl Iterator<Item = &mut DebrisParticle> {
772 self.particles.iter_mut().filter(|p| p.alive)
773 }
774
775 pub fn particles(&self) -> &[DebrisParticle] {
777 &self.particles
778 }
779
780 pub fn particles_mut(&mut self) -> &mut [DebrisParticle] {
782 &mut self.particles
783 }
784
785 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 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
814pub struct DebrisSimulator {
819 pub arena: ArenaCollider,
821
822 pub gravity: Vec3,
824
825 pub enable_particle_collision: bool,
827
828 shatter_queue: Vec<ShatterRequest>,
830}
831
832#[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 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 for i in 0..len {
868 if !particles[i].alive {
869 continue;
870 }
871
872 let p = &mut particles[i];
873
874 p.lifetime += dt;
876
877 if !p.settled && p.lifetime >= p.max_lifetime {
879 p.settled = true;
880 }
881
882 if p.settled {
884 p.fade_time += dt;
885 p.velocity.y -= 1.0 * dt;
887 p.velocity *= (1.0 - 2.0 * dt).max(0.0);
888 p.position += p.velocity * dt;
889 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 if p.debris_type.has_buoyancy() {
901 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 p.velocity += self.gravity * 1.5 * dt;
907 } else if p.debris_type.drips() {
908 p.velocity += self.gravity * 1.3 * dt;
910 } else {
911 p.velocity += self.gravity * dt;
912 }
913
914 if p.debris_type.has_heavy_drag() {
916 let drag_force = -p.velocity * SLOW_DRAG * dt;
917 p.velocity += drag_force;
918 }
919
920 p.position += p.velocity * dt;
922
923 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 p.angular_velocity *= (1.0 - 0.5 * dt).max(0.0);
933
934 if let Some(hit) = self.arena.test_particle(p.position) {
936 p.position += hit.normal * (hit.penetration + COLLISION_EPSILON);
938
939 let vn = p.velocity.dot(hit.normal);
941 if vn < 0.0 {
942 p.velocity -= hit.normal * vn * (1.0 + p.restitution);
944
945 if hit.normal.y > 0.5 {
947 let tangential = p.velocity - hit.normal * p.velocity.dot(hit.normal);
949 p.velocity -= tangential * p.friction * dt * 10.0;
950
951 if p.velocity.length_squared() < REST_VELOCITY_THRESHOLD * REST_VELOCITY_THRESHOLD {
953 p.velocity = Vec3::ZERO;
954 }
955 }
956
957 p.angular_velocity *= 0.7;
959
960 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 if p.debris_type.sinks() && p.position.y < self.arena.floor_y {
975 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 if self.enable_particle_collision {
987 self.solve_particle_collisions(pool.particles_mut());
988 }
989
990 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 pool.reclaim_dead();
1022 }
1023
1024 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 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 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
1068fn 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
1079pub struct DebrisRenderer {
1084 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 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 let (_, rot_y, _) = quat_to_euler(p.rotation);
1114 let _ = rot_y; 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 pub fn instance_count(&self) -> usize {
1137 self.instances.len()
1138 }
1139}
1140
1141fn quat_to_euler(q: Quat) -> (f32, f32, f32) {
1143 let (x, y, z, w) = (q.x, q.y, q.z, q.w);
1144
1145 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 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 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#[derive(Clone, Debug)]
1170pub struct CameraTrauma {
1171 pub trauma: f32,
1173 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 pub fn add(&mut self, amount: f32) {
1189 self.trauma = (self.trauma + amount).clamp(0.0, 1.0);
1190 }
1191
1192 pub fn shake_amount(&self) -> f32 {
1194 self.trauma * self.trauma
1195 }
1196
1197 pub fn update(&mut self, dt: f32) {
1199 self.trauma = (self.trauma - self.decay_rate * dt).max(0.0);
1200 }
1201}
1202
1203#[derive(Clone, Debug)]
1205pub struct SoundCue {
1206 pub name: String,
1208 pub volume: f32,
1210 pub pitch: f32,
1212}
1213
1214pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[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 #[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 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 #[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 assert!(count >= 6, "expected >= 6 shatter particles, got {}", count);
1613 assert!(count <= 10, "expected <= 10 shatter particles, got {}", count);
1614 }
1615
1616 #[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 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 #[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 for _ in 0..10 {
1670 sim.step(0.1, &mut pool);
1671 }
1672
1673 let particle = pool.iter_alive().next().unwrap();
1674 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 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 for _ in 0..5 {
1714 sim.step(0.1, &mut pool);
1715 }
1716
1717 let particle = pool.iter_alive().next().unwrap();
1718 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; 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 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; 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 for _ in 0..30 {
1764 sim.step(0.1, &mut pool);
1765 }
1766
1767 assert_eq!(pool.alive_count(), 0, "expired particle should have been reclaimed");
1769 }
1770
1771 #[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 #[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; 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 #[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 #[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 assert!(DebrisType::Lightning.velocity_multiplier() > DebrisType::Normal.velocity_multiplier());
1924 }
1925
1926 #[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(); assert!(p2.is_expired());
1956
1957 p2.alive = true;
1958 p2.settled = false;
1959 assert!(!p2.is_expired());
1960 }
1961
1962 #[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 #[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 let (count, _trauma, _cue) = effect.execute(&event, &mut pool);
1990 assert!(count > 0);
1991
1992 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 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 for _ in 0..300 {
2006 sim.step(1.0 / 60.0, &mut pool);
2007 }
2008
2009 let late_count = renderer.build_instances(&pool).len();
2011 assert!(late_count < initial_count,
2012 "debris count should decrease over time");
2013 }
2014}