Skip to main content

steel_core/entity/
fluid_contact.rs

1//! Entity contact with world fluids.
2
3use std::sync::Arc;
4
5use glam::DVec3;
6use steel_registry::fluid::{FluidState, FluidStateExt as _};
7use steel_utils::{BlockPos, ChunkPos, SectionPos, WorldAabb, axis::Axis};
8
9use crate::fluid::{get_flow, get_fluid_state, get_height};
10use crate::world::World;
11
12const FLUID_INTERACTION_MARGIN: f64 = 0.001;
13const MIN_CURRENT_LENGTH_SQUARED: f64 = 1.0e-5;
14const STILL_CURRENT_VELOCITY_THRESHOLD: f64 = 0.003;
15const MIN_STILL_CURRENT_IMPULSE: f64 = 0.004_500_000_000_000_000_5;
16const SHALLOW_CURRENT_HEIGHT: f64 = 0.4;
17
18#[derive(Debug, Clone, Copy, Default, PartialEq)]
19struct EntityFluidCurrent {
20    accumulated: DVec3,
21    count: u32,
22}
23
24impl EntityFluidCurrent {
25    fn accumulate(&mut self, flow: DVec3) {
26        self.accumulated += flow;
27        self.count += 1;
28    }
29
30    fn impulse(self, is_player: bool, old_velocity: DVec3, scale: f64) -> DVec3 {
31        if self.count == 0 || self.accumulated.length_squared() < MIN_CURRENT_LENGTH_SQUARED {
32            return DVec3::ZERO;
33        }
34
35        let mut impulse = if is_player {
36            self.accumulated / f64::from(self.count)
37        } else {
38            self.accumulated.normalize_or_zero()
39        };
40        impulse *= scale;
41
42        if old_velocity.x.abs() < STILL_CURRENT_VELOCITY_THRESHOLD
43            && old_velocity.z.abs() < STILL_CURRENT_VELOCITY_THRESHOLD
44            && impulse.length() < MIN_STILL_CURRENT_IMPULSE
45        {
46            impulse = impulse.normalize_or_zero() * MIN_STILL_CURRENT_IMPULSE;
47        }
48
49        impulse
50    }
51}
52
53#[derive(Debug, Clone, Copy)]
54struct FluidScanBounds {
55    interaction_box: WorldAabb,
56    entity_y: f64,
57    x0: i32,
58    y0: i32,
59    z0: i32,
60    x1: i32,
61    y1: i32,
62    z1: i32,
63}
64
65/// Fluid heights intersecting an entity's current bounding box.
66///
67/// Mirrors the body-height and eye-fluid tracking part of vanilla's
68/// `EntityFluidInteraction`. Current pushing should build on this scan rather
69/// than storing separate water/lava flags on individual entity types.
70#[derive(Debug, Clone, Copy, Default, PartialEq)]
71pub struct EntityFluidContact {
72    water_height: f64,
73    lava_height: f64,
74    eye_in_water: bool,
75    eye_in_lava: bool,
76    water_current: EntityFluidCurrent,
77    lava_current: EntityFluidCurrent,
78}
79
80impl EntityFluidContact {
81    #[cfg(test)]
82    #[must_use]
83    pub(crate) fn from_parts(
84        water_height: f64,
85        lava_height: f64,
86        eye_in_water: bool,
87        eye_in_lava: bool,
88    ) -> Self {
89        Self {
90            water_height,
91            lava_height,
92            eye_in_water,
93            eye_in_lava,
94            water_current: EntityFluidCurrent::default(),
95            lava_current: EntityFluidCurrent::default(),
96        }
97    }
98
99    /// Scans the world for water/lava touching `bounding_box`.
100    #[must_use]
101    pub fn scan(world: &Arc<World>, position: DVec3, eye_y: f64, bounding_box: WorldAabb) -> Self {
102        let Some(bounds) = Self::scan_bounds(bounding_box) else {
103            return Self::default();
104        };
105        if !has_fluid_and_loaded(world, bounds) {
106            return Self::default();
107        }
108
109        Self::scan_with_bounds(
110            bounds,
111            position,
112            eye_y,
113            false,
114            |pos| get_fluid_state(world, pos),
115            |pos, fluid_state| get_height(world, pos, fluid_state),
116            |_pos, _fluid_state| DVec3::ZERO,
117        )
118    }
119
120    /// Scans fluid contact and optionally accumulates vanilla fluid currents.
121    #[must_use]
122    pub fn scan_with_currents(
123        world: &Arc<World>,
124        position: DVec3,
125        eye_y: f64,
126        bounding_box: WorldAabb,
127        include_current: bool,
128    ) -> Self {
129        let Some(bounds) = Self::scan_bounds(bounding_box) else {
130            return Self::default();
131        };
132        if !has_fluid_and_loaded(world, bounds) {
133            return Self::default();
134        }
135
136        Self::scan_with_bounds(
137            bounds,
138            position,
139            eye_y,
140            include_current,
141            |pos| get_fluid_state(world, pos),
142            |pos, fluid_state| get_height(world, pos, fluid_state),
143            |pos, fluid_state| get_flow(world, pos, fluid_state),
144        )
145    }
146
147    /// Returns the highest water surface above the entity's feet.
148    #[must_use]
149    pub const fn water_height(self) -> f64 {
150        self.water_height
151    }
152
153    /// Returns the highest lava surface above the entity's feet.
154    #[must_use]
155    pub const fn lava_height(self) -> f64 {
156        self.lava_height
157    }
158
159    /// Returns whether the entity's eyes are currently inside water.
160    #[must_use]
161    pub const fn eye_in_water(self) -> bool {
162        self.eye_in_water
163    }
164
165    /// Returns whether the entity's eyes are currently inside lava.
166    #[must_use]
167    pub const fn eye_in_lava(self) -> bool {
168        self.eye_in_lava
169    }
170
171    /// Returns vanilla water-current impulse for this scan.
172    #[must_use]
173    pub fn water_current_impulse(self, is_player: bool, old_velocity: DVec3, scale: f64) -> DVec3 {
174        self.water_current.impulse(is_player, old_velocity, scale)
175    }
176
177    /// Returns vanilla lava-current impulse for this scan.
178    #[must_use]
179    pub fn lava_current_impulse(self, is_player: bool, old_velocity: DVec3, scale: f64) -> DVec3 {
180        self.lava_current.impulse(is_player, old_velocity, scale)
181    }
182
183    #[cfg(test)]
184    fn scan_with(
185        bounding_box: WorldAabb,
186        position: DVec3,
187        eye_y: f64,
188        fluid_at: impl FnMut(BlockPos) -> FluidState,
189        height_at: impl FnMut(BlockPos, FluidState) -> f32,
190    ) -> Self {
191        Self::scan_with_flow(
192            bounding_box,
193            position,
194            eye_y,
195            false,
196            fluid_at,
197            height_at,
198            |_pos, _fluid_state| DVec3::ZERO,
199        )
200    }
201
202    #[cfg(test)]
203    fn scan_with_flow(
204        bounding_box: WorldAabb,
205        position: DVec3,
206        eye_y: f64,
207        include_current: bool,
208        fluid_at: impl FnMut(BlockPos) -> FluidState,
209        height_at: impl FnMut(BlockPos, FluidState) -> f32,
210        flow_at: impl FnMut(BlockPos, FluidState) -> DVec3,
211    ) -> Self {
212        let Some(bounds) = Self::scan_bounds(bounding_box) else {
213            return Self::default();
214        };
215
216        Self::scan_with_bounds(
217            bounds,
218            position,
219            eye_y,
220            include_current,
221            fluid_at,
222            height_at,
223            flow_at,
224        )
225    }
226
227    fn scan_bounds(bounding_box: WorldAabb) -> Option<FluidScanBounds> {
228        let interaction_box = bounding_box.deflate(FLUID_INTERACTION_MARGIN);
229        if interaction_box.is_empty() {
230            return None;
231        }
232
233        let x0 = interaction_box.min(Axis::X).floor() as i32;
234        let y0 = interaction_box.min(Axis::Y).floor() as i32;
235        let z0 = interaction_box.min(Axis::Z).floor() as i32;
236        let x1 = interaction_box.max(Axis::X).ceil() as i32 - 1;
237        let y1 = interaction_box.max(Axis::Y).ceil() as i32 - 1;
238        let z1 = interaction_box.max(Axis::Z).ceil() as i32 - 1;
239        if x0 > x1 || y0 > y1 || z0 > z1 {
240            return None;
241        }
242
243        Some(FluidScanBounds {
244            interaction_box,
245            entity_y: bounding_box.min(Axis::Y),
246            x0,
247            y0,
248            z0,
249            x1,
250            y1,
251            z1,
252        })
253    }
254
255    fn scan_with_bounds(
256        bounds: FluidScanBounds,
257        position: DVec3,
258        eye_y: f64,
259        include_current: bool,
260        mut fluid_at: impl FnMut(BlockPos) -> FluidState,
261        mut height_at: impl FnMut(BlockPos, FluidState) -> f32,
262        mut flow_at: impl FnMut(BlockPos, FluidState) -> DVec3,
263    ) -> Self {
264        let mut contact = Self::default();
265        let eye_block_x = position.x.floor() as i32;
266        let eye_block_z = position.z.floor() as i32;
267
268        for x in bounds.x0..=bounds.x1 {
269            for y in bounds.y0..=bounds.y1 {
270                for z in bounds.z0..=bounds.z1 {
271                    let pos = BlockPos::new(x, y, z);
272                    let fluid_state = fluid_at(pos);
273                    if fluid_state.is_empty() {
274                        continue;
275                    }
276
277                    let fluid_bottom = f64::from(y);
278                    let fluid_top = fluid_bottom + f64::from(height_at(pos, fluid_state));
279                    if fluid_top < bounds.interaction_box.min(Axis::Y) {
280                        continue;
281                    }
282
283                    let eye_inside = x == eye_block_x
284                        && z == eye_block_z
285                        && eye_y >= fluid_bottom
286                        && eye_y <= fluid_top;
287                    let height = fluid_top - bounds.entity_y;
288                    if fluid_state.is_water() {
289                        contact.water_height = contact.water_height.max(height);
290                        contact.eye_in_water |= eye_inside;
291                        if include_current {
292                            let mut flow = flow_at(pos, fluid_state);
293                            if contact.water_height < SHALLOW_CURRENT_HEIGHT {
294                                flow *= contact.water_height;
295                            }
296                            contact.water_current.accumulate(flow);
297                        }
298                    } else if fluid_state.is_lava() {
299                        contact.lava_height = contact.lava_height.max(height);
300                        contact.eye_in_lava |= eye_inside;
301                        if include_current {
302                            let mut flow = flow_at(pos, fluid_state);
303                            if contact.lava_height < SHALLOW_CURRENT_HEIGHT {
304                                flow *= contact.lava_height;
305                            }
306                            contact.lava_current.accumulate(flow);
307                        }
308                    }
309                }
310            }
311        }
312
313        contact
314    }
315}
316
317#[expect(
318    clippy::similar_names,
319    reason = "axis-paired bounds mirror vanilla hasFluidAndLoaded"
320)]
321fn has_fluid_and_loaded(world: &World, bounds: FluidScanBounds) -> bool {
322    let section_x0 = SectionPos::block_to_section_coord(bounds.x0 - 1);
323    let section_y0 = SectionPos::block_to_section_coord(bounds.y0);
324    let section_z0 = SectionPos::block_to_section_coord(bounds.z0 - 1);
325    let section_x1 = SectionPos::block_to_section_coord(bounds.x1 + 1);
326    let section_y1 = SectionPos::block_to_section_coord(bounds.y1);
327    let section_z1 = SectionPos::block_to_section_coord(bounds.z1 + 1);
328
329    let mut has_fluid = false;
330    for chunk_z in section_z0..=section_z1 {
331        for chunk_x in section_x0..=section_x1 {
332            let Some(chunk_has_fluid) =
333                world
334                    .chunk_map
335                    .with_full_chunk(ChunkPos::new(chunk_x, chunk_z), |chunk| {
336                        let min_section_y = SectionPos::block_to_section_coord(chunk.min_y());
337                        let sections = &chunk.sections().sections;
338                        let mut chunk_has_fluid = false;
339
340                        for section_y in section_y0..=section_y1 {
341                            let section_index = section_y - min_section_y;
342                            let Ok(section_index) = usize::try_from(section_index) else {
343                                continue;
344                            };
345                            let Some(section) = sections.get(section_index) else {
346                                continue;
347                            };
348
349                            chunk_has_fluid |= section.read().has_fluid();
350                        }
351
352                        chunk_has_fluid
353                    })
354            else {
355                return false;
356            };
357
358            has_fluid |= chunk_has_fluid;
359        }
360    }
361
362    has_fluid
363}
364
365#[cfg(test)]
366mod tests {
367    use steel_registry::fluid::FluidState;
368    use steel_registry::init_vanilla_registry;
369    use steel_registry::vanilla_fluids;
370
371    use super::*;
372
373    #[test]
374    fn scan_reports_fluid_height_above_entity_feet() {
375        init_vanilla_registry();
376        let bounding_box = WorldAabb::new(0.1, 10.0, 0.1, 0.9, 10.5, 0.9);
377
378        let contact = EntityFluidContact::scan_with(
379            bounding_box,
380            DVec3::new(0.5, 10.0, 0.5),
381            12.0,
382            |pos| {
383                if pos.y() == 10 {
384                    FluidState::source(&vanilla_fluids::WATER)
385                } else {
386                    FluidState::EMPTY
387                }
388            },
389            |_pos, _fluid_state| 1.0,
390        );
391
392        assert!((contact.water_height() - 1.0).abs() < f64::EPSILON);
393        assert!(contact.lava_height().abs() < f64::EPSILON);
394        assert!(!contact.eye_in_water());
395        assert!(!contact.eye_in_lava());
396    }
397
398    #[test]
399    fn scan_uses_effective_fluid_height() {
400        init_vanilla_registry();
401        let bounding_box = WorldAabb::new(0.1, 10.0, 0.1, 0.9, 10.5, 0.9);
402
403        let contact = EntityFluidContact::scan_with(
404            bounding_box,
405            DVec3::new(0.5, 10.0, 0.5),
406            12.0,
407            |pos| {
408                if pos.y() == 10 {
409                    FluidState::flowing(&vanilla_fluids::FLOWING_LAVA, 4, false)
410                } else {
411                    FluidState::EMPTY
412                }
413            },
414            |_pos, _fluid_state| 4.0 / 9.0,
415        );
416
417        assert!(contact.water_height().abs() < f64::EPSILON);
418        assert!((contact.lava_height() - 4.0 / 9.0).abs() < 1.0e-7);
419        assert!(!contact.eye_in_water());
420        assert!(!contact.eye_in_lava());
421    }
422
423    #[test]
424    fn scan_ignores_fluid_below_interaction_box() {
425        init_vanilla_registry();
426        let bounding_box = WorldAabb::new(0.1, 10.2, 0.1, 0.9, 10.6, 0.9);
427
428        let contact = EntityFluidContact::scan_with(
429            bounding_box,
430            DVec3::new(0.5, 10.0, 0.5),
431            10.3,
432            |pos| {
433                if pos.y() == 10 {
434                    FluidState::flowing(&vanilla_fluids::FLOWING_WATER, 1, false)
435                } else {
436                    FluidState::EMPTY
437                }
438            },
439            |_pos, _fluid_state| 1.0 / 9.0,
440        );
441
442        assert_eq!(contact, EntityFluidContact::default());
443    }
444
445    #[test]
446    fn scan_marks_eye_inside_matching_fluid_column() {
447        init_vanilla_registry();
448        let bounding_box = WorldAabb::new(0.1, 10.0, 0.1, 0.9, 11.0, 0.9);
449
450        let contact = EntityFluidContact::scan_with(
451            bounding_box,
452            DVec3::new(0.5, 10.0, 0.5),
453            10.8,
454            |pos| {
455                if pos.y() == 10 {
456                    FluidState::source(&vanilla_fluids::WATER)
457                } else {
458                    FluidState::EMPTY
459                }
460            },
461            |_pos, _fluid_state| 1.0,
462        );
463
464        assert!(contact.eye_in_water());
465        assert!(!contact.eye_in_lava());
466    }
467
468    #[test]
469    fn scan_accumulates_player_fluid_current_as_average_flow() {
470        init_vanilla_registry();
471        let bounding_box = WorldAabb::new(0.1, 10.0, 0.1, 1.9, 10.5, 0.9);
472
473        let contact = EntityFluidContact::scan_with_flow(
474            bounding_box,
475            DVec3::new(0.5, 10.0, 0.5),
476            12.0,
477            true,
478            |_pos| FluidState::source(&vanilla_fluids::WATER),
479            |_pos, _fluid_state| 1.0,
480            |pos, _fluid_state| {
481                if pos.x() == 0 { DVec3::X } else { DVec3::Z }
482            },
483        );
484
485        assert_eq!(
486            contact.water_current_impulse(true, DVec3::ZERO, 1.0),
487            DVec3::new(0.5, 0.0, 0.5)
488        );
489    }
490
491    #[test]
492    fn scan_accumulates_non_player_fluid_current_as_normalized_flow() {
493        init_vanilla_registry();
494        let bounding_box = WorldAabb::new(0.1, 10.0, 0.1, 1.9, 10.5, 0.9);
495
496        let contact = EntityFluidContact::scan_with_flow(
497            bounding_box,
498            DVec3::new(0.5, 10.0, 0.5),
499            12.0,
500            true,
501            |_pos| FluidState::source(&vanilla_fluids::WATER),
502            |_pos, _fluid_state| 1.0,
503            |pos, _fluid_state| {
504                if pos.x() == 0 { DVec3::X } else { DVec3::Z }
505            },
506        );
507
508        let expected = DVec3::new(1.0, 0.0, 1.0).normalize();
509        let impulse = contact.water_current_impulse(false, DVec3::ZERO, 1.0);
510        assert!((impulse - expected).length() < f64::EPSILON);
511    }
512
513    #[test]
514    fn shallow_current_is_scaled_by_fluid_height() {
515        init_vanilla_registry();
516        let bounding_box = WorldAabb::new(0.1, 10.0, 0.1, 0.9, 10.5, 0.9);
517
518        let contact = EntityFluidContact::scan_with_flow(
519            bounding_box,
520            DVec3::new(0.5, 10.0, 0.5),
521            12.0,
522            true,
523            |_pos| FluidState::source(&vanilla_fluids::WATER),
524            |_pos, _fluid_state| 0.2,
525            |_pos, _fluid_state| DVec3::X,
526        );
527
528        let impulse = contact.water_current_impulse(true, DVec3::new(0.01, 0.0, 0.0), 1.0);
529        assert!((impulse - DVec3::new(0.2, 0.0, 0.0)).length() < 1.0e-7);
530    }
531}