1use glam::Vec3;
21use std::collections::HashMap;
22
23use crate::physics::fluids::{cubic_kernel, cubic_kernel_grad, kernel_gradient, DensityGrid};
26
27const PI: f32 = std::f32::consts::PI;
30
31const MAX_PARTICLES: usize = 2000;
33
34const MAX_POOLS: usize = 50;
36
37const DEFAULT_SMOOTHING_RADIUS: f32 = 0.35;
39
40const DEFAULT_REST_DENSITY: f32 = 1000.0;
42
43const TAIT_STIFFNESS: f32 = 50.0;
45
46const TAIT_GAMMA: f32 = 7.0;
48
49const DEFAULT_VISCOSITY: f32 = 0.02;
51
52const DEFAULT_SURFACE_TENSION: f32 = 0.01;
54
55const GRAVITY: Vec3 = Vec3::new(0.0, -9.81, 0.0);
57
58const POOL_MERGE_DISTANCE: f32 = 0.6;
60
61const SETTLE_SPEED: f32 = 0.15;
63
64const MIN_POOL_DEPTH: f32 = 0.01;
66
67const FLOOR_Y: f32 = 0.0;
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74pub enum FluidType {
75 Blood,
77 Fire,
79 Ice,
81 Dark,
83 Holy,
85 Poison,
87 Healing,
89 Necro,
91}
92
93impl FluidType {
94 pub fn base_color(self) -> [f32; 4] {
96 match self {
97 FluidType::Blood => [0.7, 0.05, 0.05, 0.9],
98 FluidType::Fire => [1.0, 0.45, 0.05, 0.85],
99 FluidType::Ice => [0.3, 0.6, 0.95, 0.8],
100 FluidType::Dark => [0.25, 0.05, 0.3, 0.9],
101 FluidType::Holy => [1.0, 0.85, 0.2, 0.75],
102 FluidType::Poison => [0.2, 0.75, 0.1, 0.85],
103 FluidType::Healing => [0.3, 0.95, 0.4, 0.7],
104 FluidType::Necro => [0.35, 0.05, 0.45, 0.9],
105 }
106 }
107
108 pub fn emission(self) -> f32 {
110 match self {
111 FluidType::Blood => 0.0,
112 FluidType::Fire => 1.5,
113 FluidType::Ice => 0.3,
114 FluidType::Dark => 0.6,
115 FluidType::Holy => 2.0,
116 FluidType::Poison => 0.4,
117 FluidType::Healing => 1.2,
118 FluidType::Necro => 0.8,
119 }
120 }
121
122 pub fn default_lifetime(self) -> f32 {
124 match self {
125 FluidType::Blood => 4.0,
126 FluidType::Fire => 2.0,
127 FluidType::Ice => 6.0,
128 FluidType::Dark => 5.0,
129 FluidType::Holy => 3.0,
130 FluidType::Poison => 5.0,
131 FluidType::Healing => 3.5,
132 FluidType::Necro => 7.0,
133 }
134 }
135
136 pub fn default_viscosity(self) -> f32 {
138 match self {
139 FluidType::Blood => 0.04,
140 FluidType::Fire => 0.005,
141 FluidType::Ice => 0.08,
142 FluidType::Dark => 0.03,
143 FluidType::Holy => 0.005,
144 FluidType::Poison => 0.06,
145 FluidType::Healing => 0.01,
146 FluidType::Necro => 0.05,
147 }
148 }
149
150 pub fn external_bias(self) -> Vec3 {
153 match self {
154 FluidType::Blood => Vec3::ZERO,
155 FluidType::Fire => Vec3::new(0.0, 18.0, 0.0), FluidType::Ice => Vec3::new(0.0, -2.0, 0.0), FluidType::Dark => Vec3::new(0.0, -3.0, 0.0), FluidType::Holy => Vec3::new(0.0, 14.0, 0.0), FluidType::Poison => Vec3::new(0.0, 1.5, 0.0), FluidType::Healing => Vec3::new(0.0, 10.0, 0.0), FluidType::Necro => Vec3::new(0.0, -3.0, 0.0), }
163 }
164
165 pub fn default_temperature(self) -> f32 {
167 match self {
168 FluidType::Blood => 37.0,
169 FluidType::Fire => 800.0,
170 FluidType::Ice => -20.0,
171 FluidType::Dark => 15.0,
172 FluidType::Holy => 50.0,
173 FluidType::Poison => 25.0,
174 FluidType::Healing => 38.0,
175 FluidType::Necro => 5.0,
176 }
177 }
178
179 pub fn can_pool(self) -> bool {
181 match self {
182 FluidType::Fire | FluidType::Holy | FluidType::Healing => false,
183 _ => true,
184 }
185 }
186
187 pub fn drag(self) -> f32 {
189 match self {
190 FluidType::Blood => 0.5,
191 FluidType::Fire => 0.1,
192 FluidType::Ice => 0.7,
193 FluidType::Dark => 0.4,
194 FluidType::Holy => 0.1,
195 FluidType::Poison => 0.6,
196 FluidType::Healing => 0.15,
197 FluidType::Necro => 0.5,
198 }
199 }
200
201 pub fn sprite_size(self) -> f32 {
203 match self {
204 FluidType::Blood => 0.06,
205 FluidType::Fire => 0.10,
206 FluidType::Ice => 0.08,
207 FluidType::Dark => 0.09,
208 FluidType::Holy => 0.12,
209 FluidType::Poison => 0.07,
210 FluidType::Healing => 0.10,
211 FluidType::Necro => 0.08,
212 }
213 }
214}
215
216#[derive(Debug, Clone)]
220pub struct FluidParticle {
221 pub position: Vec3,
223 pub velocity: Vec3,
225 pub density: f32,
227 pub pressure: f32,
229 pub color: [f32; 4],
231 pub fluid_type: FluidType,
233 pub lifetime: f32,
235 pub viscosity: f32,
237 pub temperature: f32,
239 accel: Vec3,
241 mass: f32,
243 rest_density: f32,
245 neighbors: Vec<usize>,
247}
248
249impl FluidParticle {
250 pub fn new(position: Vec3, velocity: Vec3, fluid_type: FluidType) -> Self {
252 let color = fluid_type.base_color();
253 Self {
254 position,
255 velocity,
256 density: DEFAULT_REST_DENSITY,
257 pressure: 0.0,
258 color,
259 fluid_type,
260 lifetime: fluid_type.default_lifetime(),
261 viscosity: fluid_type.default_viscosity(),
262 temperature: fluid_type.default_temperature(),
263 accel: Vec3::ZERO,
264 mass: 1.0,
265 rest_density: DEFAULT_REST_DENSITY,
266 neighbors: Vec::new(),
267 }
268 }
269
270 pub fn with_lifetime(mut self, lt: f32) -> Self {
272 self.lifetime = lt;
273 self
274 }
275
276 pub fn with_mass(mut self, m: f32) -> Self {
278 self.mass = m;
279 self
280 }
281
282 pub fn alive(&self) -> bool {
284 self.lifetime > 0.0
285 }
286
287 pub fn life_fraction(&self) -> f32 {
289 (self.lifetime / self.fluid_type.default_lifetime()).clamp(0.0, 1.0)
290 }
291
292 pub fn speed(&self) -> f32 {
294 self.velocity.length()
295 }
296}
297
298struct SpatialHash {
306 inner: DensityGrid,
307 radius: f32,
308}
309
310impl SpatialHash {
311 fn new(cell_size: f32) -> Self {
312 Self {
313 inner: DensityGrid::new(cell_size),
314 radius: cell_size,
315 }
316 }
317
318 fn rebuild(&mut self, positions: &[Vec3]) {
319 self.inner.rebuild(positions);
320 }
321
322 fn query(&self, pos: Vec3) -> Vec<usize> {
323 self.inner.query_radius(pos, self.radius)
324 }
325}
326
327pub struct SPHSimulator {
335 pub h: f32,
337 pub rest_density: f32,
339 pub stiffness: f32,
341 pub gamma: f32,
343 pub viscosity: f32,
345 pub surface_tension: f32,
347 pub gravity: Vec3,
349 grid: SpatialHash,
351}
352
353impl SPHSimulator {
354 pub fn new() -> Self {
356 Self {
357 h: DEFAULT_SMOOTHING_RADIUS,
358 rest_density: DEFAULT_REST_DENSITY,
359 stiffness: TAIT_STIFFNESS,
360 gamma: TAIT_GAMMA,
361 viscosity: DEFAULT_VISCOSITY,
362 surface_tension: DEFAULT_SURFACE_TENSION,
363 gravity: GRAVITY,
364 grid: SpatialHash::new(DEFAULT_SMOOTHING_RADIUS),
365 }
366 }
367
368 pub fn with_smoothing_radius(mut self, h: f32) -> Self {
370 self.h = h;
371 self.grid = SpatialHash::new(h);
372 self
373 }
374
375 pub fn with_stiffness(mut self, b: f32) -> Self {
377 self.stiffness = b;
378 self
379 }
380
381 #[inline]
385 fn kernel(&self, r: f32) -> f32 {
386 cubic_kernel(r, self.h)
387 }
388
389 #[inline]
391 fn kernel_grad_scalar(&self, r: f32) -> f32 {
392 cubic_kernel_grad(r, self.h)
393 }
394
395 #[inline]
397 fn kernel_grad_vec(&self, r_vec: Vec3) -> Vec3 {
398 kernel_gradient(r_vec, self.h)
399 }
400
401 fn rebuild_grid(&mut self, particles: &[FluidParticle]) {
405 let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
406 self.grid.rebuild(&positions);
407 }
408
409 fn find_neighbors(&self, particles: &mut [FluidParticle]) {
411 let h = self.h;
412 let h_sq = h * h;
413 let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
414 for (i, p) in particles.iter_mut().enumerate() {
415 let candidates = self.grid.query(p.position);
416 p.neighbors.clear();
417 for &j in &candidates {
418 if j == i {
419 continue;
420 }
421 let diff = positions[i] - positions[j];
422 if diff.length_squared() < h_sq {
423 p.neighbors.push(j);
424 }
425 }
426 }
427 }
428
429 fn compute_density(&self, particles: &mut [FluidParticle]) {
431 let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
432 let masses: Vec<f32> = particles.iter().map(|p| p.mass).collect();
433 let neighbors_snapshot: Vec<Vec<usize>> =
434 particles.iter().map(|p| p.neighbors.clone()).collect();
435
436 for (i, p) in particles.iter_mut().enumerate() {
437 let mut rho = p.mass * self.kernel(0.0);
439 for &j in &neighbors_snapshot[i] {
440 let r = (positions[i] - positions[j]).length();
441 rho += masses[j] * self.kernel(r);
442 }
443 p.density = rho.max(1.0); }
445 }
446
447 fn compute_pressure(&self, particles: &mut [FluidParticle]) {
450 let b = self.stiffness;
451 let g = self.gamma;
452 for p in particles.iter_mut() {
453 let ratio = p.density / p.rest_density;
454 p.pressure = b * (ratio.powf(g) - 1.0);
455 if p.pressure < 0.0 {
456 p.pressure = 0.0;
457 }
458 }
459 }
460
461 fn compute_pressure_force(&self, particles: &mut [FluidParticle]) {
463 let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
464 let masses: Vec<f32> = particles.iter().map(|p| p.mass).collect();
465 let pressures: Vec<f32> = particles.iter().map(|p| p.pressure).collect();
466 let densities: Vec<f32> = particles.iter().map(|p| p.density).collect();
467 let neighbors_snapshot: Vec<Vec<usize>> =
468 particles.iter().map(|p| p.neighbors.clone()).collect();
469
470 for (i, p) in particles.iter_mut().enumerate() {
471 let mut accel = Vec3::ZERO;
472 let pi_over_rho2 = pressures[i] / (densities[i] * densities[i]);
473 for &j in &neighbors_snapshot[i] {
474 let pj_over_rho2 = pressures[j] / (densities[j] * densities[j]);
475 let r_vec = positions[i] - positions[j];
476 let grad_w = self.kernel_grad_vec(r_vec);
477 accel -= masses[j] * (pi_over_rho2 + pj_over_rho2) * grad_w;
478 }
479 p.accel += accel;
480 }
481 }
482
483 fn compute_viscosity_force(&self, particles: &mut [FluidParticle]) {
488 let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
489 let velocities: Vec<Vec3> = particles.iter().map(|p| p.velocity).collect();
490 let masses: Vec<f32> = particles.iter().map(|p| p.mass).collect();
491 let densities: Vec<f32> = particles.iter().map(|p| p.density).collect();
492 let viscosities: Vec<f32> = particles.iter().map(|p| p.viscosity).collect();
493 let neighbors_snapshot: Vec<Vec<usize>> =
494 particles.iter().map(|p| p.neighbors.clone()).collect();
495
496 let eps = 0.01 * self.h * self.h;
497
498 for (i, p) in particles.iter_mut().enumerate() {
499 let mut accel = Vec3::ZERO;
500 let mu = self.viscosity * viscosities[i];
501 for &j in &neighbors_snapshot[i] {
502 let r_vec = positions[i] - positions[j];
503 let v_diff = velocities[j] - velocities[i];
504 let r_dot_v = r_vec.dot(v_diff);
505 let r_len_sq = r_vec.length_squared() + eps;
506 let grad_w = self.kernel_grad_vec(r_vec);
507 let factor = 10.0 * masses[j] / densities[j] * r_dot_v / r_len_sq;
509 accel += mu * factor * grad_w;
510 }
511 p.accel += accel;
512 }
513 }
514
515 fn compute_surface_tension(&self, particles: &mut [FluidParticle]) {
519 let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
520 let masses: Vec<f32> = particles.iter().map(|p| p.mass).collect();
521 let densities: Vec<f32> = particles.iter().map(|p| p.density).collect();
522 let neighbors_snapshot: Vec<Vec<usize>> =
523 particles.iter().map(|p| p.neighbors.clone()).collect();
524
525 let sigma = self.surface_tension;
526 let threshold = 6.0 / self.h; let mut normals = vec![Vec3::ZERO; particles.len()];
530 for (i, _p) in particles.iter().enumerate() {
531 let mut n = Vec3::ZERO;
532 for &j in &neighbors_snapshot[i] {
533 let r_vec = positions[i] - positions[j];
534 let grad_w = self.kernel_grad_vec(r_vec);
535 n += (masses[j] / densities[j]) * grad_w;
536 }
537 normals[i] = n;
538 }
539
540 for (i, p) in particles.iter_mut().enumerate() {
542 let n_len = normals[i].length();
543 if n_len > threshold {
544 let curvature_dir = normals[i] / n_len;
546 p.accel -= sigma * n_len * curvature_dir;
547 }
548 }
549 }
550
551 fn apply_external_forces(&self, particles: &mut [FluidParticle]) {
553 for p in particles.iter_mut() {
554 p.accel += self.gravity;
556
557 p.accel += p.fluid_type.external_bias();
559
560 let drag = p.fluid_type.drag();
562 p.accel -= drag * p.velocity;
563 }
564 }
565
566 fn integrate(&self, particles: &mut [FluidParticle], dt: f32) {
571 for p in particles.iter_mut() {
572 p.velocity += p.accel * dt;
573
574 let max_speed = 20.0;
576 let speed = p.velocity.length();
577 if speed > max_speed {
578 p.velocity *= max_speed / speed;
579 }
580
581 p.position += p.velocity * dt;
582
583 if p.position.y < FLOOR_Y {
585 p.position.y = FLOOR_Y;
586 p.velocity.y = p.velocity.y.abs() * 0.2; }
588
589 p.accel = Vec3::ZERO;
591 }
592 }
593
594 pub fn step(&mut self, particles: &mut [FluidParticle], dt: f32) {
597 if particles.is_empty() {
598 return;
599 }
600 self.rebuild_grid(particles);
601 self.find_neighbors(particles);
602 self.compute_density(particles);
603 self.compute_pressure(particles);
604 self.compute_pressure_force(particles);
605 self.compute_viscosity_force(particles);
606 self.compute_surface_tension(particles);
607 self.apply_external_forces(particles);
608 self.integrate(particles, dt);
609 }
610}
611
612impl Default for SPHSimulator {
613 fn default() -> Self {
614 Self::new()
615 }
616}
617
618#[derive(Debug, Clone)]
622pub struct FluidPool {
623 pub position: Vec3,
625 pub radius: f32,
627 pub fluid_type: FluidType,
629 pub depth: f32,
631 pub age: f32,
633 pub max_lifetime: f32,
635 pub absorbed_count: u32,
637}
638
639impl FluidPool {
640 pub fn new(position: Vec3, radius: f32, fluid_type: FluidType) -> Self {
642 let max_lifetime = match fluid_type {
643 FluidType::Blood => 15.0,
644 FluidType::Fire => 8.0,
645 FluidType::Ice => 20.0,
646 FluidType::Dark => 25.0,
647 FluidType::Holy => 0.0, FluidType::Poison => 18.0,
649 FluidType::Healing => 0.0, FluidType::Necro => 30.0,
651 };
652 Self {
653 position: Vec3::new(position.x, FLOOR_Y, position.z),
654 radius,
655 fluid_type,
656 depth: MIN_POOL_DEPTH,
657 age: 0.0,
658 max_lifetime,
659 absorbed_count: 1,
660 }
661 }
662
663 pub fn absorb_particle(&mut self) {
665 self.absorbed_count += 1;
666 self.radius += 0.005;
668 self.depth += 0.002;
669 self.depth = self.depth.min(0.2); }
671
672 pub fn area(&self) -> f32 {
674 PI * self.radius * self.radius
675 }
676
677 pub fn contains_xz(&self, point: Vec3) -> bool {
679 let dx = point.x - self.position.x;
680 let dz = point.z - self.position.z;
681 dx * dx + dz * dz <= self.radius * self.radius
682 }
683
684 pub fn alive(&self) -> bool {
686 if self.max_lifetime <= 0.0 {
687 return true; }
689 self.age < self.max_lifetime
690 }
691
692 pub fn life_fraction(&self) -> f32 {
694 if self.max_lifetime <= 0.0 {
695 return 1.0;
696 }
697 (1.0 - self.age / self.max_lifetime).clamp(0.0, 1.0)
698 }
699
700 pub fn color(&self) -> [f32; 4] {
702 let mut c = self.fluid_type.base_color();
703 let f = self.life_fraction();
704 c[3] *= f; c
706 }
707
708 pub fn update(&mut self, dt: f32) {
710 self.age += dt;
711 }
712
713 pub fn merge_from(&mut self, other: &FluidPool) {
715 let total = self.absorbed_count + other.absorbed_count;
717 if total > 0 {
718 let w_self = self.absorbed_count as f32 / total as f32;
719 let w_other = other.absorbed_count as f32 / total as f32;
720 self.position = self.position * w_self + other.position * w_other;
721 }
722 let combined_area = self.area() + other.area();
724 self.radius = (combined_area / PI).sqrt();
725 self.depth = self.depth.max(other.depth);
726 self.absorbed_count += other.absorbed_count;
727 }
728
729 pub fn distance_to(&self, other: &FluidPool) -> f32 {
731 let dx = self.position.x - other.position.x;
732 let dz = self.position.z - other.position.z;
733 (dx * dx + dz * dz).sqrt()
734 }
735}
736
737pub struct FluidSpawner;
741
742impl FluidSpawner {
743 pub fn spawn_bleed(
745 particles: &mut Vec<FluidParticle>,
746 entity_pos: Vec3,
747 direction: Vec3,
748 count: usize,
749 ) {
750 let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
751 let dir = if direction.length_squared() > 0.001 {
752 direction.normalize()
753 } else {
754 Vec3::new(0.0, -1.0, 0.0)
755 };
756 for i in 0..count {
757 let t = i as f32 / count.max(1) as f32;
758 let spread = Vec3::new(
759 pseudo_random(i as f32 * 1.1) * 0.3 - 0.15,
760 pseudo_random(i as f32 * 2.3) * 0.1,
761 pseudo_random(i as f32 * 3.7) * 0.3 - 0.15,
762 );
763 let vel = dir * (1.5 + t * 0.5) + Vec3::new(0.0, -2.0, 0.0) + spread;
764 let p = FluidParticle::new(entity_pos + spread * 0.1, vel, FluidType::Blood);
765 particles.push(p);
766 }
767 }
768
769 pub fn spawn_fire_pool(
771 particles: &mut Vec<FluidParticle>,
772 position: Vec3,
773 radius: f32,
774 count: usize,
775 ) {
776 let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
777 for i in 0..count {
778 let angle = pseudo_random(i as f32 * 4.1) * 2.0 * PI;
779 let r = pseudo_random(i as f32 * 5.3) * radius;
780 let offset = Vec3::new(angle.cos() * r, 0.0, angle.sin() * r);
781 let vel = Vec3::new(
782 pseudo_random(i as f32 * 6.7) * 0.5 - 0.25,
783 2.0 + pseudo_random(i as f32 * 7.1) * 3.0,
784 pseudo_random(i as f32 * 8.3) * 0.5 - 0.25,
785 );
786 let p = FluidParticle::new(position + offset, vel, FluidType::Fire);
787 particles.push(p);
788 }
789 }
790
791 pub fn spawn_ice_spread(
793 particles: &mut Vec<FluidParticle>,
794 position: Vec3,
795 radius: f32,
796 count: usize,
797 ) {
798 let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
799 for i in 0..count {
800 let angle = pseudo_random(i as f32 * 9.1) * 2.0 * PI;
801 let spread_speed = 0.5 + pseudo_random(i as f32 * 10.3) * 1.5;
802 let vel = Vec3::new(
803 angle.cos() * spread_speed,
804 -0.1,
805 angle.sin() * spread_speed,
806 );
807 let offset = Vec3::new(
808 pseudo_random(i as f32 * 11.7) * radius * 0.2,
809 0.05,
810 pseudo_random(i as f32 * 12.3) * radius * 0.2,
811 );
812 let p = FluidParticle::new(
813 Vec3::new(position.x, FLOOR_Y + 0.05, position.z) + offset,
814 vel,
815 FluidType::Ice,
816 );
817 particles.push(p);
818 }
819 }
820
821 pub fn spawn_healing_fountain(
823 particles: &mut Vec<FluidParticle>,
824 position: Vec3,
825 count: usize,
826 ) {
827 let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
828 for i in 0..count {
829 let angle = pseudo_random(i as f32 * 13.1) * 2.0 * PI;
830 let r = pseudo_random(i as f32 * 14.3) * 0.15;
831 let vel = Vec3::new(
832 angle.cos() * r * 2.0,
833 4.0 + pseudo_random(i as f32 * 15.7) * 3.0,
834 angle.sin() * r * 2.0,
835 );
836 let p = FluidParticle::new(position, vel, FluidType::Healing);
837 particles.push(p);
838 }
839 }
840
841 pub fn spawn_ouroboros_flow(
845 particles: &mut Vec<FluidParticle>,
846 from_pos: Vec3,
847 to_pos: Vec3,
848 count: usize,
849 ) {
850 let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
851 let dir = to_pos - from_pos;
852 let dist = dir.length();
853 let dir_norm = if dist > 0.001 { dir / dist } else { Vec3::X };
854
855 for i in 0..count {
856 let t = i as f32 / count.max(1) as f32;
857 let spawn_pos = from_pos + dir * t * 0.3;
859 let speed = 3.0 + pseudo_random(i as f32 * 16.1) * 2.0;
860 let wobble = Vec3::new(
861 pseudo_random(i as f32 * 17.3) * 0.5 - 0.25,
862 pseudo_random(i as f32 * 18.7) * 0.3 - 0.15,
863 pseudo_random(i as f32 * 19.1) * 0.5 - 0.25,
864 );
865 let vel = dir_norm * speed + wobble;
866 let mut p = FluidParticle::new(spawn_pos, vel, FluidType::Dark);
867 p.lifetime = (dist / speed).max(1.0);
868 particles.push(p);
869 }
870 }
871
872 pub fn spawn_necro_crawl(
874 particles: &mut Vec<FluidParticle>,
875 origin: Vec3,
876 corpse_positions: &[Vec3],
877 particles_per_corpse: usize,
878 ) {
879 if corpse_positions.is_empty() {
880 return;
881 }
882 for (ci, &corpse) in corpse_positions.iter().enumerate() {
883 let remaining = MAX_PARTICLES.saturating_sub(particles.len());
884 let count = particles_per_corpse.min(remaining);
885 if count == 0 {
886 break;
887 }
888 let dir = corpse - origin;
889 let dist = dir.length();
890 let dir_norm = if dist > 0.001 { dir / dist } else { Vec3::X };
891
892 for i in 0..count {
893 let speed = 1.0 + pseudo_random((ci * 100 + i) as f32 * 20.1) * 2.0;
894 let wobble = Vec3::new(
895 pseudo_random((ci * 100 + i) as f32 * 21.3) * 0.4 - 0.2,
896 0.0,
897 pseudo_random((ci * 100 + i) as f32 * 22.7) * 0.4 - 0.2,
898 );
899 let vel = dir_norm * speed + wobble;
900 let p = FluidParticle::new(
901 Vec3::new(origin.x, FLOOR_Y + 0.03, origin.z),
902 vel,
903 FluidType::Necro,
904 );
905 particles.push(p);
906 }
907 }
908 }
909
910 pub fn spawn_poison_bubbles(
912 particles: &mut Vec<FluidParticle>,
913 position: Vec3,
914 count: usize,
915 ) {
916 let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
917 for i in 0..count {
918 let angle = pseudo_random(i as f32 * 23.1) * 2.0 * PI;
919 let r = pseudo_random(i as f32 * 24.3) * 0.3;
920 let offset = Vec3::new(angle.cos() * r, 0.0, angle.sin() * r);
921 let vel = Vec3::new(
922 pseudo_random(i as f32 * 25.7) * 0.3 - 0.15,
923 0.5 + pseudo_random(i as f32 * 26.1) * 1.0,
924 pseudo_random(i as f32 * 27.3) * 0.3 - 0.15,
925 );
926 let p = FluidParticle::new(position + offset, vel, FluidType::Poison);
927 particles.push(p);
928 }
929 }
930
931 pub fn spawn_holy_rise(
933 particles: &mut Vec<FluidParticle>,
934 position: Vec3,
935 count: usize,
936 ) {
937 let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
938 for i in 0..count {
939 let angle = pseudo_random(i as f32 * 28.1) * 2.0 * PI;
940 let r = pseudo_random(i as f32 * 29.3) * 0.2;
941 let vel = Vec3::new(
942 angle.cos() * r * 1.5,
943 5.0 + pseudo_random(i as f32 * 30.7) * 2.0,
944 angle.sin() * r * 1.5,
945 );
946 let p = FluidParticle::new(position, vel, FluidType::Holy);
947 particles.push(p);
948 }
949 }
950}
951
952#[inline]
955fn pseudo_random(seed: f32) -> f32 {
956 let x = (seed * 12.9898 + 78.233).sin() * 43758.5453;
957 x - x.floor()
958}
959
960#[derive(Debug, Clone, Copy)]
964pub struct FluidSpriteData {
965 pub position: Vec3,
967 pub color: [f32; 4],
969 pub size: f32,
971 pub emission: f32,
973}
974
975pub struct FluidRenderer {
977 pub size_scale: f32,
979 pub emission_scale: f32,
981}
982
983impl FluidRenderer {
984 pub fn new() -> Self {
985 Self {
986 size_scale: 1.0,
987 emission_scale: 1.0,
988 }
989 }
990
991 pub fn extract_sprites(&self, particles: &[FluidParticle]) -> Vec<FluidSpriteData> {
993 let mut sprites = Vec::with_capacity(particles.len());
994 for p in particles {
995 if !p.alive() {
996 continue;
997 }
998 let life = p.life_fraction();
999 let mut color = p.color;
1000 color[3] *= life; let size = p.fluid_type.sprite_size() * self.size_scale * (0.5 + 0.5 * life);
1002 let emission = p.fluid_type.emission() * self.emission_scale * life;
1003 sprites.push(FluidSpriteData {
1004 position: p.position,
1005 color,
1006 size,
1007 emission,
1008 });
1009 }
1010 sprites
1011 }
1012
1013 pub fn extract_pool_sprites(&self, pools: &[FluidPool]) -> Vec<FluidSpriteData> {
1015 let mut sprites = Vec::with_capacity(pools.len());
1016 for pool in pools {
1017 if !pool.alive() {
1018 continue;
1019 }
1020 sprites.push(FluidSpriteData {
1021 position: pool.position,
1022 color: pool.color(),
1023 size: pool.radius * 2.0 * self.size_scale,
1024 emission: pool.fluid_type.emission() * self.emission_scale * pool.life_fraction(),
1025 });
1026 }
1027 sprites
1028 }
1029}
1030
1031impl Default for FluidRenderer {
1032 fn default() -> Self {
1033 Self::new()
1034 }
1035}
1036
1037#[derive(Debug, Clone, Copy, PartialEq)]
1041pub enum FluidStatusEffect {
1042 DamageOverTime {
1044 damage_per_second: f32,
1045 element: FluidType,
1046 },
1047 BleedAmplify { multiplier: f32 },
1049 Slow { factor: f32 },
1051 ManaDrain { drain_per_second: f32 },
1053 HealOverTime { heal_per_second: f32 },
1055 NecroEmpower { speed_multiplier: f32 },
1057}
1058
1059pub struct FluidGameplayEffects;
1063
1064impl FluidGameplayEffects {
1065 pub fn query_effects(pools: &[FluidPool], entity_pos: Vec3) -> Vec<FluidStatusEffect> {
1068 let mut effects = Vec::new();
1069 for pool in pools {
1070 if !pool.alive() {
1071 continue;
1072 }
1073 if !pool.contains_xz(entity_pos) {
1074 continue;
1075 }
1076 let intensity = pool.depth / 0.1; match pool.fluid_type {
1078 FluidType::Blood => {
1079 effects.push(FluidStatusEffect::BleedAmplify {
1080 multiplier: 1.0 + 0.5 * intensity,
1081 });
1082 }
1083 FluidType::Fire => {
1084 effects.push(FluidStatusEffect::DamageOverTime {
1085 damage_per_second: 15.0 * intensity,
1086 element: FluidType::Fire,
1087 });
1088 }
1089 FluidType::Ice => {
1090 effects.push(FluidStatusEffect::Slow {
1091 factor: (0.3 + 0.2 * intensity).min(0.8),
1092 });
1093 }
1094 FluidType::Dark => {
1095 effects.push(FluidStatusEffect::ManaDrain {
1096 drain_per_second: 10.0 * intensity,
1097 });
1098 }
1099 FluidType::Poison => {
1100 effects.push(FluidStatusEffect::DamageOverTime {
1101 damage_per_second: 8.0 * intensity,
1102 element: FluidType::Poison,
1103 });
1104 }
1105 FluidType::Healing => {
1106 effects.push(FluidStatusEffect::HealOverTime {
1107 heal_per_second: 12.0 * intensity,
1108 });
1109 }
1110 FluidType::Necro => {
1111 effects.push(FluidStatusEffect::NecroEmpower {
1112 speed_multiplier: 1.0 + 1.0 * intensity,
1113 });
1114 }
1115 FluidType::Holy => {
1116 }
1119 }
1120 }
1121 effects
1122 }
1123
1124 pub fn total_dot(effects: &[FluidStatusEffect]) -> f32 {
1126 let mut total = 0.0;
1127 for e in effects {
1128 if let FluidStatusEffect::DamageOverTime { damage_per_second, .. } = e {
1129 total += damage_per_second;
1130 }
1131 }
1132 total
1133 }
1134
1135 pub fn strongest_slow(effects: &[FluidStatusEffect]) -> f32 {
1137 let mut max_slow = 0.0_f32;
1138 for e in effects {
1139 if let FluidStatusEffect::Slow { factor } = e {
1140 max_slow = max_slow.max(*factor);
1141 }
1142 }
1143 max_slow
1144 }
1145
1146 pub fn total_mana_drain(effects: &[FluidStatusEffect]) -> f32 {
1148 let mut total = 0.0;
1149 for e in effects {
1150 if let FluidStatusEffect::ManaDrain { drain_per_second } = e {
1151 total += drain_per_second;
1152 }
1153 }
1154 total
1155 }
1156
1157 pub fn total_heal(effects: &[FluidStatusEffect]) -> f32 {
1159 let mut total = 0.0;
1160 for e in effects {
1161 if let FluidStatusEffect::HealOverTime { heal_per_second } = e {
1162 total += heal_per_second;
1163 }
1164 }
1165 total
1166 }
1167
1168 pub fn bleed_multiplier(effects: &[FluidStatusEffect]) -> f32 {
1170 let mut mult = 1.0;
1171 for e in effects {
1172 if let FluidStatusEffect::BleedAmplify { multiplier } = e {
1173 mult *= multiplier;
1174 }
1175 }
1176 mult
1177 }
1178}
1179
1180pub struct FluidManager {
1185 pub particles: Vec<FluidParticle>,
1187 pub pools: Vec<FluidPool>,
1189 pub simulator: SPHSimulator,
1191 pub renderer: FluidRenderer,
1193 pub time: f32,
1195 pub fixed_dt: f32,
1197 time_accumulator: f32,
1199}
1200
1201impl FluidManager {
1202 pub fn new() -> Self {
1204 Self {
1205 particles: Vec::with_capacity(MAX_PARTICLES),
1206 pools: Vec::with_capacity(MAX_POOLS),
1207 simulator: SPHSimulator::new(),
1208 renderer: FluidRenderer::new(),
1209 time: 0.0,
1210 fixed_dt: 1.0 / 60.0,
1211 time_accumulator: 0.0,
1212 }
1213 }
1214
1215 pub fn particle_count(&self) -> usize {
1217 self.particles.len()
1218 }
1219
1220 pub fn pool_count(&self) -> usize {
1222 self.pools.len()
1223 }
1224
1225 pub fn update(&mut self, dt: f32) {
1227 self.time += dt;
1228 self.time_accumulator += dt;
1229
1230 while self.time_accumulator >= self.fixed_dt {
1232 self.simulator.step(&mut self.particles, self.fixed_dt);
1233 self.time_accumulator -= self.fixed_dt;
1234 }
1235
1236 for p in &mut self.particles {
1238 p.lifetime -= dt;
1239 }
1240
1241 for pool in &mut self.pools {
1243 pool.update(dt);
1244 }
1245
1246 self.settle_particles_to_pools();
1248
1249 self.merge_pools();
1251
1252 self.particles.retain(|p| p.alive());
1254
1255 self.pools.retain(|p| p.alive());
1257
1258 while self.particles.len() > MAX_PARTICLES {
1260 if let Some(min_idx) = self
1262 .particles
1263 .iter()
1264 .enumerate()
1265 .min_by(|a, b| a.1.lifetime.partial_cmp(&b.1.lifetime).unwrap())
1266 .map(|(i, _)| i)
1267 {
1268 self.particles.swap_remove(min_idx);
1269 } else {
1270 break;
1271 }
1272 }
1273
1274 while self.pools.len() > MAX_POOLS {
1275 if let Some(min_idx) = self
1277 .pools
1278 .iter()
1279 .enumerate()
1280 .max_by(|a, b| a.1.age.partial_cmp(&b.1.age).unwrap())
1281 .map(|(i, _)| i)
1282 {
1283 self.pools.swap_remove(min_idx);
1284 } else {
1285 break;
1286 }
1287 }
1288 }
1289
1290 fn settle_particles_to_pools(&mut self) {
1293 let mut settled_indices = Vec::new();
1294 let mut new_pool_data: Vec<(Vec3, FluidType)> = Vec::new();
1295
1296 for (i, p) in self.particles.iter().enumerate() {
1297 if !p.fluid_type.can_pool() {
1298 continue;
1299 }
1300 if p.position.y > FLOOR_Y + 0.1 {
1301 continue;
1302 }
1303 if p.speed() > SETTLE_SPEED {
1304 continue;
1305 }
1306 let mut found_pool = false;
1308 for pool in &mut self.pools {
1309 if pool.fluid_type != p.fluid_type {
1310 continue;
1311 }
1312 let dx = p.position.x - pool.position.x;
1313 let dz = p.position.z - pool.position.z;
1314 if dx * dx + dz * dz < (pool.radius + 0.3) * (pool.radius + 0.3) {
1315 pool.absorb_particle();
1316 found_pool = true;
1317 break;
1318 }
1319 }
1320 if !found_pool {
1321 new_pool_data.push((p.position, p.fluid_type));
1322 }
1323 settled_indices.push(i);
1324 }
1325
1326 settled_indices.sort_unstable_by(|a, b| b.cmp(a));
1328 for idx in settled_indices {
1329 self.particles.swap_remove(idx);
1330 }
1331
1332 for (pos, ft) in new_pool_data {
1334 if self.pools.len() < MAX_POOLS {
1335 let mut pool = FluidPool::new(pos, 0.1, ft);
1336 pool.absorb_particle();
1337 self.pools.push(pool);
1338 }
1339 }
1340 }
1341
1342 fn merge_pools(&mut self) {
1344 if self.pools.len() < 2 {
1345 return;
1346 }
1347 let mut merged = vec![false; self.pools.len()];
1348 let mut i = 0;
1349 while i < self.pools.len() {
1350 if merged[i] {
1351 i += 1;
1352 continue;
1353 }
1354 let mut j = i + 1;
1355 while j < self.pools.len() {
1356 if merged[j] {
1357 j += 1;
1358 continue;
1359 }
1360 if self.pools[i].fluid_type != self.pools[j].fluid_type {
1361 j += 1;
1362 continue;
1363 }
1364 let dist = self.pools[i].distance_to(&self.pools[j]);
1365 if dist < POOL_MERGE_DISTANCE {
1366 let other = self.pools[j].clone();
1368 self.pools[i].merge_from(&other);
1369 merged[j] = true;
1370 }
1371 j += 1;
1372 }
1373 i += 1;
1374 }
1375
1376 let mut idx = self.pools.len();
1378 while idx > 0 {
1379 idx -= 1;
1380 if merged[idx] {
1381 self.pools.swap_remove(idx);
1382 }
1383 }
1384 }
1385
1386 pub fn particle_sprites(&self) -> Vec<FluidSpriteData> {
1388 self.renderer.extract_sprites(&self.particles)
1389 }
1390
1391 pub fn pool_sprites(&self) -> Vec<FluidSpriteData> {
1393 self.renderer.extract_pool_sprites(&self.pools)
1394 }
1395
1396 pub fn query_effects_at(&self, pos: Vec3) -> Vec<FluidStatusEffect> {
1398 FluidGameplayEffects::query_effects(&self.pools, pos)
1399 }
1400
1401 pub fn spawn_bleed(&mut self, entity_pos: Vec3, direction: Vec3, count: usize) {
1405 FluidSpawner::spawn_bleed(&mut self.particles, entity_pos, direction, count);
1406 }
1407
1408 pub fn spawn_fire_pool(&mut self, position: Vec3, radius: f32, count: usize) {
1410 FluidSpawner::spawn_fire_pool(&mut self.particles, position, radius, count);
1411 }
1412
1413 pub fn spawn_ice_spread(&mut self, position: Vec3, radius: f32, count: usize) {
1415 FluidSpawner::spawn_ice_spread(&mut self.particles, position, radius, count);
1416 }
1417
1418 pub fn spawn_healing_fountain(&mut self, position: Vec3, count: usize) {
1420 FluidSpawner::spawn_healing_fountain(&mut self.particles, position, count);
1421 }
1422
1423 pub fn spawn_ouroboros_flow(&mut self, from_pos: Vec3, to_pos: Vec3, count: usize) {
1425 FluidSpawner::spawn_ouroboros_flow(&mut self.particles, from_pos, to_pos, count);
1426 }
1427
1428 pub fn spawn_necro_crawl(
1430 &mut self,
1431 origin: Vec3,
1432 corpse_positions: &[Vec3],
1433 particles_per_corpse: usize,
1434 ) {
1435 FluidSpawner::spawn_necro_crawl(
1436 &mut self.particles,
1437 origin,
1438 corpse_positions,
1439 particles_per_corpse,
1440 );
1441 }
1442
1443 pub fn spawn_poison_bubbles(&mut self, position: Vec3, count: usize) {
1445 FluidSpawner::spawn_poison_bubbles(&mut self.particles, position, count);
1446 }
1447
1448 pub fn spawn_holy_rise(&mut self, position: Vec3, count: usize) {
1450 FluidSpawner::spawn_holy_rise(&mut self.particles, position, count);
1451 }
1452
1453 pub fn clear(&mut self) {
1455 self.particles.clear();
1456 self.pools.clear();
1457 }
1458}
1459
1460impl Default for FluidManager {
1461 fn default() -> Self {
1462 Self::new()
1463 }
1464}
1465
1466#[cfg(test)]
1469mod tests {
1470 use super::*;
1471
1472 #[test]
1475 fn test_kernel_at_zero_is_positive() {
1476 let sim = SPHSimulator::new();
1477 let w = sim.kernel(0.0);
1478 assert!(w > 0.0, "Kernel at r=0 should be positive, got {w}");
1479 }
1480
1481 #[test]
1482 fn test_kernel_at_h_is_zero() {
1483 let sim = SPHSimulator::new();
1484 let w = sim.kernel(sim.h);
1485 assert!(
1486 w.abs() < 1e-5,
1487 "Kernel at r=h should be ~0, got {w}"
1488 );
1489 }
1490
1491 #[test]
1492 fn test_kernel_beyond_h_is_zero() {
1493 let sim = SPHSimulator::new();
1494 let w = sim.kernel(sim.h * 1.5);
1495 assert_eq!(w, 0.0, "Kernel beyond h should be exactly 0");
1496 }
1497
1498 #[test]
1499 fn test_kernel_monotone_decreasing() {
1500 let sim = SPHSimulator::new();
1501 let mut prev = sim.kernel(0.0);
1502 for i in 1..20 {
1503 let r = sim.h * i as f32 / 20.0;
1504 let w = sim.kernel(r);
1505 assert!(
1506 w <= prev + 1e-6,
1507 "Kernel should be monotonically decreasing: W({r}) = {w} > W_prev = {prev}"
1508 );
1509 prev = w;
1510 }
1511 }
1512
1513 #[test]
1516 fn test_density_single_particle() {
1517 let mut sim = SPHSimulator::new();
1518 let mut particles = vec![FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Blood)];
1519 sim.rebuild_grid(&particles);
1520 sim.find_neighbors(&mut particles);
1521 sim.compute_density(&mut particles);
1522 assert!(
1524 particles[0].density > 0.0,
1525 "Single particle density should be > 0, got {}",
1526 particles[0].density
1527 );
1528 }
1529
1530 #[test]
1531 fn test_density_increases_with_nearby_particles() {
1532 let mut sim = SPHSimulator::new();
1533 let mut single = vec![FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Blood)];
1534 sim.rebuild_grid(&single);
1535 sim.find_neighbors(&mut single);
1536 sim.compute_density(&mut single);
1537 let single_density = single[0].density;
1538
1539 let mut pair = vec![
1540 FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Blood),
1541 FluidParticle::new(
1542 Vec3::new(sim.h * 0.3, 0.0, 0.0),
1543 Vec3::ZERO,
1544 FluidType::Blood,
1545 ),
1546 ];
1547 sim.rebuild_grid(&pair);
1548 sim.find_neighbors(&mut pair);
1549 sim.compute_density(&mut pair);
1550 assert!(
1551 pair[0].density > single_density,
1552 "Density with neighbour ({}) should exceed single ({})",
1553 pair[0].density,
1554 single_density
1555 );
1556 }
1557
1558 #[test]
1561 fn test_pressure_at_rest_density() {
1562 let sim = SPHSimulator::new();
1563 let mut p = FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Blood);
1564 p.density = sim.rest_density;
1565 let mut particles = vec![p];
1566 sim.compute_pressure(&mut particles);
1567 assert!(
1568 particles[0].pressure.abs() < 1e-3,
1569 "Pressure at rest density should be ~0, got {}",
1570 particles[0].pressure
1571 );
1572 }
1573
1574 #[test]
1575 fn test_pressure_positive_above_rest() {
1576 let sim = SPHSimulator::new();
1577 let mut p = FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Blood);
1578 p.density = sim.rest_density * 1.5;
1579 let mut particles = vec![p];
1580 sim.compute_pressure(&mut particles);
1581 assert!(
1582 particles[0].pressure > 0.0,
1583 "Pressure above rest density should be positive, got {}",
1584 particles[0].pressure
1585 );
1586 }
1587
1588 #[test]
1591 fn test_pool_contains_xz() {
1592 let pool = FluidPool::new(Vec3::new(1.0, 0.0, 2.0), 0.5, FluidType::Blood);
1593 assert!(pool.contains_xz(Vec3::new(1.0, 0.5, 2.0)));
1594 assert!(pool.contains_xz(Vec3::new(1.3, 0.0, 2.0)));
1595 assert!(!pool.contains_xz(Vec3::new(2.0, 0.0, 2.0)));
1596 }
1597
1598 #[test]
1599 fn test_pool_absorb_grows() {
1600 let mut pool = FluidPool::new(Vec3::ZERO, 0.1, FluidType::Ice);
1601 let r0 = pool.radius;
1602 let d0 = pool.depth;
1603 pool.absorb_particle();
1604 assert!(pool.radius > r0);
1605 assert!(pool.depth > d0);
1606 assert_eq!(pool.absorbed_count, 2); }
1608
1609 #[test]
1610 fn test_pool_merge() {
1611 let mut a = FluidPool::new(Vec3::new(0.0, 0.0, 0.0), 0.2, FluidType::Blood);
1612 a.absorbed_count = 5;
1613 let mut b = FluidPool::new(Vec3::new(0.3, 0.0, 0.0), 0.15, FluidType::Blood);
1614 b.absorbed_count = 3;
1615 let area_before = a.area() + b.area();
1616 a.merge_from(&b);
1617 let area_after = a.area();
1618 assert!(
1619 (area_after - area_before).abs() < 1e-4,
1620 "Merged area should be sum of individual areas"
1621 );
1622 assert_eq!(a.absorbed_count, 8);
1623 }
1624
1625 #[test]
1626 fn test_pool_lifetime() {
1627 let mut pool = FluidPool::new(Vec3::ZERO, 0.5, FluidType::Blood);
1628 assert!(pool.alive());
1629 pool.age = pool.max_lifetime + 1.0;
1630 assert!(!pool.alive());
1631 }
1632
1633 #[test]
1636 fn test_fire_cannot_pool() {
1637 assert!(!FluidType::Fire.can_pool());
1638 }
1639
1640 #[test]
1641 fn test_blood_can_pool() {
1642 assert!(FluidType::Blood.can_pool());
1643 }
1644
1645 #[test]
1646 fn test_holy_cannot_pool() {
1647 assert!(!FluidType::Holy.can_pool());
1648 }
1649
1650 #[test]
1653 fn test_spawn_bleed_creates_particles() {
1654 let mut particles = Vec::new();
1655 FluidSpawner::spawn_bleed(
1656 &mut particles,
1657 Vec3::new(0.0, 2.0, 0.0),
1658 Vec3::new(1.0, 0.0, 0.0),
1659 10,
1660 );
1661 assert_eq!(particles.len(), 10);
1662 for p in &particles {
1663 assert_eq!(p.fluid_type, FluidType::Blood);
1664 }
1665 }
1666
1667 #[test]
1668 fn test_spawn_respects_max_particles() {
1669 let mut particles = Vec::new();
1670 for _ in 0..(MAX_PARTICLES - 5) {
1672 particles.push(FluidParticle::new(
1673 Vec3::ZERO,
1674 Vec3::ZERO,
1675 FluidType::Blood,
1676 ));
1677 }
1678 FluidSpawner::spawn_bleed(
1679 &mut particles,
1680 Vec3::ZERO,
1681 Vec3::Y,
1682 100,
1683 );
1684 assert!(
1685 particles.len() <= MAX_PARTICLES,
1686 "Should not exceed MAX_PARTICLES"
1687 );
1688 }
1689
1690 #[test]
1691 fn test_spawn_healing_fountain() {
1692 let mut particles = Vec::new();
1693 FluidSpawner::spawn_healing_fountain(&mut particles, Vec3::new(0.0, 0.5, 0.0), 20);
1694 assert_eq!(particles.len(), 20);
1695 for p in &particles {
1696 assert_eq!(p.fluid_type, FluidType::Healing);
1697 assert!(p.velocity.y > 0.0, "Healing fountain should go up");
1699 }
1700 }
1701
1702 #[test]
1703 fn test_spawn_ouroboros_flow() {
1704 let mut particles = Vec::new();
1705 let from = Vec3::new(-5.0, 1.0, 0.0);
1706 let to = Vec3::new(5.0, 1.0, 0.0);
1707 FluidSpawner::spawn_ouroboros_flow(&mut particles, from, to, 15);
1708 assert_eq!(particles.len(), 15);
1709 for p in &particles {
1710 assert_eq!(p.fluid_type, FluidType::Dark);
1711 assert!(p.velocity.x > 0.0, "Ouroboros should flow toward target");
1713 }
1714 }
1715
1716 #[test]
1717 fn test_spawn_necro_crawl() {
1718 let mut particles = Vec::new();
1719 let origin = Vec3::ZERO;
1720 let corpses = vec![
1721 Vec3::new(3.0, 0.0, 0.0),
1722 Vec3::new(-2.0, 0.0, 1.0),
1723 ];
1724 FluidSpawner::spawn_necro_crawl(&mut particles, origin, &corpses, 5);
1725 assert_eq!(particles.len(), 10); for p in &particles {
1727 assert_eq!(p.fluid_type, FluidType::Necro);
1728 }
1729 }
1730
1731 #[test]
1734 fn test_blood_pool_bleed_amplify() {
1735 let pool = FluidPool::new(Vec3::ZERO, 1.0, FluidType::Blood);
1736 let effects = FluidGameplayEffects::query_effects(
1737 &[pool],
1738 Vec3::new(0.5, 0.0, 0.0),
1739 );
1740 let mult = FluidGameplayEffects::bleed_multiplier(&effects);
1741 assert!(mult > 1.0, "Blood pool should amplify bleed, got {mult}");
1742 }
1743
1744 #[test]
1745 fn test_ice_pool_slow() {
1746 let pool = FluidPool::new(Vec3::ZERO, 1.0, FluidType::Ice);
1747 let effects = FluidGameplayEffects::query_effects(
1748 &[pool],
1749 Vec3::new(0.3, 0.0, 0.3),
1750 );
1751 let slow = FluidGameplayEffects::strongest_slow(&effects);
1752 assert!(slow > 0.0, "Ice pool should slow, got {slow}");
1753 }
1754
1755 #[test]
1756 fn test_fire_pool_dot() {
1757 let pool = FluidPool::new(Vec3::ZERO, 1.0, FluidType::Fire);
1758 let effects = FluidGameplayEffects::query_effects(
1759 &[pool],
1760 Vec3::new(0.0, 0.0, 0.0),
1761 );
1762 let dot = FluidGameplayEffects::total_dot(&effects);
1763 assert!(dot > 0.0, "Fire pool should deal DoT, got {dot}");
1764 }
1765
1766 #[test]
1767 fn test_dark_pool_mana_drain() {
1768 let pool = FluidPool::new(Vec3::ZERO, 1.0, FluidType::Dark);
1769 let effects = FluidGameplayEffects::query_effects(
1770 &[pool],
1771 Vec3::new(0.0, 0.0, 0.0),
1772 );
1773 let drain = FluidGameplayEffects::total_mana_drain(&effects);
1774 assert!(drain > 0.0, "Dark pool should drain mana, got {drain}");
1775 }
1776
1777 #[test]
1778 fn test_no_effect_outside_pool() {
1779 let pool = FluidPool::new(Vec3::ZERO, 0.5, FluidType::Fire);
1780 let effects = FluidGameplayEffects::query_effects(
1781 &[pool],
1782 Vec3::new(5.0, 0.0, 5.0),
1783 );
1784 assert!(effects.is_empty(), "Should have no effects outside pool");
1785 }
1786
1787 #[test]
1790 fn test_manager_spawn_and_update() {
1791 let mut mgr = FluidManager::new();
1792 mgr.spawn_bleed(Vec3::new(0.0, 2.0, 0.0), Vec3::Y, 20);
1793 assert_eq!(mgr.particle_count(), 20);
1794 mgr.update(0.016);
1795 assert!(mgr.particle_count() > 0);
1797 }
1798
1799 #[test]
1800 fn test_manager_particles_die_over_time() {
1801 let mut mgr = FluidManager::new();
1802 mgr.spawn_bleed(Vec3::new(0.0, 2.0, 0.0), Vec3::Y, 10);
1803 for _ in 0..300 {
1805 mgr.update(0.016);
1806 }
1807 assert_eq!(
1808 mgr.particle_count(),
1809 0,
1810 "All blood particles should have died"
1811 );
1812 }
1813
1814 #[test]
1815 fn test_manager_clear() {
1816 let mut mgr = FluidManager::new();
1817 mgr.spawn_bleed(Vec3::ZERO, Vec3::Y, 50);
1818 mgr.pools
1819 .push(FluidPool::new(Vec3::ZERO, 1.0, FluidType::Blood));
1820 mgr.clear();
1821 assert_eq!(mgr.particle_count(), 0);
1822 assert_eq!(mgr.pool_count(), 0);
1823 }
1824
1825 #[test]
1828 fn test_renderer_extracts_alive_only() {
1829 let renderer = FluidRenderer::new();
1830 let mut alive = FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Fire);
1831 alive.lifetime = 1.0;
1832 let mut dead = FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Fire);
1833 dead.lifetime = -1.0;
1834 let sprites = renderer.extract_sprites(&[alive, dead]);
1835 assert_eq!(sprites.len(), 1, "Should only render alive particles");
1836 }
1837
1838 #[test]
1839 fn test_pseudo_random_in_range() {
1840 for i in 0..100 {
1841 let v = pseudo_random(i as f32 * 0.7);
1842 assert!(v >= 0.0 && v < 1.0, "pseudo_random out of range: {v}");
1843 }
1844 }
1845
1846 #[test]
1849 fn test_sph_step_does_not_explode() {
1850 let mut sim = SPHSimulator::new();
1851 let mut particles: Vec<FluidParticle> = (0..50)
1852 .map(|i| {
1853 let x = (i % 10) as f32 * 0.05;
1854 let y = (i / 10) as f32 * 0.05 + 1.0;
1855 FluidParticle::new(Vec3::new(x, y, 0.0), Vec3::ZERO, FluidType::Blood)
1856 })
1857 .collect();
1858
1859 for _ in 0..10 {
1860 sim.step(&mut particles, 1.0 / 60.0);
1861 }
1862
1863 for p in &particles {
1864 let speed = p.velocity.length();
1865 assert!(
1866 speed < 100.0,
1867 "Particle velocity exploded: speed = {speed}"
1868 );
1869 assert!(
1870 p.position.length() < 100.0,
1871 "Particle position exploded: {:?}",
1872 p.position
1873 );
1874 }
1875 }
1876}