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    /// Cubic spline kernel W(r, h).
384    #[inline]
385    fn kernel(&self, r: f32) -> f32 {
386        cubic_kernel(r, self.h)
387    }
388
389    /// Scalar gradient of kernel dW/dr.
390    #[inline]
391    fn kernel_grad_scalar(&self, r: f32) -> f32 {
392        cubic_kernel_grad(r, self.h)
393    }
394
395    /// Vector gradient of kernel.
396    #[inline]
397    fn kernel_grad_vec(&self, r_vec: Vec3) -> Vec3 {
398        kernel_gradient(r_vec, self.h)
399    }
400
401    // ── SPH steps ───────────────────────────────────────────────────────────
402
403    /// Rebuild the spatial hash grid from current particle positions.
404    fn rebuild_grid(&mut self, particles: &[FluidParticle]) {
405        let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
406        self.grid.rebuild(&positions);
407    }
408
409    /// Find neighbours for every particle.
410    fn find_neighbors(&self, particles: &mut [FluidParticle]) {
411        let h = self.h;
412        let h_sq = h * h;
413        let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
414        for (i, p) in particles.iter_mut().enumerate() {
415            let candidates = self.grid.query(p.position);
416            p.neighbors.clear();
417            for &j in &candidates {
418                if j == i {
419                    continue;
420                }
421                let diff = positions[i] - positions[j];
422                if diff.length_squared() < h_sq {
423                    p.neighbors.push(j);
424                }
425            }
426        }
427    }
428
429    /// Compute density for each particle: rho_i = sum_j m_j * W(r_ij, h).
430    fn compute_density(&self, particles: &mut [FluidParticle]) {
431        let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
432        let masses: Vec<f32> = particles.iter().map(|p| p.mass).collect();
433        let neighbors_snapshot: Vec<Vec<usize>> =
434            particles.iter().map(|p| p.neighbors.clone()).collect();
435
436        for (i, p) in particles.iter_mut().enumerate() {
437            // Self contribution
438            let mut rho = p.mass * self.kernel(0.0);
439            for &j in &neighbors_snapshot[i] {
440                let r = (positions[i] - positions[j]).length();
441                rho += masses[j] * self.kernel(r);
442            }
443            p.density = rho.max(1.0); // avoid zero density
444        }
445    }
446
447    /// Compute pressure from density via Tait equation of state:
448    /// P = B * ((rho / rho0)^gamma - 1)
449    fn compute_pressure(&self, particles: &mut [FluidParticle]) {
450        let b = self.stiffness;
451        let g = self.gamma;
452        for p in particles.iter_mut() {
453            let ratio = p.density / p.rest_density;
454            p.pressure = b * (ratio.powf(g) - 1.0);
455            if p.pressure < 0.0 {
456                p.pressure = 0.0;
457            }
458        }
459    }
460
461    /// Compute pressure force: a_i += -sum_j m_j * (P_i/rho_i^2 + P_j/rho_j^2) * grad W
462    fn compute_pressure_force(&self, particles: &mut [FluidParticle]) {
463        let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
464        let masses: Vec<f32> = particles.iter().map(|p| p.mass).collect();
465        let pressures: Vec<f32> = particles.iter().map(|p| p.pressure).collect();
466        let densities: Vec<f32> = particles.iter().map(|p| p.density).collect();
467        let neighbors_snapshot: Vec<Vec<usize>> =
468            particles.iter().map(|p| p.neighbors.clone()).collect();
469
470        for (i, p) in particles.iter_mut().enumerate() {
471            let mut accel = Vec3::ZERO;
472            let pi_over_rho2 = pressures[i] / (densities[i] * densities[i]);
473            for &j in &neighbors_snapshot[i] {
474                let pj_over_rho2 = pressures[j] / (densities[j] * densities[j]);
475                let r_vec = positions[i] - positions[j];
476                let grad_w = self.kernel_grad_vec(r_vec);
477                accel -= masses[j] * (pi_over_rho2 + pj_over_rho2) * grad_w;
478            }
479            p.accel += accel;
480        }
481    }
482
483    /// Compute viscosity force: Laplacian of velocity (artificial viscosity).
484    /// a_visc_i = mu * sum_j m_j * (v_j - v_i) / rho_j * laplacian W
485    /// We approximate laplacian W with 2 * (d+2) * dot(v_ij, r_ij) / (|r|^2 + eps) * grad W
486    /// (Monaghan artificial viscosity approach simplified).
487    fn compute_viscosity_force(&self, particles: &mut [FluidParticle]) {
488        let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
489        let velocities: Vec<Vec3> = particles.iter().map(|p| p.velocity).collect();
490        let masses: Vec<f32> = particles.iter().map(|p| p.mass).collect();
491        let densities: Vec<f32> = particles.iter().map(|p| p.density).collect();
492        let viscosities: Vec<f32> = particles.iter().map(|p| p.viscosity).collect();
493        let neighbors_snapshot: Vec<Vec<usize>> =
494            particles.iter().map(|p| p.neighbors.clone()).collect();
495
496        let eps = 0.01 * self.h * self.h;
497
498        for (i, p) in particles.iter_mut().enumerate() {
499            let mut accel = Vec3::ZERO;
500            let mu = self.viscosity * viscosities[i];
501            for &j in &neighbors_snapshot[i] {
502                let r_vec = positions[i] - positions[j];
503                let v_diff = velocities[j] - velocities[i];
504                let r_dot_v = r_vec.dot(v_diff);
505                let r_len_sq = r_vec.length_squared() + eps;
506                let grad_w = self.kernel_grad_vec(r_vec);
507                // Simplified Monaghan-style: 2*(d+2) with d=3 => 10
508                let factor = 10.0 * masses[j] / densities[j] * r_dot_v / r_len_sq;
509                accel += mu * factor * grad_w;
510            }
511            p.accel += accel;
512        }
513    }
514
515    /// Surface tension via colour-field gradient.
516    /// For each particle compute n_i = sum_j (m_j / rho_j) * grad W(r_ij, h).
517    /// Then accel -= sigma * |n_i| * (n_i / |n_i|) when |n_i| exceeds threshold.
518    fn compute_surface_tension(&self, particles: &mut [FluidParticle]) {
519        let positions: Vec<Vec3> = particles.iter().map(|p| p.position).collect();
520        let masses: Vec<f32> = particles.iter().map(|p| p.mass).collect();
521        let densities: Vec<f32> = particles.iter().map(|p| p.density).collect();
522        let neighbors_snapshot: Vec<Vec<usize>> =
523            particles.iter().map(|p| p.neighbors.clone()).collect();
524
525        let sigma = self.surface_tension;
526        let threshold = 6.0 / self.h; // typical threshold
527
528        // First pass: compute colour field normal for each particle.
529        let mut normals = vec![Vec3::ZERO; particles.len()];
530        for (i, _p) in particles.iter().enumerate() {
531            let mut n = Vec3::ZERO;
532            for &j in &neighbors_snapshot[i] {
533                let r_vec = positions[i] - positions[j];
534                let grad_w = self.kernel_grad_vec(r_vec);
535                n += (masses[j] / densities[j]) * grad_w;
536            }
537            normals[i] = n;
538        }
539
540        // Second pass: apply surface tension acceleration.
541        for (i, p) in particles.iter_mut().enumerate() {
542            let n_len = normals[i].length();
543            if n_len > threshold {
544                // Curvature force
545                let curvature_dir = normals[i] / n_len;
546                p.accel -= sigma * n_len * curvature_dir;
547            }
548        }
549    }
550
551    /// Apply external forces: gravity + type-specific bias + drag.
552    fn apply_external_forces(&self, particles: &mut [FluidParticle]) {
553        for p in particles.iter_mut() {
554            // Gravity
555            p.accel += self.gravity;
556
557            // Type-specific buoyancy / bias
558            p.accel += p.fluid_type.external_bias();
559
560            // Drag: a_drag = -drag_coeff * v
561            let drag = p.fluid_type.drag();
562            p.accel -= drag * p.velocity;
563        }
564    }
565
566    /// Integrate all particles using symplectic Euler.
567    ///
568    /// v(t + dt) = v(t) + a(t) * dt
569    /// x(t + dt) = x(t) + v(t + dt) * dt
570    fn integrate(&self, particles: &mut [FluidParticle], dt: f32) {
571        for p in particles.iter_mut() {
572            p.velocity += p.accel * dt;
573
574            // Clamp velocity to prevent explosions
575            let max_speed = 20.0;
576            let speed = p.velocity.length();
577            if speed > max_speed {
578                p.velocity *= max_speed / speed;
579            }
580
581            p.position += p.velocity * dt;
582
583            // Floor collision (simple)
584            if p.position.y < FLOOR_Y {
585                p.position.y = FLOOR_Y;
586                p.velocity.y = p.velocity.y.abs() * 0.2; // slight bounce
587            }
588
589            // Reset acceleration for next step
590            p.accel = Vec3::ZERO;
591        }
592    }
593
594    /// Run one full SPH step: rebuild grid, find neighbours, compute forces,
595    /// integrate.
596    pub fn step(&mut self, particles: &mut [FluidParticle], dt: f32) {
597        if particles.is_empty() {
598            return;
599        }
600        self.rebuild_grid(particles);
601        self.find_neighbors(particles);
602        self.compute_density(particles);
603        self.compute_pressure(particles);
604        self.compute_pressure_force(particles);
605        self.compute_viscosity_force(particles);
606        self.compute_surface_tension(particles);
607        self.apply_external_forces(particles);
608        self.integrate(particles, dt);
609    }
610}
611
612impl Default for SPHSimulator {
613    fn default() -> Self {
614        Self::new()
615    }
616}
617
618// ── FluidPool ───────────────────────────────────────────────────────────────
619
620/// A settled pool of fluid on the floor, providing area gameplay effects.
621#[derive(Debug, Clone)]
622pub struct FluidPool {
623    /// Centre position (Y is FLOOR_Y).
624    pub position: Vec3,
625    /// Radius of the pool on the XZ plane.
626    pub radius: f32,
627    /// Type of fluid in this pool.
628    pub fluid_type: FluidType,
629    /// Depth of the pool (increases as more particles settle).
630    pub depth: f32,
631    /// Age in seconds since creation.
632    pub age: f32,
633    /// Maximum lifetime — pool evaporates after this (0 = infinite).
634    pub max_lifetime: f32,
635    /// Number of particles that have been absorbed into this pool.
636    pub absorbed_count: u32,
637}
638
639impl FluidPool {
640    /// Create a new pool at `position` with initial `radius`.
641    pub fn new(position: Vec3, radius: f32, fluid_type: FluidType) -> Self {
642        let max_lifetime = match fluid_type {
643            FluidType::Blood  => 15.0,
644            FluidType::Fire   => 8.0,
645            FluidType::Ice    => 20.0,
646            FluidType::Dark   => 25.0,
647            FluidType::Holy   => 0.0,   // Holy doesn't pool
648            FluidType::Poison => 18.0,
649            FluidType::Healing => 0.0,  // Healing doesn't pool
650            FluidType::Necro  => 30.0,
651        };
652        Self {
653            position: Vec3::new(position.x, FLOOR_Y, position.z),
654            radius,
655            fluid_type,
656            depth: MIN_POOL_DEPTH,
657            age: 0.0,
658            max_lifetime,
659            absorbed_count: 1,
660        }
661    }
662
663    /// Absorb a particle into this pool, growing it slightly.
664    pub fn absorb_particle(&mut self) {
665        self.absorbed_count += 1;
666        // Each particle adds a little radius and depth
667        self.radius += 0.005;
668        self.depth += 0.002;
669        self.depth = self.depth.min(0.2); // cap depth
670    }
671
672    /// Area of the pool (circle approximation).
673    pub fn area(&self) -> f32 {
674        PI * self.radius * self.radius
675    }
676
677    /// Whether a world-space point (XZ only) falls inside this pool.
678    pub fn contains_xz(&self, point: Vec3) -> bool {
679        let dx = point.x - self.position.x;
680        let dz = point.z - self.position.z;
681        dx * dx + dz * dz <= self.radius * self.radius
682    }
683
684    /// Whether this pool is still alive.
685    pub fn alive(&self) -> bool {
686        if self.max_lifetime <= 0.0 {
687            return true; // infinite lifetime
688        }
689        self.age < self.max_lifetime
690    }
691
692    /// Fraction of lifetime remaining.
693    pub fn life_fraction(&self) -> f32 {
694        if self.max_lifetime <= 0.0 {
695            return 1.0;
696        }
697        (1.0 - self.age / self.max_lifetime).clamp(0.0, 1.0)
698    }
699
700    /// Base RGBA colour modulated by pool age.
701    pub fn color(&self) -> [f32; 4] {
702        let mut c = self.fluid_type.base_color();
703        let f = self.life_fraction();
704        c[3] *= f; // fade alpha
705        c
706    }
707
708    /// Update pool age.
709    pub fn update(&mut self, dt: f32) {
710        self.age += dt;
711    }
712
713    /// Merge another pool into this one (absorb its area/depth).
714    pub fn merge_from(&mut self, other: &FluidPool) {
715        // Weighted average position
716        let total = self.absorbed_count + other.absorbed_count;
717        if total > 0 {
718            let w_self = self.absorbed_count as f32 / total as f32;
719            let w_other = other.absorbed_count as f32 / total as f32;
720            self.position = self.position * w_self + other.position * w_other;
721        }
722        // Combine radii (area-additive)
723        let combined_area = self.area() + other.area();
724        self.radius = (combined_area / PI).sqrt();
725        self.depth = self.depth.max(other.depth);
726        self.absorbed_count += other.absorbed_count;
727    }
728
729    /// Distance between pool centres (XZ plane).
730    pub fn distance_to(&self, other: &FluidPool) -> f32 {
731        let dx = self.position.x - other.position.x;
732        let dz = self.position.z - other.position.z;
733        (dx * dx + dz * dz).sqrt()
734    }
735}
736
737// ── FluidSpawner ────────────────────────────────────────────────────────────
738
739/// High-level API for spawning themed fluid particle bursts.
740pub struct FluidSpawner;
741
742impl FluidSpawner {
743    /// Drip blood particles downward from a wound at `entity_pos` in `direction`.
744    pub fn spawn_bleed(
745        particles: &mut Vec<FluidParticle>,
746        entity_pos: Vec3,
747        direction: Vec3,
748        count: usize,
749    ) {
750        let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
751        let dir = if direction.length_squared() > 0.001 {
752            direction.normalize()
753        } else {
754            Vec3::new(0.0, -1.0, 0.0)
755        };
756        for i in 0..count {
757            let t = i as f32 / count.max(1) as f32;
758            let spread = Vec3::new(
759                pseudo_random(i as f32 * 1.1) * 0.3 - 0.15,
760                pseudo_random(i as f32 * 2.3) * 0.1,
761                pseudo_random(i as f32 * 3.7) * 0.3 - 0.15,
762            );
763            let vel = dir * (1.5 + t * 0.5) + Vec3::new(0.0, -2.0, 0.0) + spread;
764            let p = FluidParticle::new(entity_pos + spread * 0.1, vel, FluidType::Blood);
765            particles.push(p);
766        }
767    }
768
769    /// Spawn a burning fire pool on the floor.
770    pub fn spawn_fire_pool(
771        particles: &mut Vec<FluidParticle>,
772        position: Vec3,
773        radius: f32,
774        count: usize,
775    ) {
776        let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
777        for i in 0..count {
778            let angle = pseudo_random(i as f32 * 4.1) * 2.0 * PI;
779            let r = pseudo_random(i as f32 * 5.3) * radius;
780            let offset = Vec3::new(angle.cos() * r, 0.0, angle.sin() * r);
781            let vel = Vec3::new(
782                pseudo_random(i as f32 * 6.7) * 0.5 - 0.25,
783                2.0 + pseudo_random(i as f32 * 7.1) * 3.0,
784                pseudo_random(i as f32 * 8.3) * 0.5 - 0.25,
785            );
786            let p = FluidParticle::new(position + offset, vel, FluidType::Fire);
787            particles.push(p);
788        }
789    }
790
791    /// Spawn frost spreading outward on the floor.
792    pub fn spawn_ice_spread(
793        particles: &mut Vec<FluidParticle>,
794        position: Vec3,
795        radius: f32,
796        count: usize,
797    ) {
798        let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
799        for i in 0..count {
800            let angle = pseudo_random(i as f32 * 9.1) * 2.0 * PI;
801            let spread_speed = 0.5 + pseudo_random(i as f32 * 10.3) * 1.5;
802            let vel = Vec3::new(
803                angle.cos() * spread_speed,
804                -0.1,
805                angle.sin() * spread_speed,
806            );
807            let offset = Vec3::new(
808                pseudo_random(i as f32 * 11.7) * radius * 0.2,
809                0.05,
810                pseudo_random(i as f32 * 12.3) * radius * 0.2,
811            );
812            let p = FluidParticle::new(
813                Vec3::new(position.x, FLOOR_Y + 0.05, position.z) + offset,
814                vel,
815                FluidType::Ice,
816            );
817            particles.push(p);
818        }
819    }
820
821    /// Spawn an upward green healing particle fountain.
822    pub fn spawn_healing_fountain(
823        particles: &mut Vec<FluidParticle>,
824        position: Vec3,
825        count: usize,
826    ) {
827        let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
828        for i in 0..count {
829            let angle = pseudo_random(i as f32 * 13.1) * 2.0 * PI;
830            let r = pseudo_random(i as f32 * 14.3) * 0.15;
831            let vel = Vec3::new(
832                angle.cos() * r * 2.0,
833                4.0 + pseudo_random(i as f32 * 15.7) * 3.0,
834                angle.sin() * r * 2.0,
835            );
836            let p = FluidParticle::new(position, vel, FluidType::Healing);
837            particles.push(p);
838        }
839    }
840
841    /// Spawn dark fluid flowing from `from_pos` (player damage source)
842    /// to `to_pos` (boss / Ouroboros target). The Ouroboros mechanic:
843    /// damage dealt to the player feeds the boss.
844    pub fn spawn_ouroboros_flow(
845        particles: &mut Vec<FluidParticle>,
846        from_pos: Vec3,
847        to_pos: Vec3,
848        count: usize,
849    ) {
850        let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
851        let dir = to_pos - from_pos;
852        let dist = dir.length();
853        let dir_norm = if dist > 0.001 { dir / dist } else { Vec3::X };
854
855        for i in 0..count {
856            let t = i as f32 / count.max(1) as f32;
857            // Particles spawn along the path with velocity toward the target
858            let spawn_pos = from_pos + dir * t * 0.3;
859            let speed = 3.0 + pseudo_random(i as f32 * 16.1) * 2.0;
860            let wobble = Vec3::new(
861                pseudo_random(i as f32 * 17.3) * 0.5 - 0.25,
862                pseudo_random(i as f32 * 18.7) * 0.3 - 0.15,
863                pseudo_random(i as f32 * 19.1) * 0.5 - 0.25,
864            );
865            let vel = dir_norm * speed + wobble;
866            let mut p = FluidParticle::new(spawn_pos, vel, FluidType::Dark);
867            p.lifetime = (dist / speed).max(1.0);
868            particles.push(p);
869        }
870    }
871
872    /// Spawn necromantic energy crawling toward corpse positions on the floor.
873    pub fn spawn_necro_crawl(
874        particles: &mut Vec<FluidParticle>,
875        origin: Vec3,
876        corpse_positions: &[Vec3],
877        particles_per_corpse: usize,
878    ) {
879        if corpse_positions.is_empty() {
880            return;
881        }
882        for (ci, &corpse) in corpse_positions.iter().enumerate() {
883            let remaining = MAX_PARTICLES.saturating_sub(particles.len());
884            let count = particles_per_corpse.min(remaining);
885            if count == 0 {
886                break;
887            }
888            let dir = corpse - origin;
889            let dist = dir.length();
890            let dir_norm = if dist > 0.001 { dir / dist } else { Vec3::X };
891
892            for i in 0..count {
893                let speed = 1.0 + pseudo_random((ci * 100 + i) as f32 * 20.1) * 2.0;
894                let wobble = Vec3::new(
895                    pseudo_random((ci * 100 + i) as f32 * 21.3) * 0.4 - 0.2,
896                    0.0,
897                    pseudo_random((ci * 100 + i) as f32 * 22.7) * 0.4 - 0.2,
898                );
899                let vel = dir_norm * speed + wobble;
900                let p = FluidParticle::new(
901                    Vec3::new(origin.x, FLOOR_Y + 0.03, origin.z),
902                    vel,
903                    FluidType::Necro,
904                );
905                particles.push(p);
906            }
907        }
908    }
909
910    /// Spawn poison bubbling up from a position.
911    pub fn spawn_poison_bubbles(
912        particles: &mut Vec<FluidParticle>,
913        position: Vec3,
914        count: usize,
915    ) {
916        let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
917        for i in 0..count {
918            let angle = pseudo_random(i as f32 * 23.1) * 2.0 * PI;
919            let r = pseudo_random(i as f32 * 24.3) * 0.3;
920            let offset = Vec3::new(angle.cos() * r, 0.0, angle.sin() * r);
921            let vel = Vec3::new(
922                pseudo_random(i as f32 * 25.7) * 0.3 - 0.15,
923                0.5 + pseudo_random(i as f32 * 26.1) * 1.0,
924                pseudo_random(i as f32 * 27.3) * 0.3 - 0.15,
925            );
926            let p = FluidParticle::new(position + offset, vel, FluidType::Poison);
927            particles.push(p);
928        }
929    }
930
931    /// Spawn holy light rising upward.
932    pub fn spawn_holy_rise(
933        particles: &mut Vec<FluidParticle>,
934        position: Vec3,
935        count: usize,
936    ) {
937        let count = count.min(MAX_PARTICLES.saturating_sub(particles.len()));
938        for i in 0..count {
939            let angle = pseudo_random(i as f32 * 28.1) * 2.0 * PI;
940            let r = pseudo_random(i as f32 * 29.3) * 0.2;
941            let vel = Vec3::new(
942                angle.cos() * r * 1.5,
943                5.0 + pseudo_random(i as f32 * 30.7) * 2.0,
944                angle.sin() * r * 1.5,
945            );
946            let p = FluidParticle::new(position, vel, FluidType::Holy);
947            particles.push(p);
948        }
949    }
950}
951
952/// Simple deterministic pseudo-random based on a seed float.
953/// Returns value in [0, 1).
954#[inline]
955fn pseudo_random(seed: f32) -> f32 {
956    let x = (seed * 12.9898 + 78.233).sin() * 43758.5453;
957    x - x.floor()
958}
959
960// ── FluidRenderer ───────────────────────────────────────────────────────────
961
962/// Render data for a single fluid point sprite.
963#[derive(Debug, Clone, Copy)]
964pub struct FluidSpriteData {
965    /// World-space position.
966    pub position: Vec3,
967    /// RGBA colour.
968    pub color: [f32; 4],
969    /// Size of the point sprite.
970    pub size: f32,
971    /// Emission/glow intensity.
972    pub emission: f32,
973}
974
975/// Exports fluid particles as point-sprite render data.
976pub struct FluidRenderer {
977    /// Global size multiplier.
978    pub size_scale: f32,
979    /// Global emission multiplier.
980    pub emission_scale: f32,
981}
982
983impl FluidRenderer {
984    pub fn new() -> Self {
985        Self {
986            size_scale: 1.0,
987            emission_scale: 1.0,
988        }
989    }
990
991    /// Extract render data from a slice of particles.
992    pub fn extract_sprites(&self, particles: &[FluidParticle]) -> Vec<FluidSpriteData> {
993        let mut sprites = Vec::with_capacity(particles.len());
994        for p in particles {
995            if !p.alive() {
996                continue;
997            }
998            let life = p.life_fraction();
999            let mut color = p.color;
1000            color[3] *= life; // fade out alpha with lifetime
1001            let size = p.fluid_type.sprite_size() * self.size_scale * (0.5 + 0.5 * life);
1002            let emission = p.fluid_type.emission() * self.emission_scale * life;
1003            sprites.push(FluidSpriteData {
1004                position: p.position,
1005                color,
1006                size,
1007                emission,
1008            });
1009        }
1010        sprites
1011    }
1012
1013    /// Extract render data for pools (as flat disc sprites).
1014    pub fn extract_pool_sprites(&self, pools: &[FluidPool]) -> Vec<FluidSpriteData> {
1015        let mut sprites = Vec::with_capacity(pools.len());
1016        for pool in pools {
1017            if !pool.alive() {
1018                continue;
1019            }
1020            sprites.push(FluidSpriteData {
1021                position: pool.position,
1022                color: pool.color(),
1023                size: pool.radius * 2.0 * self.size_scale,
1024                emission: pool.fluid_type.emission() * self.emission_scale * pool.life_fraction(),
1025            });
1026        }
1027        sprites
1028    }
1029}
1030
1031impl Default for FluidRenderer {
1032    fn default() -> Self {
1033        Self::new()
1034    }
1035}
1036
1037// ── Gameplay Effect Types ───────────────────────────────────────────────────
1038
1039/// Status effect applied to an entity standing in a fluid pool.
1040#[derive(Debug, Clone, Copy, PartialEq)]
1041pub enum FluidStatusEffect {
1042    /// Damage over time (fire, poison).
1043    DamageOverTime {
1044        damage_per_second: f32,
1045        element: FluidType,
1046    },
1047    /// Increased bleed damage multiplier (blood pool).
1048    BleedAmplify { multiplier: f32 },
1049    /// Movement slow (ice pool).
1050    Slow { factor: f32 },
1051    /// Mana drain per second (dark pool).
1052    ManaDrain { drain_per_second: f32 },
1053    /// Heal over time (healing).
1054    HealOverTime { heal_per_second: f32 },
1055    /// Necro pool — raises corpses faster.
1056    NecroEmpower { speed_multiplier: f32 },
1057}
1058
1059// ── FluidGameplayEffects ────────────────────────────────────────────────────
1060
1061/// Queries entity positions against active pools and returns status effects.
1062pub struct FluidGameplayEffects;
1063
1064impl FluidGameplayEffects {
1065    /// Check which pools an entity at `entity_pos` is standing in and return
1066    /// the combined status effects.
1067    pub fn query_effects(pools: &[FluidPool], entity_pos: Vec3) -> Vec<FluidStatusEffect> {
1068        let mut effects = Vec::new();
1069        for pool in pools {
1070            if !pool.alive() {
1071                continue;
1072            }
1073            if !pool.contains_xz(entity_pos) {
1074                continue;
1075            }
1076            let intensity = pool.depth / 0.1; // normalise by reference depth
1077            match pool.fluid_type {
1078                FluidType::Blood => {
1079                    effects.push(FluidStatusEffect::BleedAmplify {
1080                        multiplier: 1.0 + 0.5 * intensity,
1081                    });
1082                }
1083                FluidType::Fire => {
1084                    effects.push(FluidStatusEffect::DamageOverTime {
1085                        damage_per_second: 15.0 * intensity,
1086                        element: FluidType::Fire,
1087                    });
1088                }
1089                FluidType::Ice => {
1090                    effects.push(FluidStatusEffect::Slow {
1091                        factor: (0.3 + 0.2 * intensity).min(0.8),
1092                    });
1093                }
1094                FluidType::Dark => {
1095                    effects.push(FluidStatusEffect::ManaDrain {
1096                        drain_per_second: 10.0 * intensity,
1097                    });
1098                }
1099                FluidType::Poison => {
1100                    effects.push(FluidStatusEffect::DamageOverTime {
1101                        damage_per_second: 8.0 * intensity,
1102                        element: FluidType::Poison,
1103                    });
1104                }
1105                FluidType::Healing => {
1106                    effects.push(FluidStatusEffect::HealOverTime {
1107                        heal_per_second: 12.0 * intensity,
1108                    });
1109                }
1110                FluidType::Necro => {
1111                    effects.push(FluidStatusEffect::NecroEmpower {
1112                        speed_multiplier: 1.0 + 1.0 * intensity,
1113                    });
1114                }
1115                FluidType::Holy => {
1116                    // Holy pools purify — no negative effect, clears debuffs
1117                    // (handled externally by the game logic)
1118                }
1119            }
1120        }
1121        effects
1122    }
1123
1124    /// Compute the total damage-per-second from all DoT effects in a list.
1125    pub fn total_dot(effects: &[FluidStatusEffect]) -> f32 {
1126        let mut total = 0.0;
1127        for e in effects {
1128            if let FluidStatusEffect::DamageOverTime { damage_per_second, .. } = e {
1129                total += damage_per_second;
1130            }
1131        }
1132        total
1133    }
1134
1135    /// Compute the strongest slow factor from effects (0 = no slow, 1 = full stop).
1136    pub fn strongest_slow(effects: &[FluidStatusEffect]) -> f32 {
1137        let mut max_slow = 0.0_f32;
1138        for e in effects {
1139            if let FluidStatusEffect::Slow { factor } = e {
1140                max_slow = max_slow.max(*factor);
1141            }
1142        }
1143        max_slow
1144    }
1145
1146    /// Compute total mana drain per second.
1147    pub fn total_mana_drain(effects: &[FluidStatusEffect]) -> f32 {
1148        let mut total = 0.0;
1149        for e in effects {
1150            if let FluidStatusEffect::ManaDrain { drain_per_second } = e {
1151                total += drain_per_second;
1152            }
1153        }
1154        total
1155    }
1156
1157    /// Compute total heal per second.
1158    pub fn total_heal(effects: &[FluidStatusEffect]) -> f32 {
1159        let mut total = 0.0;
1160        for e in effects {
1161            if let FluidStatusEffect::HealOverTime { heal_per_second } = e {
1162                total += heal_per_second;
1163            }
1164        }
1165        total
1166    }
1167
1168    /// Compute total bleed amplification multiplier (multiplicative).
1169    pub fn bleed_multiplier(effects: &[FluidStatusEffect]) -> f32 {
1170        let mut mult = 1.0;
1171        for e in effects {
1172            if let FluidStatusEffect::BleedAmplify { multiplier } = e {
1173                mult *= multiplier;
1174            }
1175        }
1176        mult
1177    }
1178}
1179
1180// ── FluidManager ────────────────────────────────────────────────────────────
1181
1182/// Owns all fluid particles and pools. Drives the SPH simulation, handles
1183/// pool formation/merging, particle lifecycle, and provides render data.
1184pub struct FluidManager {
1185    /// All active fluid particles.
1186    pub particles: Vec<FluidParticle>,
1187    /// All active fluid pools.
1188    pub pools: Vec<FluidPool>,
1189    /// The SPH simulator.
1190    pub simulator: SPHSimulator,
1191    /// The renderer.
1192    pub renderer: FluidRenderer,
1193    /// Accumulated simulation time.
1194    pub time: f32,
1195    /// Fixed timestep for SPH (seconds).
1196    pub fixed_dt: f32,
1197    /// Accumulated time for fixed-step integration.
1198    time_accumulator: f32,
1199}
1200
1201impl FluidManager {
1202    /// Create a new FluidManager with default settings.
1203    pub fn new() -> Self {
1204        Self {
1205            particles: Vec::with_capacity(MAX_PARTICLES),
1206            pools: Vec::with_capacity(MAX_POOLS),
1207            simulator: SPHSimulator::new(),
1208            renderer: FluidRenderer::new(),
1209            time: 0.0,
1210            fixed_dt: 1.0 / 60.0,
1211            time_accumulator: 0.0,
1212        }
1213    }
1214
1215    /// Number of active particles.
1216    pub fn particle_count(&self) -> usize {
1217        self.particles.len()
1218    }
1219
1220    /// Number of active pools.
1221    pub fn pool_count(&self) -> usize {
1222        self.pools.len()
1223    }
1224
1225    /// Step the entire fluid system by `dt` seconds.
1226    pub fn update(&mut self, dt: f32) {
1227        self.time += dt;
1228        self.time_accumulator += dt;
1229
1230        // Fixed timestep SPH
1231        while self.time_accumulator >= self.fixed_dt {
1232            self.simulator.step(&mut self.particles, self.fixed_dt);
1233            self.time_accumulator -= self.fixed_dt;
1234        }
1235
1236        // Update particle lifetimes
1237        for p in &mut self.particles {
1238            p.lifetime -= dt;
1239        }
1240
1241        // Update pool ages
1242        for pool in &mut self.pools {
1243            pool.update(dt);
1244        }
1245
1246        // Convert settled particles to pools
1247        self.settle_particles_to_pools();
1248
1249        // Merge overlapping pools of the same type
1250        self.merge_pools();
1251
1252        // Remove dead particles
1253        self.particles.retain(|p| p.alive());
1254
1255        // Remove dead pools
1256        self.pools.retain(|p| p.alive());
1257
1258        // Enforce capacity limits
1259        while self.particles.len() > MAX_PARTICLES {
1260            // Remove oldest (lowest lifetime)
1261            if let Some(min_idx) = self
1262                .particles
1263                .iter()
1264                .enumerate()
1265                .min_by(|a, b| a.1.lifetime.partial_cmp(&b.1.lifetime).unwrap())
1266                .map(|(i, _)| i)
1267            {
1268                self.particles.swap_remove(min_idx);
1269            } else {
1270                break;
1271            }
1272        }
1273
1274        while self.pools.len() > MAX_POOLS {
1275            // Remove oldest pool
1276            if let Some(min_idx) = self
1277                .pools
1278                .iter()
1279                .enumerate()
1280                .max_by(|a, b| a.1.age.partial_cmp(&b.1.age).unwrap())
1281                .map(|(i, _)| i)
1282            {
1283                self.pools.swap_remove(min_idx);
1284            } else {
1285                break;
1286            }
1287        }
1288    }
1289
1290    /// Check particles near the floor with low velocity and convert them to pool
1291    /// contributions.
1292    fn settle_particles_to_pools(&mut self) {
1293        let mut settled_indices = Vec::new();
1294        let mut new_pool_data: Vec<(Vec3, FluidType)> = Vec::new();
1295
1296        for (i, p) in self.particles.iter().enumerate() {
1297            if !p.fluid_type.can_pool() {
1298                continue;
1299            }
1300            if p.position.y > FLOOR_Y + 0.1 {
1301                continue;
1302            }
1303            if p.speed() > SETTLE_SPEED {
1304                continue;
1305            }
1306            // This particle has settled — find a nearby pool or flag a new one
1307            let mut found_pool = false;
1308            for pool in &mut self.pools {
1309                if pool.fluid_type != p.fluid_type {
1310                    continue;
1311                }
1312                let dx = p.position.x - pool.position.x;
1313                let dz = p.position.z - pool.position.z;
1314                if dx * dx + dz * dz < (pool.radius + 0.3) * (pool.radius + 0.3) {
1315                    pool.absorb_particle();
1316                    found_pool = true;
1317                    break;
1318                }
1319            }
1320            if !found_pool {
1321                new_pool_data.push((p.position, p.fluid_type));
1322            }
1323            settled_indices.push(i);
1324        }
1325
1326        // Remove settled particles in reverse order to preserve indices
1327        settled_indices.sort_unstable_by(|a, b| b.cmp(a));
1328        for idx in settled_indices {
1329            self.particles.swap_remove(idx);
1330        }
1331
1332        // Create new pools
1333        for (pos, ft) in new_pool_data {
1334            if self.pools.len() < MAX_POOLS {
1335                let mut pool = FluidPool::new(pos, 0.1, ft);
1336                pool.absorb_particle();
1337                self.pools.push(pool);
1338            }
1339        }
1340    }
1341
1342    /// Merge overlapping pools of the same fluid type.
1343    fn merge_pools(&mut self) {
1344        if self.pools.len() < 2 {
1345            return;
1346        }
1347        let mut merged = vec![false; self.pools.len()];
1348        let mut i = 0;
1349        while i < self.pools.len() {
1350            if merged[i] {
1351                i += 1;
1352                continue;
1353            }
1354            let mut j = i + 1;
1355            while j < self.pools.len() {
1356                if merged[j] {
1357                    j += 1;
1358                    continue;
1359                }
1360                if self.pools[i].fluid_type != self.pools[j].fluid_type {
1361                    j += 1;
1362                    continue;
1363                }
1364                let dist = self.pools[i].distance_to(&self.pools[j]);
1365                if dist < POOL_MERGE_DISTANCE {
1366                    // Clone pool j data then merge into i
1367                    let other = self.pools[j].clone();
1368                    self.pools[i].merge_from(&other);
1369                    merged[j] = true;
1370                }
1371                j += 1;
1372            }
1373            i += 1;
1374        }
1375
1376        // Remove merged pools (in reverse)
1377        let mut idx = self.pools.len();
1378        while idx > 0 {
1379            idx -= 1;
1380            if merged[idx] {
1381                self.pools.swap_remove(idx);
1382            }
1383        }
1384    }
1385
1386    /// Get all particle render data.
1387    pub fn particle_sprites(&self) -> Vec<FluidSpriteData> {
1388        self.renderer.extract_sprites(&self.particles)
1389    }
1390
1391    /// Get all pool render data.
1392    pub fn pool_sprites(&self) -> Vec<FluidSpriteData> {
1393        self.renderer.extract_pool_sprites(&self.pools)
1394    }
1395
1396    /// Query gameplay effects for an entity at `pos`.
1397    pub fn query_effects_at(&self, pos: Vec3) -> Vec<FluidStatusEffect> {
1398        FluidGameplayEffects::query_effects(&self.pools, pos)
1399    }
1400
1401    // ── Convenience spawner methods ─────────────────────────────────────────
1402
1403    /// Spawn blood drip from a wound.
1404    pub fn spawn_bleed(&mut self, entity_pos: Vec3, direction: Vec3, count: usize) {
1405        FluidSpawner::spawn_bleed(&mut self.particles, entity_pos, direction, count);
1406    }
1407
1408    /// Spawn a fire pool.
1409    pub fn spawn_fire_pool(&mut self, position: Vec3, radius: f32, count: usize) {
1410        FluidSpawner::spawn_fire_pool(&mut self.particles, position, radius, count);
1411    }
1412
1413    /// Spawn ice spread.
1414    pub fn spawn_ice_spread(&mut self, position: Vec3, radius: f32, count: usize) {
1415        FluidSpawner::spawn_ice_spread(&mut self.particles, position, radius, count);
1416    }
1417
1418    /// Spawn healing fountain.
1419    pub fn spawn_healing_fountain(&mut self, position: Vec3, count: usize) {
1420        FluidSpawner::spawn_healing_fountain(&mut self.particles, position, count);
1421    }
1422
1423    /// Spawn Ouroboros dark flow.
1424    pub fn spawn_ouroboros_flow(&mut self, from_pos: Vec3, to_pos: Vec3, count: usize) {
1425        FluidSpawner::spawn_ouroboros_flow(&mut self.particles, from_pos, to_pos, count);
1426    }
1427
1428    /// Spawn necro crawl toward corpses.
1429    pub fn spawn_necro_crawl(
1430        &mut self,
1431        origin: Vec3,
1432        corpse_positions: &[Vec3],
1433        particles_per_corpse: usize,
1434    ) {
1435        FluidSpawner::spawn_necro_crawl(
1436            &mut self.particles,
1437            origin,
1438            corpse_positions,
1439            particles_per_corpse,
1440        );
1441    }
1442
1443    /// Spawn poison bubbles.
1444    pub fn spawn_poison_bubbles(&mut self, position: Vec3, count: usize) {
1445        FluidSpawner::spawn_poison_bubbles(&mut self.particles, position, count);
1446    }
1447
1448    /// Spawn holy rise.
1449    pub fn spawn_holy_rise(&mut self, position: Vec3, count: usize) {
1450        FluidSpawner::spawn_holy_rise(&mut self.particles, position, count);
1451    }
1452
1453    /// Clear all particles and pools.
1454    pub fn clear(&mut self) {
1455        self.particles.clear();
1456        self.pools.clear();
1457    }
1458}
1459
1460impl Default for FluidManager {
1461    fn default() -> Self {
1462        Self::new()
1463    }
1464}
1465
1466// ── Tests ───────────────────────────────────────────────────────────────────
1467
1468#[cfg(test)]
1469mod tests {
1470    use super::*;
1471
1472    // ── Kernel tests ────────────────────────────────────────────────────────
1473
1474    #[test]
1475    fn test_kernel_at_zero_is_positive() {
1476        let sim = SPHSimulator::new();
1477        let w = sim.kernel(0.0);
1478        assert!(w > 0.0, "Kernel at r=0 should be positive, got {w}");
1479    }
1480
1481    #[test]
1482    fn test_kernel_at_h_is_zero() {
1483        let sim = SPHSimulator::new();
1484        let w = sim.kernel(sim.h);
1485        assert!(
1486            w.abs() < 1e-5,
1487            "Kernel at r=h should be ~0, got {w}"
1488        );
1489    }
1490
1491    #[test]
1492    fn test_kernel_beyond_h_is_zero() {
1493        let sim = SPHSimulator::new();
1494        let w = sim.kernel(sim.h * 1.5);
1495        assert_eq!(w, 0.0, "Kernel beyond h should be exactly 0");
1496    }
1497
1498    #[test]
1499    fn test_kernel_monotone_decreasing() {
1500        let sim = SPHSimulator::new();
1501        let mut prev = sim.kernel(0.0);
1502        for i in 1..20 {
1503            let r = sim.h * i as f32 / 20.0;
1504            let w = sim.kernel(r);
1505            assert!(
1506                w <= prev + 1e-6,
1507                "Kernel should be monotonically decreasing: W({r}) = {w} > W_prev = {prev}"
1508            );
1509            prev = w;
1510        }
1511    }
1512
1513    // ── Density tests ───────────────────────────────────────────────────────
1514
1515    #[test]
1516    fn test_density_single_particle() {
1517        let mut sim = SPHSimulator::new();
1518        let mut particles = vec![FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Blood)];
1519        sim.rebuild_grid(&particles);
1520        sim.find_neighbors(&mut particles);
1521        sim.compute_density(&mut particles);
1522        // Single particle density = mass * W(0, h) > 0
1523        assert!(
1524            particles[0].density > 0.0,
1525            "Single particle density should be > 0, got {}",
1526            particles[0].density
1527        );
1528    }
1529
1530    #[test]
1531    fn test_density_increases_with_nearby_particles() {
1532        let mut sim = SPHSimulator::new();
1533        let mut single = vec![FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Blood)];
1534        sim.rebuild_grid(&single);
1535        sim.find_neighbors(&mut single);
1536        sim.compute_density(&mut single);
1537        let single_density = single[0].density;
1538
1539        let mut pair = vec![
1540            FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Blood),
1541            FluidParticle::new(
1542                Vec3::new(sim.h * 0.3, 0.0, 0.0),
1543                Vec3::ZERO,
1544                FluidType::Blood,
1545            ),
1546        ];
1547        sim.rebuild_grid(&pair);
1548        sim.find_neighbors(&mut pair);
1549        sim.compute_density(&mut pair);
1550        assert!(
1551            pair[0].density > single_density,
1552            "Density with neighbour ({}) should exceed single ({})",
1553            pair[0].density,
1554            single_density
1555        );
1556    }
1557
1558    // ── Pressure tests ──────────────────────────────────────────────────────
1559
1560    #[test]
1561    fn test_pressure_at_rest_density() {
1562        let sim = SPHSimulator::new();
1563        let mut p = FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Blood);
1564        p.density = sim.rest_density;
1565        let mut particles = vec![p];
1566        sim.compute_pressure(&mut particles);
1567        assert!(
1568            particles[0].pressure.abs() < 1e-3,
1569            "Pressure at rest density should be ~0, got {}",
1570            particles[0].pressure
1571        );
1572    }
1573
1574    #[test]
1575    fn test_pressure_positive_above_rest() {
1576        let sim = SPHSimulator::new();
1577        let mut p = FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Blood);
1578        p.density = sim.rest_density * 1.5;
1579        let mut particles = vec![p];
1580        sim.compute_pressure(&mut particles);
1581        assert!(
1582            particles[0].pressure > 0.0,
1583            "Pressure above rest density should be positive, got {}",
1584            particles[0].pressure
1585        );
1586    }
1587
1588    // ── Pool tests ──────────────────────────────────────────────────────────
1589
1590    #[test]
1591    fn test_pool_contains_xz() {
1592        let pool = FluidPool::new(Vec3::new(1.0, 0.0, 2.0), 0.5, FluidType::Blood);
1593        assert!(pool.contains_xz(Vec3::new(1.0, 0.5, 2.0)));
1594        assert!(pool.contains_xz(Vec3::new(1.3, 0.0, 2.0)));
1595        assert!(!pool.contains_xz(Vec3::new(2.0, 0.0, 2.0)));
1596    }
1597
1598    #[test]
1599    fn test_pool_absorb_grows() {
1600        let mut pool = FluidPool::new(Vec3::ZERO, 0.1, FluidType::Ice);
1601        let r0 = pool.radius;
1602        let d0 = pool.depth;
1603        pool.absorb_particle();
1604        assert!(pool.radius > r0);
1605        assert!(pool.depth > d0);
1606        assert_eq!(pool.absorbed_count, 2); // 1 from new + 1 from absorb
1607    }
1608
1609    #[test]
1610    fn test_pool_merge() {
1611        let mut a = FluidPool::new(Vec3::new(0.0, 0.0, 0.0), 0.2, FluidType::Blood);
1612        a.absorbed_count = 5;
1613        let mut b = FluidPool::new(Vec3::new(0.3, 0.0, 0.0), 0.15, FluidType::Blood);
1614        b.absorbed_count = 3;
1615        let area_before = a.area() + b.area();
1616        a.merge_from(&b);
1617        let area_after = a.area();
1618        assert!(
1619            (area_after - area_before).abs() < 1e-4,
1620            "Merged area should be sum of individual areas"
1621        );
1622        assert_eq!(a.absorbed_count, 8);
1623    }
1624
1625    #[test]
1626    fn test_pool_lifetime() {
1627        let mut pool = FluidPool::new(Vec3::ZERO, 0.5, FluidType::Blood);
1628        assert!(pool.alive());
1629        pool.age = pool.max_lifetime + 1.0;
1630        assert!(!pool.alive());
1631    }
1632
1633    // ── FluidType tests ─────────────────────────────────────────────────────
1634
1635    #[test]
1636    fn test_fire_cannot_pool() {
1637        assert!(!FluidType::Fire.can_pool());
1638    }
1639
1640    #[test]
1641    fn test_blood_can_pool() {
1642        assert!(FluidType::Blood.can_pool());
1643    }
1644
1645    #[test]
1646    fn test_holy_cannot_pool() {
1647        assert!(!FluidType::Holy.can_pool());
1648    }
1649
1650    // ── Spawner tests ───────────────────────────────────────────────────────
1651
1652    #[test]
1653    fn test_spawn_bleed_creates_particles() {
1654        let mut particles = Vec::new();
1655        FluidSpawner::spawn_bleed(
1656            &mut particles,
1657            Vec3::new(0.0, 2.0, 0.0),
1658            Vec3::new(1.0, 0.0, 0.0),
1659            10,
1660        );
1661        assert_eq!(particles.len(), 10);
1662        for p in &particles {
1663            assert_eq!(p.fluid_type, FluidType::Blood);
1664        }
1665    }
1666
1667    #[test]
1668    fn test_spawn_respects_max_particles() {
1669        let mut particles = Vec::new();
1670        // Fill up to near max
1671        for _ in 0..(MAX_PARTICLES - 5) {
1672            particles.push(FluidParticle::new(
1673                Vec3::ZERO,
1674                Vec3::ZERO,
1675                FluidType::Blood,
1676            ));
1677        }
1678        FluidSpawner::spawn_bleed(
1679            &mut particles,
1680            Vec3::ZERO,
1681            Vec3::Y,
1682            100,
1683        );
1684        assert!(
1685            particles.len() <= MAX_PARTICLES,
1686            "Should not exceed MAX_PARTICLES"
1687        );
1688    }
1689
1690    #[test]
1691    fn test_spawn_healing_fountain() {
1692        let mut particles = Vec::new();
1693        FluidSpawner::spawn_healing_fountain(&mut particles, Vec3::new(0.0, 0.5, 0.0), 20);
1694        assert_eq!(particles.len(), 20);
1695        for p in &particles {
1696            assert_eq!(p.fluid_type, FluidType::Healing);
1697            // Healing particles should have upward velocity
1698            assert!(p.velocity.y > 0.0, "Healing fountain should go up");
1699        }
1700    }
1701
1702    #[test]
1703    fn test_spawn_ouroboros_flow() {
1704        let mut particles = Vec::new();
1705        let from = Vec3::new(-5.0, 1.0, 0.0);
1706        let to = Vec3::new(5.0, 1.0, 0.0);
1707        FluidSpawner::spawn_ouroboros_flow(&mut particles, from, to, 15);
1708        assert_eq!(particles.len(), 15);
1709        for p in &particles {
1710            assert_eq!(p.fluid_type, FluidType::Dark);
1711            // Should have positive X velocity (toward target)
1712            assert!(p.velocity.x > 0.0, "Ouroboros should flow toward target");
1713        }
1714    }
1715
1716    #[test]
1717    fn test_spawn_necro_crawl() {
1718        let mut particles = Vec::new();
1719        let origin = Vec3::ZERO;
1720        let corpses = vec![
1721            Vec3::new(3.0, 0.0, 0.0),
1722            Vec3::new(-2.0, 0.0, 1.0),
1723        ];
1724        FluidSpawner::spawn_necro_crawl(&mut particles, origin, &corpses, 5);
1725        assert_eq!(particles.len(), 10); // 5 per corpse
1726        for p in &particles {
1727            assert_eq!(p.fluid_type, FluidType::Necro);
1728        }
1729    }
1730
1731    // ── Gameplay effects tests ──────────────────────────────────────────────
1732
1733    #[test]
1734    fn test_blood_pool_bleed_amplify() {
1735        let pool = FluidPool::new(Vec3::ZERO, 1.0, FluidType::Blood);
1736        let effects = FluidGameplayEffects::query_effects(
1737            &[pool],
1738            Vec3::new(0.5, 0.0, 0.0),
1739        );
1740        let mult = FluidGameplayEffects::bleed_multiplier(&effects);
1741        assert!(mult > 1.0, "Blood pool should amplify bleed, got {mult}");
1742    }
1743
1744    #[test]
1745    fn test_ice_pool_slow() {
1746        let pool = FluidPool::new(Vec3::ZERO, 1.0, FluidType::Ice);
1747        let effects = FluidGameplayEffects::query_effects(
1748            &[pool],
1749            Vec3::new(0.3, 0.0, 0.3),
1750        );
1751        let slow = FluidGameplayEffects::strongest_slow(&effects);
1752        assert!(slow > 0.0, "Ice pool should slow, got {slow}");
1753    }
1754
1755    #[test]
1756    fn test_fire_pool_dot() {
1757        let pool = FluidPool::new(Vec3::ZERO, 1.0, FluidType::Fire);
1758        let effects = FluidGameplayEffects::query_effects(
1759            &[pool],
1760            Vec3::new(0.0, 0.0, 0.0),
1761        );
1762        let dot = FluidGameplayEffects::total_dot(&effects);
1763        assert!(dot > 0.0, "Fire pool should deal DoT, got {dot}");
1764    }
1765
1766    #[test]
1767    fn test_dark_pool_mana_drain() {
1768        let pool = FluidPool::new(Vec3::ZERO, 1.0, FluidType::Dark);
1769        let effects = FluidGameplayEffects::query_effects(
1770            &[pool],
1771            Vec3::new(0.0, 0.0, 0.0),
1772        );
1773        let drain = FluidGameplayEffects::total_mana_drain(&effects);
1774        assert!(drain > 0.0, "Dark pool should drain mana, got {drain}");
1775    }
1776
1777    #[test]
1778    fn test_no_effect_outside_pool() {
1779        let pool = FluidPool::new(Vec3::ZERO, 0.5, FluidType::Fire);
1780        let effects = FluidGameplayEffects::query_effects(
1781            &[pool],
1782            Vec3::new(5.0, 0.0, 5.0),
1783        );
1784        assert!(effects.is_empty(), "Should have no effects outside pool");
1785    }
1786
1787    // ── Manager integration tests ───────────────────────────────────────────
1788
1789    #[test]
1790    fn test_manager_spawn_and_update() {
1791        let mut mgr = FluidManager::new();
1792        mgr.spawn_bleed(Vec3::new(0.0, 2.0, 0.0), Vec3::Y, 20);
1793        assert_eq!(mgr.particle_count(), 20);
1794        mgr.update(0.016);
1795        // Particles should still be alive after one frame
1796        assert!(mgr.particle_count() > 0);
1797    }
1798
1799    #[test]
1800    fn test_manager_particles_die_over_time() {
1801        let mut mgr = FluidManager::new();
1802        mgr.spawn_bleed(Vec3::new(0.0, 2.0, 0.0), Vec3::Y, 10);
1803        // Advance past blood lifetime (4 seconds)
1804        for _ in 0..300 {
1805            mgr.update(0.016);
1806        }
1807        assert_eq!(
1808            mgr.particle_count(),
1809            0,
1810            "All blood particles should have died"
1811        );
1812    }
1813
1814    #[test]
1815    fn test_manager_clear() {
1816        let mut mgr = FluidManager::new();
1817        mgr.spawn_bleed(Vec3::ZERO, Vec3::Y, 50);
1818        mgr.pools
1819            .push(FluidPool::new(Vec3::ZERO, 1.0, FluidType::Blood));
1820        mgr.clear();
1821        assert_eq!(mgr.particle_count(), 0);
1822        assert_eq!(mgr.pool_count(), 0);
1823    }
1824
1825    // ── Renderer tests ──────────────────────────────────────────────────────
1826
1827    #[test]
1828    fn test_renderer_extracts_alive_only() {
1829        let renderer = FluidRenderer::new();
1830        let mut alive = FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Fire);
1831        alive.lifetime = 1.0;
1832        let mut dead = FluidParticle::new(Vec3::ZERO, Vec3::ZERO, FluidType::Fire);
1833        dead.lifetime = -1.0;
1834        let sprites = renderer.extract_sprites(&[alive, dead]);
1835        assert_eq!(sprites.len(), 1, "Should only render alive particles");
1836    }
1837
1838    #[test]
1839    fn test_pseudo_random_in_range() {
1840        for i in 0..100 {
1841            let v = pseudo_random(i as f32 * 0.7);
1842            assert!(v >= 0.0 && v < 1.0, "pseudo_random out of range: {v}");
1843        }
1844    }
1845
1846    // ── SPH step integration test ───────────────────────────────────────────
1847
1848    #[test]
1849    fn test_sph_step_does_not_explode() {
1850        let mut sim = SPHSimulator::new();
1851        let mut particles: Vec<FluidParticle> = (0..50)
1852            .map(|i| {
1853                let x = (i % 10) as f32 * 0.05;
1854                let y = (i / 10) as f32 * 0.05 + 1.0;
1855                FluidParticle::new(Vec3::new(x, y, 0.0), Vec3::ZERO, FluidType::Blood)
1856            })
1857            .collect();
1858
1859        for _ in 0..10 {
1860            sim.step(&mut particles, 1.0 / 60.0);
1861        }
1862
1863        for p in &particles {
1864            let speed = p.velocity.length();
1865            assert!(
1866                speed < 100.0,
1867                "Particle velocity exploded: speed = {speed}"
1868            );
1869            assert!(
1870                p.position.length() < 100.0,
1871                "Particle position exploded: {:?}",
1872                p.position
1873            );
1874        }
1875    }
1876}