Skip to main content

proof_engine/game/
fluids.rs

1//! Fluid blood and magical energy system for Chaos RPG.
2//!
3//! Provides a gameplay-oriented fluid simulation built on simplified SPH
4//! (Smoothed Particle Hydrodynamics). Supports multiple magical fluid types
5//! (blood, fire, ice, dark, holy, poison, healing, necro), each with distinct
6//! visual behaviour and gameplay effects.
7//!
8//! ## Architecture
9//! - [`FluidType`] — categorises fluids with colour, motion bias, and effects
10//! - [`FluidParticle`] — individual SPH particle with type-specific properties
11//! - [`SPHSimulator`] — simplified SPH solver (kernel, density, pressure, viscosity)
12//! - [`FluidPool`] — settled pool of fluid on the floor with gameplay area effects
13//! - [`FluidSpawner`] — high-level API for emitting themed particle bursts
14//! - [`FluidManager`] — owns particles + pools, drives simulation and lifecycle
15//! - [`FluidRenderer`] — exports point-sprite render data
16//! - [`FluidGameplayEffects`] — queries entity positions against pools for status effects
17//!
18//! Reuses [`crate::physics::fluids`] kernel functions and spatial hashing where possible.
19
20use glam::Vec3;
21use std::collections::HashMap;
22
23// ── Re-use kernel functions from physics::fluids ────────────────────────────
24
25use crate::physics::fluids::{cubic_kernel, cubic_kernel_grad, kernel_gradient, DensityGrid};
26
27// ── Constants ───────────────────────────────────────────────────────────────
28
29const PI: f32 = std::f32::consts::PI;
30
31/// Maximum number of fluid particles alive at once.
32const MAX_PARTICLES: usize = 2000;
33
34/// Maximum number of fluid pools alive at once.
35const MAX_POOLS: usize = 50;
36
37/// Default smoothing radius for game-SPH.
38const DEFAULT_SMOOTHING_RADIUS: f32 = 0.35;
39
40/// Default rest density (kg/m^3) for game fluids.
41const DEFAULT_REST_DENSITY: f32 = 1000.0;
42
43/// Tait EOS stiffness.
44const TAIT_STIFFNESS: f32 = 50.0;
45
46/// Tait EOS gamma.
47const TAIT_GAMMA: f32 = 7.0;
48
49/// Default viscosity coefficient.
50const DEFAULT_VISCOSITY: f32 = 0.02;
51
52/// Default surface tension coefficient.
53const DEFAULT_SURFACE_TENSION: f32 = 0.01;
54
55/// Gravity vector (Y-up).
56const GRAVITY: Vec3 = Vec3::new(0.0, -9.81, 0.0);
57
58/// Pool merge distance — two pools closer than this merge.
59const POOL_MERGE_DISTANCE: f32 = 0.6;
60
61/// Particle settle speed threshold — below this a particle converts to pool.
62const SETTLE_SPEED: f32 = 0.15;
63
64/// Minimum pool depth for gameplay effects.
65const MIN_POOL_DEPTH: f32 = 0.01;
66
67/// Floor Y coordinate.
68const FLOOR_Y: f32 = 0.0;
69
70// ── FluidType ───────────────────────────────────────────────────────────────
71
72/// Categorises a fluid by visual style, movement bias, and gameplay effect.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74pub enum FluidType {
75    /// Red blood — drips downward, pools on floor, increases bleed damage.
76    Blood,
77    /// Orange fire — rises upward, burns, deals fire DoT.
78    Fire,
79    /// Blue ice — spreads across floor, slows movement.
80    Ice,
81    /// Purple/black dark energy — crawls along floor, drains mana.
82    Dark,
83    /// Golden holy light — rises upward, purifies.
84    Holy,
85    /// Green poison — bubbles, deals poison DoT.
86    Poison,
87    /// Bright green healing — fountains upward, heals over time.
88    Healing,
89    /// Dark purple necromantic energy — flows toward corpses.
90    Necro,
91}
92
93impl FluidType {
94    /// Base RGBA colour for this fluid type.
95    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    /// Extra emission/glow multiplier.
109    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    /// Default lifetime in seconds for particles of this type.
123    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    /// Default viscosity for this fluid type.
137    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    /// External force bias (added to gravity each step).
151    /// Fire and Holy rise, Dark/Necro hug the floor, etc.
152    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),   // strong upward buoyancy
156            FluidType::Ice     => Vec3::new(0.0, -2.0, 0.0),   // presses to floor
157            FluidType::Dark    => Vec3::new(0.0, -3.0, 0.0),   // crawls along floor
158            FluidType::Holy    => Vec3::new(0.0, 14.0, 0.0),   // rises upward
159            FluidType::Poison  => Vec3::new(0.0, 1.5, 0.0),    // slight bubbling
160            FluidType::Healing => Vec3::new(0.0, 10.0, 0.0),   // fountain upward
161            FluidType::Necro   => Vec3::new(0.0, -3.0, 0.0),   // floor crawler
162        }
163    }
164
165    /// Default temperature for this fluid.
166    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    /// Whether this fluid type can form floor pools.
180    pub fn can_pool(self) -> bool {
181        match self {
182            FluidType::Fire | FluidType::Holy | FluidType::Healing => false,
183            _ => true,
184        }
185    }
186
187    /// Drag coefficient applied to particles of this type.
188    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    /// Point sprite size multiplier for rendering.
202    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// ── FluidParticle ───────────────────────────────────────────────────────────
217
218/// A single fluid particle in the game-layer SPH simulation.
219#[derive(Debug, Clone)]
220pub struct FluidParticle {
221    /// World-space position.
222    pub position: Vec3,
223    /// Velocity (m/s).
224    pub velocity: Vec3,
225    /// SPH-computed density.
226    pub density: f32,
227    /// SPH-computed pressure.
228    pub pressure: f32,
229    /// RGBA colour (may drift from base over lifetime).
230    pub color: [f32; 4],
231    /// The type of fluid.
232    pub fluid_type: FluidType,
233    /// Remaining lifetime in seconds. Particle dies when <= 0.
234    pub lifetime: f32,
235    /// Per-particle viscosity.
236    pub viscosity: f32,
237    /// Temperature (Kelvin-ish, gameplay scale).
238    pub temperature: f32,
239    /// Accumulated acceleration for current step.
240    accel: Vec3,
241    /// Mass (kept uniform for simplicity).
242    mass: f32,
243    /// Rest density.
244    rest_density: f32,
245    /// Neighbor indices (populated each step).
246    neighbors: Vec<usize>,
247}
248
249impl FluidParticle {
250    /// Create a new fluid particle.
251    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    /// Create a particle with a custom lifetime.
271    pub fn with_lifetime(mut self, lt: f32) -> Self {
272        self.lifetime = lt;
273        self
274    }
275
276    /// Create a particle with a custom mass.
277    pub fn with_mass(mut self, m: f32) -> Self {
278        self.mass = m;
279        self
280    }
281
282    /// Whether this particle is still alive.
283    pub fn alive(&self) -> bool {
284        self.lifetime > 0.0
285    }
286
287    /// Fraction of lifetime remaining (1.0 = fresh, 0.0 = dead).
288    pub fn life_fraction(&self) -> f32 {
289        (self.lifetime / self.fluid_type.default_lifetime()).clamp(0.0, 1.0)
290    }
291
292    /// Speed (magnitude of velocity).
293    pub fn speed(&self) -> f32 {
294        self.velocity.length()
295    }
296}
297
298// ── SpatialHashGrid ─────────────────────────────────────────────────────────
299//
300// We wrap `DensityGrid` from physics::fluids for convenience, providing a
301// thinner interface suited to the game layer.
302
303/// Thin wrapper around [`DensityGrid`] providing neighbour queries for the
304/// game-layer fluid simulation.
305struct 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
327// ── SPHSimulator ────────────────────────────────────────────────────────────
328
329/// Simplified SPH solver tailored for the Chaos RPG game-layer fluid system.
330///
331/// Uses the cubic spline kernel from [`crate::physics::fluids`], Tait equation
332/// of state for pressure, artificial viscosity, simple surface tension via
333/// colour-field gradient, and type-specific external forces (buoyancy, drag).
334pub struct SPHSimulator {
335    /// Smoothing radius h.
336    pub h: f32,
337    /// Reference rest density.
338    pub rest_density: f32,
339    /// Tait stiffness B.
340    pub stiffness: f32,
341    /// Tait gamma.
342    pub gamma: f32,
343    /// Base viscosity coefficient (multiplied by per-particle viscosity).
344    pub viscosity: f32,
345    /// Surface tension coefficient.
346    pub surface_tension: f32,
347    /// Global gravity.
348    pub gravity: Vec3,
349    /// Spatial hash grid for neighbour search.
350    grid: SpatialHash,
351}
352
353impl SPHSimulator {
354    /// Create a new SPH simulator with default game parameters.
355    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    /// Create with a custom smoothing radius.
369    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    /// Create with custom stiffness.
376    pub fn with_stiffness(mut self, b: f32) -> Self {
377        self.stiffness = b;
378        self
379    }
380
381    // ── Kernel helpers (delegate to physics) ────────────────────────────────
382
383    // `self.h` is the interaction radius: neighbours are searched within h.
384    // The Monaghan cubic spline has support 2 * (smoothing length), so the
385    // smoothing length passed to it is h / 2. Passing h made the kernel reach
386    // to 2h while only neighbours inside h were summed, cutting the kernel
387    // off at about 0.23 of its peak instead of at zero.
388
389    /// Cubic spline kernel W(r), zero at r >= h.
390    #[inline]
391    fn kernel(&self, r: f32) -> f32 {
392        cubic_kernel(r, self.h * 0.5)
393    }
394
395    /// Scalar gradient of kernel dW/dr.
396    #[inline]
397    fn kernel_grad_scalar(&self, r: f32) -> f32 {
398        cubic_kernel_grad(r, self.h * 0.5)
399    }
400
401    /// Vector gradient of kernel.
402    #[inline]
403    fn kernel_grad_vec(&self, r_vec: Vec3) -> Vec3 {
404        kernel_gradient(r_vec, self.h * 0.5)
405    }
406
407    // ── SPH steps ───────────────────────────────────────────────────────────
408
409    /// Rebuild the spatial hash grid from current particle positions.
410    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    /// Find neighbours for every particle.
416    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    /// Compute density for each particle: rho_i = sum_j m_j * W(r_ij, h).
436    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            // Self contribution
444            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); // avoid zero density
450        }
451    }
452
453    /// Compute pressure from density via Tait equation of state:
454    /// P = B * ((rho / rho0)^gamma - 1)
455    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    /// Compute pressure force: a_i += -sum_j m_j * (P_i/rho_i^2 + P_j/rho_j^2) * grad W
468    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    /// Compute viscosity force: Laplacian of velocity (artificial viscosity).
490    /// a_visc_i = mu * sum_j m_j * (v_j - v_i) / rho_j * laplacian W
491    /// We approximate laplacian W with 2 * (d+2) * dot(v_ij, r_ij) / (|r|^2 + eps) * grad W
492    /// (Monaghan artificial viscosity approach simplified).
493    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                // Simplified Monaghan-style: 2*(d+2) with d=3 => 10
514                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    /// Surface tension via colour-field gradient.
522    /// For each particle compute n_i = sum_j (m_j / rho_j) * grad W(r_ij, h).
523    /// Then accel -= sigma * |n_i| * (n_i / |n_i|) when |n_i| exceeds threshold.
524    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; // typical threshold
533
534        // First pass: compute colour field normal for each particle.
535        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        // Second pass: apply surface tension acceleration.
547        for (i, p) in particles.iter_mut().enumerate() {
548            let n_len = normals[i].length();
549            if n_len > threshold {
550                // Curvature force
551                let curvature_dir = normals[i] / n_len;
552                p.accel -= sigma * n_len * curvature_dir;
553            }
554        }
555    }
556
557    /// Apply external forces: gravity + type-specific bias + drag.
558    fn apply_external_forces(&self, particles: &mut [FluidParticle]) {
559        for p in particles.iter_mut() {
560            // Gravity
561            p.accel += self.gravity;
562
563            // Type-specific buoyancy / bias
564            p.accel += p.fluid_type.external_bias();
565
566            // Drag: a_drag = -drag_coeff * v
567            let drag = p.fluid_type.drag();
568            p.accel -= drag * p.velocity;
569        }
570    }
571
572    /// Integrate all particles using symplectic Euler.
573    ///
574    /// v(t + dt) = v(t) + a(t) * dt
575    /// x(t + dt) = x(t) + v(t + dt) * dt
576    fn integrate(&self, particles: &mut [FluidParticle], dt: f32) {
577        for p in particles.iter_mut() {
578            p.velocity += p.accel * dt;
579
580            // Clamp velocity to prevent explosions
581            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            // Floor collision (simple)
590            if p.position.y < FLOOR_Y {
591                p.position.y = FLOOR_Y;
592                p.velocity.y = p.velocity.y.abs() * 0.2; // slight bounce
593            }
594
595            // Reset acceleration for next step
596            p.accel = Vec3::ZERO;
597        }
598    }
599
600    /// Run one full SPH step: rebuild grid, find neighbours, compute forces,
601    /// integrate.
602    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// ── FluidPool ───────────────────────────────────────────────────────────────
625
626/// A settled pool of fluid on the floor, providing area gameplay effects.
627#[derive(Debug, Clone)]
628pub struct FluidPool {
629    /// Centre position (Y is FLOOR_Y).
630    pub position: Vec3,
631    /// Radius of the pool on the XZ plane.
632    pub radius: f32,
633    /// Type of fluid in this pool.
634    pub fluid_type: FluidType,
635    /// Depth of the pool (increases as more particles settle).
636    pub depth: f32,
637    /// Age in seconds since creation.
638    pub age: f32,
639    /// Maximum lifetime — pool evaporates after this (0 = infinite).
640    pub max_lifetime: f32,
641    /// Number of particles that have been absorbed into this pool.
642    pub absorbed_count: u32,
643}
644
645impl FluidPool {
646    /// Create a new pool at `position` with initial `radius`.
647    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,   // Holy doesn't pool
654            FluidType::Poison => 18.0,
655            FluidType::Healing => 0.0,  // Healing doesn't pool
656            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    /// Absorb a particle into this pool, growing it slightly.
670    pub fn absorb_particle(&mut self) {
671        self.absorbed_count += 1;
672        // Each particle adds a little radius and depth
673        self.radius += 0.005;
674        self.depth += 0.002;
675        self.depth = self.depth.min(0.2); // cap depth
676    }
677
678    /// Area of the pool (circle approximation).
679    pub fn area(&self) -> f32 {
680        PI * self.radius * self.radius
681    }
682
683    /// Whether a world-space point (XZ only) falls inside this pool.
684    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    /// Whether this pool is still alive.
691    pub fn alive(&self) -> bool {
692        if self.max_lifetime <= 0.0 {
693            return true; // infinite lifetime
694        }
695        self.age < self.max_lifetime
696    }
697
698    /// Fraction of lifetime remaining.
699    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    /// Base RGBA colour modulated by pool age.
707    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; // fade alpha
711        c
712    }
713
714    /// Update pool age.
715    pub fn update(&mut self, dt: f32) {
716        self.age += dt;
717    }
718
719    /// Merge another pool into this one (absorb its area/depth).
720    pub fn merge_from(&mut self, other: &FluidPool) {
721        // Weighted average position
722        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        // Combine radii (area-additive)
729        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    /// Distance between pool centres (XZ plane).
736    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
743// ── FluidSpawner ────────────────────────────────────────────────────────────
744
745/// High-level API for spawning themed fluid particle bursts.
746pub struct FluidSpawner;
747
748impl FluidSpawner {
749    /// Drip blood particles downward from a wound at `entity_pos` in `direction`.
750    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    /// Spawn a burning fire pool on the floor.
776    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    /// Spawn frost spreading outward on the floor.
798    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    /// Spawn an upward green healing particle fountain.
828    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    /// Spawn dark fluid flowing from `from_pos` (player damage source)
848    /// to `to_pos` (boss / Ouroboros target). The Ouroboros mechanic:
849    /// damage dealt to the player feeds the boss.
850    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            // Particles spawn along the path with velocity toward the target
864            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    /// Spawn necromantic energy crawling toward corpse positions on the floor.
879    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    /// Spawn poison bubbling up from a position.
917    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    /// Spawn holy light rising upward.
938    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/// Simple deterministic pseudo-random based on a seed float.
959/// Returns value in [0, 1).
960#[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// ── FluidRenderer ───────────────────────────────────────────────────────────
967
968/// Render data for a single fluid point sprite.
969#[derive(Debug, Clone, Copy)]
970pub struct FluidSpriteData {
971    /// World-space position.
972    pub position: Vec3,
973    /// RGBA colour.
974    pub color: [f32; 4],
975    /// Size of the point sprite.
976    pub size: f32,
977    /// Emission/glow intensity.
978    pub emission: f32,
979}
980
981/// Exports fluid particles as point-sprite render data.
982pub struct FluidRenderer {
983    /// Global size multiplier.
984    pub size_scale: f32,
985    /// Global emission multiplier.
986    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    /// Extract render data from a slice of particles.
998    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; // fade out alpha with lifetime
1007            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    /// Extract render data for pools (as flat disc sprites).
1020    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// ── Gameplay Effect Types ───────────────────────────────────────────────────
1044
1045/// Status effect applied to an entity standing in a fluid pool.
1046#[derive(Debug, Clone, Copy, PartialEq)]
1047pub enum FluidStatusEffect {
1048    /// Damage over time (fire, poison).
1049    DamageOverTime {
1050        damage_per_second: f32,
1051        element: FluidType,
1052    },
1053    /// Increased bleed damage multiplier (blood pool).
1054    BleedAmplify { multiplier: f32 },
1055    /// Movement slow (ice pool).
1056    Slow { factor: f32 },
1057    /// Mana drain per second (dark pool).
1058    ManaDrain { drain_per_second: f32 },
1059    /// Heal over time (healing).
1060    HealOverTime { heal_per_second: f32 },
1061    /// Necro pool — raises corpses faster.
1062    NecroEmpower { speed_multiplier: f32 },
1063}
1064
1065// ── FluidGameplayEffects ────────────────────────────────────────────────────
1066
1067/// Queries entity positions against active pools and returns status effects.
1068pub struct FluidGameplayEffects;
1069
1070impl FluidGameplayEffects {
1071    /// Check which pools an entity at `entity_pos` is standing in and return
1072    /// the combined status effects.
1073    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; // normalise by reference depth
1083            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                    // Holy pools purify — no negative effect, clears debuffs
1123                    // (handled externally by the game logic)
1124                }
1125            }
1126        }
1127        effects
1128    }
1129
1130    /// Compute the total damage-per-second from all DoT effects in a list.
1131    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    /// Compute the strongest slow factor from effects (0 = no slow, 1 = full stop).
1142    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    /// Compute total mana drain per second.
1153    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    /// Compute total heal per second.
1164    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    /// Compute total bleed amplification multiplier (multiplicative).
1175    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
1186// ── FluidManager ────────────────────────────────────────────────────────────
1187
1188/// Owns all fluid particles and pools. Drives the SPH simulation, handles
1189/// pool formation/merging, particle lifecycle, and provides render data.
1190pub struct FluidManager {
1191    /// All active fluid particles.
1192    pub particles: Vec<FluidParticle>,
1193    /// All active fluid pools.
1194    pub pools: Vec<FluidPool>,
1195    /// The SPH simulator.
1196    pub simulator: SPHSimulator,
1197    /// The renderer.
1198    pub renderer: FluidRenderer,
1199    /// Accumulated simulation time.
1200    pub time: f32,
1201    /// Fixed timestep for SPH (seconds).
1202    pub fixed_dt: f32,
1203    /// Accumulated time for fixed-step integration.
1204    time_accumulator: f32,
1205}
1206
1207impl FluidManager {
1208    /// Create a new FluidManager with default settings.
1209    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    /// Number of active particles.
1222    pub fn particle_count(&self) -> usize {
1223        self.particles.len()
1224    }
1225
1226    /// Number of active pools.
1227    pub fn pool_count(&self) -> usize {
1228        self.pools.len()
1229    }
1230
1231    /// Step the entire fluid system by `dt` seconds.
1232    pub fn update(&mut self, dt: f32) {
1233        self.time += dt;
1234        self.time_accumulator += dt;
1235
1236        // Fixed timestep SPH
1237        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        // Update particle lifetimes
1243        for p in &mut self.particles {
1244            p.lifetime -= dt;
1245        }
1246
1247        // Update pool ages
1248        for pool in &mut self.pools {
1249            pool.update(dt);
1250        }
1251
1252        // Convert settled particles to pools
1253        self.settle_particles_to_pools();
1254
1255        // Merge overlapping pools of the same type
1256        self.merge_pools();
1257
1258        // Remove dead particles
1259        self.particles.retain(|p| p.alive());
1260
1261        // Remove dead pools
1262        self.pools.retain(|p| p.alive());
1263
1264        // Enforce capacity limits
1265        while self.particles.len() > MAX_PARTICLES {
1266            // Remove oldest (lowest lifetime)
1267            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            // Remove oldest pool
1282            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    /// Check particles near the floor with low velocity and convert them to pool
1297    /// contributions.
1298    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            // This particle has settled — find a nearby pool or flag a new one
1313            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        // Remove settled particles in reverse order to preserve indices
1333        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        // Create new pools
1339        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    /// Merge overlapping pools of the same fluid type.
1349    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                    // Clone pool j data then merge into i
1373                    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        // Remove merged pools (in reverse)
1383        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    /// Get all particle render data.
1393    pub fn particle_sprites(&self) -> Vec<FluidSpriteData> {
1394        self.renderer.extract_sprites(&self.particles)
1395    }
1396
1397    /// Get all pool render data.
1398    pub fn pool_sprites(&self) -> Vec<FluidSpriteData> {
1399        self.renderer.extract_pool_sprites(&self.pools)
1400    }
1401
1402    /// Query gameplay effects for an entity at `pos`.
1403    pub fn query_effects_at(&self, pos: Vec3) -> Vec<FluidStatusEffect> {
1404        FluidGameplayEffects::query_effects(&self.pools, pos)
1405    }
1406
1407    // ── Convenience spawner methods ─────────────────────────────────────────
1408
1409    /// Spawn blood drip from a wound.
1410    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    /// Spawn a fire pool.
1415    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    /// Spawn ice spread.
1420    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    /// Spawn healing fountain.
1425    pub fn spawn_healing_fountain(&mut self, position: Vec3, count: usize) {
1426        FluidSpawner::spawn_healing_fountain(&mut self.particles, position, count);
1427    }
1428
1429    /// Spawn Ouroboros dark flow.
1430    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    /// Spawn necro crawl toward corpses.
1435    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    /// Spawn poison bubbles.
1450    pub fn spawn_poison_bubbles(&mut self, position: Vec3, count: usize) {
1451        FluidSpawner::spawn_poison_bubbles(&mut self.particles, position, count);
1452    }
1453
1454    /// Spawn holy rise.
1455    pub fn spawn_holy_rise(&mut self, position: Vec3, count: usize) {
1456        FluidSpawner::spawn_holy_rise(&mut self.particles, position, count);
1457    }
1458
1459    /// Clear all particles and pools.
1460    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// ── Tests ───────────────────────────────────────────────────────────────────
1473
1474#[cfg(test)]
1475mod tests {
1476    use super::*;
1477
1478    // ── Kernel tests ────────────────────────────────────────────────────────
1479
1480    #[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    // ── Density tests ───────────────────────────────────────────────────────
1520
1521    #[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        // Single particle density = mass * W(0, h) > 0
1529        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    // ── Pressure tests ──────────────────────────────────────────────────────
1565
1566    #[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    // ── Pool tests ──────────────────────────────────────────────────────────
1595
1596    #[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); // 1 from new + 1 from absorb
1613    }
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    // ── FluidType tests ─────────────────────────────────────────────────────
1640
1641    #[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    // ── Spawner tests ───────────────────────────────────────────────────────
1657
1658    #[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        // Fill up to near max
1677        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            // Healing particles should have upward velocity
1704            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            // Should have positive X velocity (toward target)
1718            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); // 5 per corpse
1732        for p in &particles {
1733            assert_eq!(p.fluid_type, FluidType::Necro);
1734        }
1735    }
1736
1737    // ── Gameplay effects tests ──────────────────────────────────────────────
1738
1739    #[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    // ── Manager integration tests ───────────────────────────────────────────
1794
1795    #[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        // Particles should still be alive after one frame
1802        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        // Advance past blood lifetime (4 seconds)
1810        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    // ── Renderer tests ──────────────────────────────────────────────────────
1832
1833    #[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    // ── SPH step integration test ───────────────────────────────────────────
1853
1854    #[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}