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]
391 fn kernel(&self, r: f32) -> f32 {
392 cubic_kernel(r, self.h * 0.5)
393 }
394
395 #[inline]
397 fn kernel_grad_scalar(&self, r: f32) -> f32 {
398 cubic_kernel_grad(r, self.h * 0.5)
399 }
400
401 #[inline]
403 fn kernel_grad_vec(&self, r_vec: Vec3) -> Vec3 {
404 kernel_gradient(r_vec, self.h * 0.5)
405 }
406
407 fn rebuild_grid(&mut self, particles: &[FluidParticle]) {
411 let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
412 self.grid.rebuild(&positions);
413 }
414
415 fn find_neighbors(&self, particles: &mut [FluidParticle]) {
417 let h = self.h;
418 let h_sq = h * h;
419 let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
420 for (i, p) in particles.iter_mut().enumerate() {
421 let candidates = self.grid.query(p.position);
422 p.neighbors.clear();
423 for &j in &candidates {
424 if j == i {
425 continue;
426 }
427 let diff = positions[i] - positions[j];
428 if diff.length_squared() < h_sq {
429 p.neighbors.push(j);
430 }
431 }
432 }
433 }
434
435 fn compute_density(&self, particles: &mut [FluidParticle]) {
437 let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
438 let masses: Vec<f32> = particles.iter().map(|p| p.mass).collect();
439 let neighbors_snapshot: Vec<Vec<usize>> =
440 particles.iter().map(|p| p.neighbors.clone()).collect();
441
442 for (i, p) in particles.iter_mut().enumerate() {
443 let mut rho = p.mass * self.kernel(0.0);
445 for &j in &neighbors_snapshot[i] {
446 let r = (positions[i] - positions[j]).length();
447 rho += masses[j] * self.kernel(r);
448 }
449 p.density = rho.max(1.0); }
451 }
452
453 fn compute_pressure(&self, particles: &mut [FluidParticle]) {
456 let b = self.stiffness;
457 let g = self.gamma;
458 for p in particles.iter_mut() {
459 let ratio = p.density / p.rest_density;
460 p.pressure = b * (ratio.powf(g) - 1.0);
461 if p.pressure < 0.0 {
462 p.pressure = 0.0;
463 }
464 }
465 }
466
467 fn compute_pressure_force(&self, particles: &mut [FluidParticle]) {
469 let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
470 let masses: Vec<f32> = particles.iter().map(|p| p.mass).collect();
471 let pressures: Vec<f32> = particles.iter().map(|p| p.pressure).collect();
472 let densities: Vec<f32> = particles.iter().map(|p| p.density).collect();
473 let neighbors_snapshot: Vec<Vec<usize>> =
474 particles.iter().map(|p| p.neighbors.clone()).collect();
475
476 for (i, p) in particles.iter_mut().enumerate() {
477 let mut accel = Vec3::ZERO;
478 let pi_over_rho2 = pressures[i] / (densities[i] * densities[i]);
479 for &j in &neighbors_snapshot[i] {
480 let pj_over_rho2 = pressures[j] / (densities[j] * densities[j]);
481 let r_vec = positions[i] - positions[j];
482 let grad_w = self.kernel_grad_vec(r_vec);
483 accel -= masses[j] * (pi_over_rho2 + pj_over_rho2) * grad_w;
484 }
485 p.accel += accel;
486 }
487 }
488
489 fn compute_viscosity_force(&self, particles: &mut [FluidParticle]) {
494 let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
495 let velocities: Vec<Vec3> = particles.iter().map(|p| p.velocity).collect();
496 let masses: Vec<f32> = particles.iter().map(|p| p.mass).collect();
497 let densities: Vec<f32> = particles.iter().map(|p| p.density).collect();
498 let viscosities: Vec<f32> = particles.iter().map(|p| p.viscosity).collect();
499 let neighbors_snapshot: Vec<Vec<usize>> =
500 particles.iter().map(|p| p.neighbors.clone()).collect();
501
502 let eps = 0.01 * self.h * self.h;
503
504 for (i, p) in particles.iter_mut().enumerate() {
505 let mut accel = Vec3::ZERO;
506 let mu = self.viscosity * viscosities[i];
507 for &j in &neighbors_snapshot[i] {
508 let r_vec = positions[i] - positions[j];
509 let v_diff = velocities[j] - velocities[i];
510 let r_dot_v = r_vec.dot(v_diff);
511 let r_len_sq = r_vec.length_squared() + eps;
512 let grad_w = self.kernel_grad_vec(r_vec);
513 let factor = 10.0 * masses[j] / densities[j] * r_dot_v / r_len_sq;
515 accel += mu * factor * grad_w;
516 }
517 p.accel += accel;
518 }
519 }
520
521 fn compute_surface_tension(&self, particles: &mut [FluidParticle]) {
525 let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
526 let masses: Vec<f32> = particles.iter().map(|p| p.mass).collect();
527 let densities: Vec<f32> = particles.iter().map(|p| p.density).collect();
528 let neighbors_snapshot: Vec<Vec<usize>> =
529 particles.iter().map(|p| p.neighbors.clone()).collect();
530
531 let sigma = self.surface_tension;
532 let threshold = 6.0 / self.h; let mut normals = vec![Vec3::ZERO; particles.len()];
536 for (i, _p) in particles.iter().enumerate() {
537 let mut n = Vec3::ZERO;
538 for &j in &neighbors_snapshot[i] {
539 let r_vec = positions[i] - positions[j];
540 let grad_w = self.kernel_grad_vec(r_vec);
541 n += (masses[j] / densities[j]) * grad_w;
542 }
543 normals[i] = n;
544 }
545
546 for (i, p) in particles.iter_mut().enumerate() {
548 let n_len = normals[i].length();
549 if n_len > threshold {
550 let curvature_dir = normals[i] / n_len;
552 p.accel -= sigma * n_len * curvature_dir;
553 }
554 }
555 }
556
557 fn apply_external_forces(&self, particles: &mut [FluidParticle]) {
559 for p in particles.iter_mut() {
560 p.accel += self.gravity;
562
563 p.accel += p.fluid_type.external_bias();
565
566 let drag = p.fluid_type.drag();
568 p.accel -= drag * p.velocity;
569 }
570 }
571
572 fn integrate(&self, particles: &mut [FluidParticle], dt: f32) {
577 for p in particles.iter_mut() {
578 p.velocity += p.accel * dt;
579
580 let max_speed = 20.0;
582 let speed = p.velocity.length();
583 if speed > max_speed {
584 p.velocity *= max_speed / speed;
585 }
586
587 p.position += p.velocity * dt;
588
589 if p.position.y < FLOOR_Y {
591 p.position.y = FLOOR_Y;
592 p.velocity.y = p.velocity.y.abs() * 0.2; }
594
595 p.accel = Vec3::ZERO;
597 }
598 }
599
600 pub fn step(&mut self, particles: &mut [FluidParticle], dt: f32) {
603 if particles.is_empty() {
604 return;
605 }
606 self.rebuild_grid(particles);
607 self.find_neighbors(particles);
608 self.compute_density(particles);
609 self.compute_pressure(particles);
610 self.compute_pressure_force(particles);
611 self.compute_viscosity_force(particles);
612 self.compute_surface_tension(particles);
613 self.apply_external_forces(particles);
614 self.integrate(particles, dt);
615 }
616}
617
618impl Default for SPHSimulator {
619 fn default() -> Self {
620 Self::new()
621 }
622}
623
624#[derive(Debug, Clone)]
628pub struct FluidPool {
629 pub position: Vec3,
631 pub radius: f32,
633 pub fluid_type: FluidType,
635 pub depth: f32,
637 pub age: f32,
639 pub max_lifetime: f32,
641 pub absorbed_count: u32,
643}
644
645impl FluidPool {
646 pub fn new(position: Vec3, radius: f32, fluid_type: FluidType) -> Self {
648 let max_lifetime = match fluid_type {
649 FluidType::Blood => 15.0,
650 FluidType::Fire => 8.0,
651 FluidType::Ice => 20.0,
652 FluidType::Dark => 25.0,
653 FluidType::Holy => 0.0, FluidType::Poison => 18.0,
655 FluidType::Healing => 0.0, FluidType::Necro => 30.0,
657 };
658 Self {
659 position: Vec3::new(position.x, FLOOR_Y, position.z),
660 radius,
661 fluid_type,
662 depth: MIN_POOL_DEPTH,
663 age: 0.0,
664 max_lifetime,
665 absorbed_count: 1,
666 }
667 }
668
669 pub fn absorb_particle(&mut self) {
671 self.absorbed_count += 1;
672 self.radius += 0.005;
674 self.depth += 0.002;
675 self.depth = self.depth.min(0.2); }
677
678 pub fn area(&self) -> f32 {
680 PI * self.radius * self.radius
681 }
682
683 pub fn contains_xz(&self, point: Vec3) -> bool {
685 let dx = point.x - self.position.x;
686 let dz = point.z - self.position.z;
687 dx * dx + dz * dz <= self.radius * self.radius
688 }
689
690 pub fn alive(&self) -> bool {
692 if self.max_lifetime <= 0.0 {
693 return true; }
695 self.age < self.max_lifetime
696 }
697
698 pub fn life_fraction(&self) -> f32 {
700 if self.max_lifetime <= 0.0 {
701 return 1.0;
702 }
703 (1.0 - self.age / self.max_lifetime).clamp(0.0, 1.0)
704 }
705
706 pub fn color(&self) -> [f32; 4] {
708 let mut c = self.fluid_type.base_color();
709 let f = self.life_fraction();
710 c[3] *= f; c
712 }
713
714 pub fn update(&mut self, dt: f32) {
716 self.age += dt;
717 }
718
719 pub fn merge_from(&mut self, other: &FluidPool) {
721 let total = self.absorbed_count + other.absorbed_count;
723 if total > 0 {
724 let w_self = self.absorbed_count as f32 / total as f32;
725 let w_other = other.absorbed_count as f32 / total as f32;
726 self.position = self.position * w_self + other.position * w_other;
727 }
728 let combined_area = self.area() + other.area();
730 self.radius = (combined_area / PI).sqrt();
731 self.depth = self.depth.max(other.depth);
732 self.absorbed_count += other.absorbed_count;
733 }
734
735 pub fn distance_to(&self, other: &FluidPool) -> f32 {
737 let dx = self.position.x - other.position.x;
738 let dz = self.position.z - other.position.z;
739 (dx * dx + dz * dz).sqrt()
740 }
741}
742
743pub struct FluidSpawner;
747
748impl FluidSpawner {
749 pub fn spawn_bleed(
751 particles: &mut Vec<FluidParticle>,
752 entity_pos: Vec3,
753 direction: Vec3,
754 count: usize,
755 ) {
756 let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
757 let dir = if direction.length_squared() > 0.001 {
758 direction.normalize()
759 } else {
760 Vec3::new(0.0, -1.0, 0.0)
761 };
762 for i in 0..count {
763 let t = i as f32 / count.max(1) as f32;
764 let spread = Vec3::new(
765 pseudo_random(i as f32 * 1.1) * 0.3 - 0.15,
766 pseudo_random(i as f32 * 2.3) * 0.1,
767 pseudo_random(i as f32 * 3.7) * 0.3 - 0.15,
768 );
769 let vel = dir * (1.5 + t * 0.5) + Vec3::new(0.0, -2.0, 0.0) + spread;
770 let p = FluidParticle::new(entity_pos + spread * 0.1, vel, FluidType::Blood);
771 particles.push(p);
772 }
773 }
774
775 pub fn spawn_fire_pool(
777 particles: &mut Vec<FluidParticle>,
778 position: Vec3,
779 radius: f32,
780 count: usize,
781 ) {
782 let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
783 for i in 0..count {
784 let angle = pseudo_random(i as f32 * 4.1) * 2.0 * PI;
785 let r = pseudo_random(i as f32 * 5.3) * radius;
786 let offset = Vec3::new(angle.cos() * r, 0.0, angle.sin() * r);
787 let vel = Vec3::new(
788 pseudo_random(i as f32 * 6.7) * 0.5 - 0.25,
789 2.0 + pseudo_random(i as f32 * 7.1) * 3.0,
790 pseudo_random(i as f32 * 8.3) * 0.5 - 0.25,
791 );
792 let p = FluidParticle::new(position + offset, vel, FluidType::Fire);
793 particles.push(p);
794 }
795 }
796
797 pub fn spawn_ice_spread(
799 particles: &mut Vec<FluidParticle>,
800 position: Vec3,
801 radius: f32,
802 count: usize,
803 ) {
804 let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
805 for i in 0..count {
806 let angle = pseudo_random(i as f32 * 9.1) * 2.0 * PI;
807 let spread_speed = 0.5 + pseudo_random(i as f32 * 10.3) * 1.5;
808 let vel = Vec3::new(
809 angle.cos() * spread_speed,
810 -0.1,
811 angle.sin() * spread_speed,
812 );
813 let offset = Vec3::new(
814 pseudo_random(i as f32 * 11.7) * radius * 0.2,
815 0.05,
816 pseudo_random(i as f32 * 12.3) * radius * 0.2,
817 );
818 let p = FluidParticle::new(
819 Vec3::new(position.x, FLOOR_Y + 0.05, position.z) + offset,
820 vel,
821 FluidType::Ice,
822 );
823 particles.push(p);
824 }
825 }
826
827 pub fn spawn_healing_fountain(
829 particles: &mut Vec<FluidParticle>,
830 position: Vec3,
831 count: usize,
832 ) {
833 let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
834 for i in 0..count {
835 let angle = pseudo_random(i as f32 * 13.1) * 2.0 * PI;
836 let r = pseudo_random(i as f32 * 14.3) * 0.15;
837 let vel = Vec3::new(
838 angle.cos() * r * 2.0,
839 4.0 + pseudo_random(i as f32 * 15.7) * 3.0,
840 angle.sin() * r * 2.0,
841 );
842 let p = FluidParticle::new(position, vel, FluidType::Healing);
843 particles.push(p);
844 }
845 }
846
847 pub fn spawn_ouroboros_flow(
851 particles: &mut Vec<FluidParticle>,
852 from_pos: Vec3,
853 to_pos: Vec3,
854 count: usize,
855 ) {
856 let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
857 let dir = to_pos - from_pos;
858 let dist = dir.length();
859 let dir_norm = if dist > 0.001 { dir / dist } else { Vec3::X };
860
861 for i in 0..count {
862 let t = i as f32 / count.max(1) as f32;
863 let spawn_pos = from_pos + dir * t * 0.3;
865 let speed = 3.0 + pseudo_random(i as f32 * 16.1) * 2.0;
866 let wobble = Vec3::new(
867 pseudo_random(i as f32 * 17.3) * 0.5 - 0.25,
868 pseudo_random(i as f32 * 18.7) * 0.3 - 0.15,
869 pseudo_random(i as f32 * 19.1) * 0.5 - 0.25,
870 );
871 let vel = dir_norm * speed + wobble;
872 let mut p = FluidParticle::new(spawn_pos, vel, FluidType::Dark);
873 p.lifetime = (dist / speed).max(1.0);
874 particles.push(p);
875 }
876 }
877
878 pub fn spawn_necro_crawl(
880 particles: &mut Vec<FluidParticle>,
881 origin: Vec3,
882 corpse_positions: &[Vec3],
883 particles_per_corpse: usize,
884 ) {
885 if corpse_positions.is_empty() {
886 return;
887 }
888 for (ci, &corpse) in corpse_positions.iter().enumerate() {
889 let remaining = MAX_PARTICLES.saturating_sub(particles.len());
890 let count = particles_per_corpse.min(remaining);
891 if count == 0 {
892 break;
893 }
894 let dir = corpse - origin;
895 let dist = dir.length();
896 let dir_norm = if dist > 0.001 { dir / dist } else { Vec3::X };
897
898 for i in 0..count {
899 let speed = 1.0 + pseudo_random((ci * 100 + i) as f32 * 20.1) * 2.0;
900 let wobble = Vec3::new(
901 pseudo_random((ci * 100 + i) as f32 * 21.3) * 0.4 - 0.2,
902 0.0,
903 pseudo_random((ci * 100 + i) as f32 * 22.7) * 0.4 - 0.2,
904 );
905 let vel = dir_norm * speed + wobble;
906 let p = FluidParticle::new(
907 Vec3::new(origin.x, FLOOR_Y + 0.03, origin.z),
908 vel,
909 FluidType::Necro,
910 );
911 particles.push(p);
912 }
913 }
914 }
915
916 pub fn spawn_poison_bubbles(
918 particles: &mut Vec<FluidParticle>,
919 position: Vec3,
920 count: usize,
921 ) {
922 let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
923 for i in 0..count {
924 let angle = pseudo_random(i as f32 * 23.1) * 2.0 * PI;
925 let r = pseudo_random(i as f32 * 24.3) * 0.3;
926 let offset = Vec3::new(angle.cos() * r, 0.0, angle.sin() * r);
927 let vel = Vec3::new(
928 pseudo_random(i as f32 * 25.7) * 0.3 - 0.15,
929 0.5 + pseudo_random(i as f32 * 26.1) * 1.0,
930 pseudo_random(i as f32 * 27.3) * 0.3 - 0.15,
931 );
932 let p = FluidParticle::new(position + offset, vel, FluidType::Poison);
933 particles.push(p);
934 }
935 }
936
937 pub fn spawn_holy_rise(
939 particles: &mut Vec<FluidParticle>,
940 position: Vec3,
941 count: usize,
942 ) {
943 let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
944 for i in 0..count {
945 let angle = pseudo_random(i as f32 * 28.1) * 2.0 * PI;
946 let r = pseudo_random(i as f32 * 29.3) * 0.2;
947 let vel = Vec3::new(
948 angle.cos() * r * 1.5,
949 5.0 + pseudo_random(i as f32 * 30.7) * 2.0,
950 angle.sin() * r * 1.5,
951 );
952 let p = FluidParticle::new(position, vel, FluidType::Holy);
953 particles.push(p);
954 }
955 }
956}
957
958#[inline]
961fn pseudo_random(seed: f32) -> f32 {
962 let x = (seed * 12.9898 + 78.233).sin() * 43758.5453;
963 x - x.floor()
964}
965
966#[derive(Debug, Clone, Copy)]
970pub struct FluidSpriteData {
971 pub position: Vec3,
973 pub color: [f32; 4],
975 pub size: f32,
977 pub emission: f32,
979}
980
981pub struct FluidRenderer {
983 pub size_scale: f32,
985 pub emission_scale: f32,
987}
988
989impl FluidRenderer {
990 pub fn new() -> Self {
991 Self {
992 size_scale: 1.0,
993 emission_scale: 1.0,
994 }
995 }
996
997 pub fn extract_sprites(&self, particles: &[FluidParticle]) -> Vec<FluidSpriteData> {
999 let mut sprites = Vec::with_capacity(particles.len());
1000 for p in particles {
1001 if !p.alive() {
1002 continue;
1003 }
1004 let life = p.life_fraction();
1005 let mut color = p.color;
1006 color[3] *= life; let size = p.fluid_type.sprite_size() * self.size_scale * (0.5 + 0.5 * life);
1008 let emission = p.fluid_type.emission() * self.emission_scale * life;
1009 sprites.push(FluidSpriteData {
1010 position: p.position,
1011 color,
1012 size,
1013 emission,
1014 });
1015 }
1016 sprites
1017 }
1018
1019 pub fn extract_pool_sprites(&self, pools: &[FluidPool]) -> Vec<FluidSpriteData> {
1021 let mut sprites = Vec::with_capacity(pools.len());
1022 for pool in pools {
1023 if !pool.alive() {
1024 continue;
1025 }
1026 sprites.push(FluidSpriteData {
1027 position: pool.position,
1028 color: pool.color(),
1029 size: pool.radius * 2.0 * self.size_scale,
1030 emission: pool.fluid_type.emission() * self.emission_scale * pool.life_fraction(),
1031 });
1032 }
1033 sprites
1034 }
1035}
1036
1037impl Default for FluidRenderer {
1038 fn default() -> Self {
1039 Self::new()
1040 }
1041}
1042
1043#[derive(Debug, Clone, Copy, PartialEq)]
1047pub enum FluidStatusEffect {
1048 DamageOverTime {
1050 damage_per_second: f32,
1051 element: FluidType,
1052 },
1053 BleedAmplify { multiplier: f32 },
1055 Slow { factor: f32 },
1057 ManaDrain { drain_per_second: f32 },
1059 HealOverTime { heal_per_second: f32 },
1061 NecroEmpower { speed_multiplier: f32 },
1063}
1064
1065pub struct FluidGameplayEffects;
1069
1070impl FluidGameplayEffects {
1071 pub fn query_effects(pools: &[FluidPool], entity_pos: Vec3) -> Vec<FluidStatusEffect> {
1074 let mut effects = Vec::new();
1075 for pool in pools {
1076 if !pool.alive() {
1077 continue;
1078 }
1079 if !pool.contains_xz(entity_pos) {
1080 continue;
1081 }
1082 let intensity = pool.depth / 0.1; match pool.fluid_type {
1084 FluidType::Blood => {
1085 effects.push(FluidStatusEffect::BleedAmplify {
1086 multiplier: 1.0 + 0.5 * intensity,
1087 });
1088 }
1089 FluidType::Fire => {
1090 effects.push(FluidStatusEffect::DamageOverTime {
1091 damage_per_second: 15.0 * intensity,
1092 element: FluidType::Fire,
1093 });
1094 }
1095 FluidType::Ice => {
1096 effects.push(FluidStatusEffect::Slow {
1097 factor: (0.3 + 0.2 * intensity).min(0.8),
1098 });
1099 }
1100 FluidType::Dark => {
1101 effects.push(FluidStatusEffect::ManaDrain {
1102 drain_per_second: 10.0 * intensity,
1103 });
1104 }
1105 FluidType::Poison => {
1106 effects.push(FluidStatusEffect::DamageOverTime {
1107 damage_per_second: 8.0 * intensity,
1108 element: FluidType::Poison,
1109 });
1110 }
1111 FluidType::Healing => {
1112 effects.push(FluidStatusEffect::HealOverTime {
1113 heal_per_second: 12.0 * intensity,
1114 });
1115 }
1116 FluidType::Necro => {
1117 effects.push(FluidStatusEffect::NecroEmpower {
1118 speed_multiplier: 1.0 + 1.0 * intensity,
1119 });
1120 }
1121 FluidType::Holy => {
1122 }
1125 }
1126 }
1127 effects
1128 }
1129
1130 pub fn total_dot(effects: &[FluidStatusEffect]) -> f32 {
1132 let mut total = 0.0;
1133 for e in effects {
1134 if let FluidStatusEffect::DamageOverTime { damage_per_second, .. } = e {
1135 total += damage_per_second;
1136 }
1137 }
1138 total
1139 }
1140
1141 pub fn strongest_slow(effects: &[FluidStatusEffect]) -> f32 {
1143 let mut max_slow = 0.0_f32;
1144 for e in effects {
1145 if let FluidStatusEffect::Slow { factor } = e {
1146 max_slow = max_slow.max(*factor);
1147 }
1148 }
1149 max_slow
1150 }
1151
1152 pub fn total_mana_drain(effects: &[FluidStatusEffect]) -> f32 {
1154 let mut total = 0.0;
1155 for e in effects {
1156 if let FluidStatusEffect::ManaDrain { drain_per_second } = e {
1157 total += drain_per_second;
1158 }
1159 }
1160 total
1161 }
1162
1163 pub fn total_heal(effects: &[FluidStatusEffect]) -> f32 {
1165 let mut total = 0.0;
1166 for e in effects {
1167 if let FluidStatusEffect::HealOverTime { heal_per_second } = e {
1168 total += heal_per_second;
1169 }
1170 }
1171 total
1172 }
1173
1174 pub fn bleed_multiplier(effects: &[FluidStatusEffect]) -> f32 {
1176 let mut mult = 1.0;
1177 for e in effects {
1178 if let FluidStatusEffect::BleedAmplify { multiplier } = e {
1179 mult *= multiplier;
1180 }
1181 }
1182 mult
1183 }
1184}
1185
1186pub struct FluidManager {
1191 pub particles: Vec<FluidParticle>,
1193 pub pools: Vec<FluidPool>,
1195 pub simulator: SPHSimulator,
1197 pub renderer: FluidRenderer,
1199 pub time: f32,
1201 pub fixed_dt: f32,
1203 time_accumulator: f32,
1205}
1206
1207impl FluidManager {
1208 pub fn new() -> Self {
1210 Self {
1211 particles: Vec::with_capacity(MAX_PARTICLES),
1212 pools: Vec::with_capacity(MAX_POOLS),
1213 simulator: SPHSimulator::new(),
1214 renderer: FluidRenderer::new(),
1215 time: 0.0,
1216 fixed_dt: 1.0 / 60.0,
1217 time_accumulator: 0.0,
1218 }
1219 }
1220
1221 pub fn particle_count(&self) -> usize {
1223 self.particles.len()
1224 }
1225
1226 pub fn pool_count(&self) -> usize {
1228 self.pools.len()
1229 }
1230
1231 pub fn update(&mut self, dt: f32) {
1233 self.time += dt;
1234 self.time_accumulator += dt;
1235
1236 while self.time_accumulator >= self.fixed_dt {
1238 self.simulator.step(&mut self.particles, self.fixed_dt);
1239 self.time_accumulator -= self.fixed_dt;
1240 }
1241
1242 for p in &mut self.particles {
1244 p.lifetime -= dt;
1245 }
1246
1247 for pool in &mut self.pools {
1249 pool.update(dt);
1250 }
1251
1252 self.settle_particles_to_pools();
1254
1255 self.merge_pools();
1257
1258 self.particles.retain(|p| p.alive());
1260
1261 self.pools.retain(|p| p.alive());
1263
1264 while self.particles.len() > MAX_PARTICLES {
1266 if let Some(min_idx) = self
1268 .particles
1269 .iter()
1270 .enumerate()
1271 .min_by(|a, b| a.1.lifetime.partial_cmp(&b.1.lifetime).unwrap())
1272 .map(|(i, _)| i)
1273 {
1274 self.particles.swap_remove(min_idx);
1275 } else {
1276 break;
1277 }
1278 }
1279
1280 while self.pools.len() > MAX_POOLS {
1281 if let Some(min_idx) = self
1283 .pools
1284 .iter()
1285 .enumerate()
1286 .max_by(|a, b| a.1.age.partial_cmp(&b.1.age).unwrap())
1287 .map(|(i, _)| i)
1288 {
1289 self.pools.swap_remove(min_idx);
1290 } else {
1291 break;
1292 }
1293 }
1294 }
1295
1296 fn settle_particles_to_pools(&mut self) {
1299 let mut settled_indices = Vec::new();
1300 let mut new_pool_data: Vec<(Vec3, FluidType)> = Vec::new();
1301
1302 for (i, p) in self.particles.iter().enumerate() {
1303 if !p.fluid_type.can_pool() {
1304 continue;
1305 }
1306 if p.position.y > FLOOR_Y + 0.1 {
1307 continue;
1308 }
1309 if p.speed() > SETTLE_SPEED {
1310 continue;
1311 }
1312 let mut found_pool = false;
1314 for pool in &mut self.pools {
1315 if pool.fluid_type != p.fluid_type {
1316 continue;
1317 }
1318 let dx = p.position.x - pool.position.x;
1319 let dz = p.position.z - pool.position.z;
1320 if dx * dx + dz * dz < (pool.radius + 0.3) * (pool.radius + 0.3) {
1321 pool.absorb_particle();
1322 found_pool = true;
1323 break;
1324 }
1325 }
1326 if !found_pool {
1327 new_pool_data.push((p.position, p.fluid_type));
1328 }
1329 settled_indices.push(i);
1330 }
1331
1332 settled_indices.sort_unstable_by(|a, b| b.cmp(a));
1334 for idx in settled_indices {
1335 self.particles.swap_remove(idx);
1336 }
1337
1338 for (pos, ft) in new_pool_data {
1340 if self.pools.len() < MAX_POOLS {
1341 let mut pool = FluidPool::new(pos, 0.1, ft);
1342 pool.absorb_particle();
1343 self.pools.push(pool);
1344 }
1345 }
1346 }
1347
1348 fn merge_pools(&mut self) {
1350 if self.pools.len() < 2 {
1351 return;
1352 }
1353 let mut merged = vec![false; self.pools.len()];
1354 let mut i = 0;
1355 while i < self.pools.len() {
1356 if merged[i] {
1357 i += 1;
1358 continue;
1359 }
1360 let mut j = i + 1;
1361 while j < self.pools.len() {
1362 if merged[j] {
1363 j += 1;
1364 continue;
1365 }
1366 if self.pools[i].fluid_type != self.pools[j].fluid_type {
1367 j += 1;
1368 continue;
1369 }
1370 let dist = self.pools[i].distance_to(&self.pools[j]);
1371 if dist < POOL_MERGE_DISTANCE {
1372 let other = self.pools[j].clone();
1374 self.pools[i].merge_from(&other);
1375 merged[j] = true;
1376 }
1377 j += 1;
1378 }
1379 i += 1;
1380 }
1381
1382 let mut idx = self.pools.len();
1384 while idx > 0 {
1385 idx -= 1;
1386 if merged[idx] {
1387 self.pools.swap_remove(idx);
1388 }
1389 }
1390 }
1391
1392 pub fn particle_sprites(&self) -> Vec<FluidSpriteData> {
1394 self.renderer.extract_sprites(&self.particles)
1395 }
1396
1397 pub fn pool_sprites(&self) -> Vec<FluidSpriteData> {
1399 self.renderer.extract_pool_sprites(&self.pools)
1400 }
1401
1402 pub fn query_effects_at(&self, pos: Vec3) -> Vec<FluidStatusEffect> {
1404 FluidGameplayEffects::query_effects(&self.pools, pos)
1405 }
1406
1407 pub fn spawn_bleed(&mut self, entity_pos: Vec3, direction: Vec3, count: usize) {
1411 FluidSpawner::spawn_bleed(&mut self.particles, entity_pos, direction, count);
1412 }
1413
1414 pub fn spawn_fire_pool(&mut self, position: Vec3, radius: f32, count: usize) {
1416 FluidSpawner::spawn_fire_pool(&mut self.particles, position, radius, count);
1417 }
1418
1419 pub fn spawn_ice_spread(&mut self, position: Vec3, radius: f32, count: usize) {
1421 FluidSpawner::spawn_ice_spread(&mut self.particles, position, radius, count);
1422 }
1423
1424 pub fn spawn_healing_fountain(&mut self, position: Vec3, count: usize) {
1426 FluidSpawner::spawn_healing_fountain(&mut self.particles, position, count);
1427 }
1428
1429 pub fn spawn_ouroboros_flow(&mut self, from_pos: Vec3, to_pos: Vec3, count: usize) {
1431 FluidSpawner::spawn_ouroboros_flow(&mut self.particles, from_pos, to_pos, count);
1432 }
1433
1434 pub fn spawn_necro_crawl(
1436 &mut self,
1437 origin: Vec3,
1438 corpse_positions: &[Vec3],
1439 particles_per_corpse: usize,
1440 ) {
1441 FluidSpawner::spawn_necro_crawl(
1442 &mut self.particles,
1443 origin,
1444 corpse_positions,
1445 particles_per_corpse,
1446 );
1447 }
1448
1449 pub fn spawn_poison_bubbles(&mut self, position: Vec3, count: usize) {
1451 FluidSpawner::spawn_poison_bubbles(&mut self.particles, position, count);
1452 }
1453
1454 pub fn spawn_holy_rise(&mut self, position: Vec3, count: usize) {
1456 FluidSpawner::spawn_holy_rise(&mut self.particles, position, count);
1457 }
1458
1459 pub fn clear(&mut self) {
1461 self.particles.clear();
1462 self.pools.clear();
1463 }
1464}
1465
1466impl Default for FluidManager {
1467 fn default() -> Self {
1468 Self::new()
1469 }
1470}
1471
1472#[cfg(test)]
1475mod tests {
1476 use super::*;
1477
1478 #[test]
1481 fn test_kernel_at_zero_is_positive() {
1482 let sim = SPHSimulator::new();
1483 let w = sim.kernel(0.0);
1484 assert!(w > 0.0, "Kernel at r=0 should be positive, got {w}");
1485 }
1486
1487 #[test]
1488 fn test_kernel_at_h_is_zero() {
1489 let sim = SPHSimulator::new();
1490 let w = sim.kernel(sim.h);
1491 assert!(
1492 w.abs() < 1e-5,
1493 "Kernel at r=h should be ~0, got {w}"
1494 );
1495 }
1496
1497 #[test]
1498 fn test_kernel_beyond_h_is_zero() {
1499 let sim = SPHSimulator::new();
1500 let w = sim.kernel(sim.h * 1.5);
1501 assert_eq!(w, 0.0, "Kernel beyond h should be exactly 0");
1502 }
1503
1504 #[test]
1505 fn test_kernel_monotone_decreasing() {
1506 let sim = SPHSimulator::new();
1507 let mut prev = sim.kernel(0.0);
1508 for i in 1..20 {
1509 let r = sim.h * i as f32 / 20.0;
1510 let w = sim.kernel(r);
1511 assert!(
1512 w <= prev + 1e-6,
1513 "Kernel should be monotonically decreasing: W({r}) = {w} > W_prev = {prev}"
1514 );
1515 prev = w;
1516 }
1517 }
1518
1519 #[test]
1522 fn test_density_single_particle() {
1523 let mut sim = SPHSimulator::new();
1524 let mut particles = vec![FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Blood)];
1525 sim.rebuild_grid(&particles);
1526 sim.find_neighbors(&mut particles);
1527 sim.compute_density(&mut particles);
1528 assert!(
1530 particles[0].density > 0.0,
1531 "Single particle density should be > 0, got {}",
1532 particles[0].density
1533 );
1534 }
1535
1536 #[test]
1537 fn test_density_increases_with_nearby_particles() {
1538 let mut sim = SPHSimulator::new();
1539 let mut single = vec![FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Blood)];
1540 sim.rebuild_grid(&single);
1541 sim.find_neighbors(&mut single);
1542 sim.compute_density(&mut single);
1543 let single_density = single[0].density;
1544
1545 let mut pair = vec![
1546 FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Blood),
1547 FluidParticle::new(
1548 Vec3::new(sim.h * 0.3, 0.0, 0.0),
1549 Vec3::ZERO,
1550 FluidType::Blood,
1551 ),
1552 ];
1553 sim.rebuild_grid(&pair);
1554 sim.find_neighbors(&mut pair);
1555 sim.compute_density(&mut pair);
1556 assert!(
1557 pair[0].density > single_density,
1558 "Density with neighbour ({}) should exceed single ({})",
1559 pair[0].density,
1560 single_density
1561 );
1562 }
1563
1564 #[test]
1567 fn test_pressure_at_rest_density() {
1568 let sim = SPHSimulator::new();
1569 let mut p = FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Blood);
1570 p.density = sim.rest_density;
1571 let mut particles = vec![p];
1572 sim.compute_pressure(&mut particles);
1573 assert!(
1574 particles[0].pressure.abs() < 1e-3,
1575 "Pressure at rest density should be ~0, got {}",
1576 particles[0].pressure
1577 );
1578 }
1579
1580 #[test]
1581 fn test_pressure_positive_above_rest() {
1582 let sim = SPHSimulator::new();
1583 let mut p = FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Blood);
1584 p.density = sim.rest_density * 1.5;
1585 let mut particles = vec![p];
1586 sim.compute_pressure(&mut particles);
1587 assert!(
1588 particles[0].pressure > 0.0,
1589 "Pressure above rest density should be positive, got {}",
1590 particles[0].pressure
1591 );
1592 }
1593
1594 #[test]
1597 fn test_pool_contains_xz() {
1598 let pool = FluidPool::new(Vec3::new(1.0, 0.0, 2.0), 0.5, FluidType::Blood);
1599 assert!(pool.contains_xz(Vec3::new(1.0, 0.5, 2.0)));
1600 assert!(pool.contains_xz(Vec3::new(1.3, 0.0, 2.0)));
1601 assert!(!pool.contains_xz(Vec3::new(2.0, 0.0, 2.0)));
1602 }
1603
1604 #[test]
1605 fn test_pool_absorb_grows() {
1606 let mut pool = FluidPool::new(Vec3::ZERO, 0.1, FluidType::Ice);
1607 let r0 = pool.radius;
1608 let d0 = pool.depth;
1609 pool.absorb_particle();
1610 assert!(pool.radius > r0);
1611 assert!(pool.depth > d0);
1612 assert_eq!(pool.absorbed_count, 2); }
1614
1615 #[test]
1616 fn test_pool_merge() {
1617 let mut a = FluidPool::new(Vec3::new(0.0, 0.0, 0.0), 0.2, FluidType::Blood);
1618 a.absorbed_count = 5;
1619 let mut b = FluidPool::new(Vec3::new(0.3, 0.0, 0.0), 0.15, FluidType::Blood);
1620 b.absorbed_count = 3;
1621 let area_before = a.area() + b.area();
1622 a.merge_from(&b);
1623 let area_after = a.area();
1624 assert!(
1625 (area_after - area_before).abs() < 1e-4,
1626 "Merged area should be sum of individual areas"
1627 );
1628 assert_eq!(a.absorbed_count, 8);
1629 }
1630
1631 #[test]
1632 fn test_pool_lifetime() {
1633 let mut pool = FluidPool::new(Vec3::ZERO, 0.5, FluidType::Blood);
1634 assert!(pool.alive());
1635 pool.age = pool.max_lifetime + 1.0;
1636 assert!(!pool.alive());
1637 }
1638
1639 #[test]
1642 fn test_fire_cannot_pool() {
1643 assert!(!FluidType::Fire.can_pool());
1644 }
1645
1646 #[test]
1647 fn test_blood_can_pool() {
1648 assert!(FluidType::Blood.can_pool());
1649 }
1650
1651 #[test]
1652 fn test_holy_cannot_pool() {
1653 assert!(!FluidType::Holy.can_pool());
1654 }
1655
1656 #[test]
1659 fn test_spawn_bleed_creates_particles() {
1660 let mut particles = Vec::new();
1661 FluidSpawner::spawn_bleed(
1662 &mut particles,
1663 Vec3::new(0.0, 2.0, 0.0),
1664 Vec3::new(1.0, 0.0, 0.0),
1665 10,
1666 );
1667 assert_eq!(particles.len(), 10);
1668 for p in &particles {
1669 assert_eq!(p.fluid_type, FluidType::Blood);
1670 }
1671 }
1672
1673 #[test]
1674 fn test_spawn_respects_max_particles() {
1675 let mut particles = Vec::new();
1676 for _ in 0..(MAX_PARTICLES - 5) {
1678 particles.push(FluidParticle::new(
1679 Vec3::ZERO,
1680 Vec3::ZERO,
1681 FluidType::Blood,
1682 ));
1683 }
1684 FluidSpawner::spawn_bleed(
1685 &mut particles,
1686 Vec3::ZERO,
1687 Vec3::Y,
1688 100,
1689 );
1690 assert!(
1691 particles.len() <= MAX_PARTICLES,
1692 "Should not exceed MAX_PARTICLES"
1693 );
1694 }
1695
1696 #[test]
1697 fn test_spawn_healing_fountain() {
1698 let mut particles = Vec::new();
1699 FluidSpawner::spawn_healing_fountain(&mut particles, Vec3::new(0.0, 0.5, 0.0), 20);
1700 assert_eq!(particles.len(), 20);
1701 for p in &particles {
1702 assert_eq!(p.fluid_type, FluidType::Healing);
1703 assert!(p.velocity.y > 0.0, "Healing fountain should go up");
1705 }
1706 }
1707
1708 #[test]
1709 fn test_spawn_ouroboros_flow() {
1710 let mut particles = Vec::new();
1711 let from = Vec3::new(-5.0, 1.0, 0.0);
1712 let to = Vec3::new(5.0, 1.0, 0.0);
1713 FluidSpawner::spawn_ouroboros_flow(&mut particles, from, to, 15);
1714 assert_eq!(particles.len(), 15);
1715 for p in &particles {
1716 assert_eq!(p.fluid_type, FluidType::Dark);
1717 assert!(p.velocity.x > 0.0, "Ouroboros should flow toward target");
1719 }
1720 }
1721
1722 #[test]
1723 fn test_spawn_necro_crawl() {
1724 let mut particles = Vec::new();
1725 let origin = Vec3::ZERO;
1726 let corpses = vec![
1727 Vec3::new(3.0, 0.0, 0.0),
1728 Vec3::new(-2.0, 0.0, 1.0),
1729 ];
1730 FluidSpawner::spawn_necro_crawl(&mut particles, origin, &corpses, 5);
1731 assert_eq!(particles.len(), 10); for p in &particles {
1733 assert_eq!(p.fluid_type, FluidType::Necro);
1734 }
1735 }
1736
1737 #[test]
1740 fn test_blood_pool_bleed_amplify() {
1741 let pool = FluidPool::new(Vec3::ZERO, 1.0, FluidType::Blood);
1742 let effects = FluidGameplayEffects::query_effects(
1743 &[pool],
1744 Vec3::new(0.5, 0.0, 0.0),
1745 );
1746 let mult = FluidGameplayEffects::bleed_multiplier(&effects);
1747 assert!(mult > 1.0, "Blood pool should amplify bleed, got {mult}");
1748 }
1749
1750 #[test]
1751 fn test_ice_pool_slow() {
1752 let pool = FluidPool::new(Vec3::ZERO, 1.0, FluidType::Ice);
1753 let effects = FluidGameplayEffects::query_effects(
1754 &[pool],
1755 Vec3::new(0.3, 0.0, 0.3),
1756 );
1757 let slow = FluidGameplayEffects::strongest_slow(&effects);
1758 assert!(slow > 0.0, "Ice pool should slow, got {slow}");
1759 }
1760
1761 #[test]
1762 fn test_fire_pool_dot() {
1763 let pool = FluidPool::new(Vec3::ZERO, 1.0, FluidType::Fire);
1764 let effects = FluidGameplayEffects::query_effects(
1765 &[pool],
1766 Vec3::new(0.0, 0.0, 0.0),
1767 );
1768 let dot = FluidGameplayEffects::total_dot(&effects);
1769 assert!(dot > 0.0, "Fire pool should deal DoT, got {dot}");
1770 }
1771
1772 #[test]
1773 fn test_dark_pool_mana_drain() {
1774 let pool = FluidPool::new(Vec3::ZERO, 1.0, FluidType::Dark);
1775 let effects = FluidGameplayEffects::query_effects(
1776 &[pool],
1777 Vec3::new(0.0, 0.0, 0.0),
1778 );
1779 let drain = FluidGameplayEffects::total_mana_drain(&effects);
1780 assert!(drain > 0.0, "Dark pool should drain mana, got {drain}");
1781 }
1782
1783 #[test]
1784 fn test_no_effect_outside_pool() {
1785 let pool = FluidPool::new(Vec3::ZERO, 0.5, FluidType::Fire);
1786 let effects = FluidGameplayEffects::query_effects(
1787 &[pool],
1788 Vec3::new(5.0, 0.0, 5.0),
1789 );
1790 assert!(effects.is_empty(), "Should have no effects outside pool");
1791 }
1792
1793 #[test]
1796 fn test_manager_spawn_and_update() {
1797 let mut mgr = FluidManager::new();
1798 mgr.spawn_bleed(Vec3::new(0.0, 2.0, 0.0), Vec3::Y, 20);
1799 assert_eq!(mgr.particle_count(), 20);
1800 mgr.update(0.016);
1801 assert!(mgr.particle_count() > 0);
1803 }
1804
1805 #[test]
1806 fn test_manager_particles_die_over_time() {
1807 let mut mgr = FluidManager::new();
1808 mgr.spawn_bleed(Vec3::new(0.0, 2.0, 0.0), Vec3::Y, 10);
1809 for _ in 0..300 {
1811 mgr.update(0.016);
1812 }
1813 assert_eq!(
1814 mgr.particle_count(),
1815 0,
1816 "All blood particles should have died"
1817 );
1818 }
1819
1820 #[test]
1821 fn test_manager_clear() {
1822 let mut mgr = FluidManager::new();
1823 mgr.spawn_bleed(Vec3::ZERO, Vec3::Y, 50);
1824 mgr.pools
1825 .push(FluidPool::new(Vec3::ZERO, 1.0, FluidType::Blood));
1826 mgr.clear();
1827 assert_eq!(mgr.particle_count(), 0);
1828 assert_eq!(mgr.pool_count(), 0);
1829 }
1830
1831 #[test]
1834 fn test_renderer_extracts_alive_only() {
1835 let renderer = FluidRenderer::new();
1836 let mut alive = FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Fire);
1837 alive.lifetime = 1.0;
1838 let mut dead = FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Fire);
1839 dead.lifetime = -1.0;
1840 let sprites = renderer.extract_sprites(&[alive, dead]);
1841 assert_eq!(sprites.len(), 1, "Should only render alive particles");
1842 }
1843
1844 #[test]
1845 fn test_pseudo_random_in_range() {
1846 for i in 0..100 {
1847 let v = pseudo_random(i as f32 * 0.7);
1848 assert!(v >= 0.0 && v < 1.0, "pseudo_random out of range: {v}");
1849 }
1850 }
1851
1852 #[test]
1855 fn test_sph_step_does_not_explode() {
1856 let mut sim = SPHSimulator::new();
1857 let mut particles: Vec<FluidParticle> = (0..50)
1858 .map(|i| {
1859 let x = (i % 10) as f32 * 0.05;
1860 let y = (i / 10) as f32 * 0.05 + 1.0;
1861 FluidParticle::new(Vec3::new(x, y, 0.0), Vec3::ZERO, FluidType::Blood)
1862 })
1863 .collect();
1864
1865 for _ in 0..10 {
1866 sim.step(&mut particles, 1.0 / 60.0);
1867 }
1868
1869 for p in &particles {
1870 let speed = p.velocity.length();
1871 assert!(
1872 speed < 100.0,
1873 "Particle velocity exploded: speed = {speed}"
1874 );
1875 assert!(
1876 p.position.length() < 100.0,
1877 "Particle position exploded: {:?}",
1878 p.position
1879 );
1880 }
1881 }
1882}