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 any fluid touches the entity.
160    #[must_use]
161    pub const fn any_fluid(self) -> bool {
162        self.water_height > 0.0 || self.lava_height > 0.0
163    }
164
165    /// Returns whether the entity's eyes are currently inside water.
166    #[must_use]
167    pub const fn eye_in_water(self) -> bool {
168        self.eye_in_water
169    }
170
171    /// Returns whether the entity's eyes are currently inside lava.
172    #[must_use]
173    pub const fn eye_in_lava(self) -> bool {
174        self.eye_in_lava
175    }
176
177    /// Returns vanilla water-current impulse for this scan.
178    #[must_use]
179    pub fn water_current_impulse(self, is_player: bool, old_velocity: DVec3, scale: f64) -> DVec3 {
180        self.water_current.impulse(is_player, old_velocity, scale)
181    }
182
183    /// Returns vanilla lava-current impulse for this scan.
184    #[must_use]
185    pub fn lava_current_impulse(self, is_player: bool, old_velocity: DVec3, scale: f64) -> DVec3 {
186        self.lava_current.impulse(is_player, old_velocity, scale)
187    }
188
189    #[cfg(test)]
190    fn scan_with(
191        bounding_box: WorldAabb,
192        position: DVec3,
193        eye_y: f64,
194        fluid_at: impl FnMut(BlockPos) -> FluidState,
195        height_at: impl FnMut(BlockPos, FluidState) -> f32,
196    ) -> Self {
197        Self::scan_with_flow(
198            bounding_box,
199            position,
200            eye_y,
201            false,
202            fluid_at,
203            height_at,
204            |_pos, _fluid_state| DVec3::ZERO,
205        )
206    }
207
208    #[cfg(test)]
209    fn scan_with_flow(
210        bounding_box: WorldAabb,
211        position: DVec3,
212        eye_y: f64,
213        include_current: bool,
214        fluid_at: impl FnMut(BlockPos) -> FluidState,
215        height_at: impl FnMut(BlockPos, FluidState) -> f32,
216        flow_at: impl FnMut(BlockPos, FluidState) -> DVec3,
217    ) -> Self {
218        let Some(bounds) = Self::scan_bounds(bounding_box) else {
219            return Self::default();
220        };
221
222        Self::scan_with_bounds(
223            bounds,
224            position,
225            eye_y,
226            include_current,
227            fluid_at,
228            height_at,
229            flow_at,
230        )
231    }
232
233    fn scan_bounds(bounding_box: WorldAabb) -> Option<FluidScanBounds> {
234        let interaction_box = bounding_box.deflate(FLUID_INTERACTION_MARGIN);
235        if interaction_box.is_empty() {
236            return None;
237        }
238
239        let x0 = interaction_box.min(Axis::X).floor() as i32;
240        let y0 = interaction_box.min(Axis::Y).floor() as i32;
241        let z0 = interaction_box.min(Axis::Z).floor() as i32;
242        let x1 = interaction_box.max(Axis::X).ceil() as i32 - 1;
243        let y1 = interaction_box.max(Axis::Y).ceil() as i32 - 1;
244        let z1 = interaction_box.max(Axis::Z).ceil() as i32 - 1;
245        if x0 > x1 || y0 > y1 || z0 > z1 {
246            return None;
247        }
248
249        Some(FluidScanBounds {
250            interaction_box,
251            entity_y: bounding_box.min(Axis::Y),
252            x0,
253            y0,
254            z0,
255            x1,
256            y1,
257            z1,
258        })
259    }
260
261    fn scan_with_bounds(
262        bounds: FluidScanBounds,
263        position: DVec3,
264        eye_y: f64,
265        include_current: bool,
266        mut fluid_at: impl FnMut(BlockPos) -> FluidState,
267        mut height_at: impl FnMut(BlockPos, FluidState) -> f32,
268        mut flow_at: impl FnMut(BlockPos, FluidState) -> DVec3,
269    ) -> Self {
270        let mut contact = Self::default();
271        let eye_block_x = position.x.floor() as i32;
272        let eye_block_z = position.z.floor() as i32;
273
274        for x in bounds.x0..=bounds.x1 {
275            for y in bounds.y0..=bounds.y1 {
276                for z in bounds.z0..=bounds.z1 {
277                    let pos = BlockPos::new(x, y, z);
278                    let fluid_state = fluid_at(pos);
279                    if fluid_state.is_empty() {
280                        continue;
281                    }
282
283                    let fluid_bottom = f64::from(y);
284                    let fluid_top = fluid_bottom + f64::from(height_at(pos, fluid_state));
285                    if fluid_top < bounds.interaction_box.min(Axis::Y) {
286                        continue;
287                    }
288
289                    let eye_inside = x == eye_block_x
290                        && z == eye_block_z
291                        && eye_y >= fluid_bottom
292                        && eye_y <= fluid_top;
293                    let height = fluid_top - bounds.entity_y;
294                    if fluid_state.is_water() {
295                        contact.water_height = contact.water_height.max(height);
296                        contact.eye_in_water |= eye_inside;
297                        if include_current {
298                            let mut flow = flow_at(pos, fluid_state);
299                            if contact.water_height < SHALLOW_CURRENT_HEIGHT {
300                                flow *= contact.water_height;
301                            }
302                            contact.water_current.accumulate(flow);
303                        }
304                    } else if fluid_state.is_lava() {
305                        contact.lava_height = contact.lava_height.max(height);
306                        contact.eye_in_lava |= eye_inside;
307                        if include_current {
308                            let mut flow = flow_at(pos, fluid_state);
309                            if contact.lava_height < SHALLOW_CURRENT_HEIGHT {
310                                flow *= contact.lava_height;
311                            }
312                            contact.lava_current.accumulate(flow);
313                        }
314                    }
315                }
316            }
317        }
318
319        contact
320    }
321}
322
323#[expect(
324    clippy::similar_names,
325    reason = "axis-paired bounds mirror vanilla hasFluidAndLoaded"
326)]
327fn has_fluid_and_loaded(world: &World, bounds: FluidScanBounds) -> bool {
328    let section_x0 = SectionPos::block_to_section_coord(bounds.x0 - 1);
329    let section_y0 = SectionPos::block_to_section_coord(bounds.y0);
330    let section_z0 = SectionPos::block_to_section_coord(bounds.z0 - 1);
331    let section_x1 = SectionPos::block_to_section_coord(bounds.x1 + 1);
332    let section_y1 = SectionPos::block_to_section_coord(bounds.y1);
333    let section_z1 = SectionPos::block_to_section_coord(bounds.z1 + 1);
334
335    let mut has_fluid = false;
336    for chunk_z in section_z0..=section_z1 {
337        for chunk_x in section_x0..=section_x1 {
338            let Some(chunk_has_fluid) =
339                world
340                    .chunk_map
341                    .with_full_chunk(ChunkPos::new(chunk_x, chunk_z), |chunk| {
342                        let min_section_y = SectionPos::block_to_section_coord(chunk.min_y());
343                        let sections = &chunk.sections().sections;
344                        let mut chunk_has_fluid = false;
345
346                        for section_y in section_y0..=section_y1 {
347                            let section_index = section_y - min_section_y;
348                            let Ok(section_index) = usize::try_from(section_index) else {
349                                continue;
350                            };
351                            let Some(section) = sections.get(section_index) else {
352                                continue;
353                            };
354
355                            chunk_has_fluid |= section.read().has_fluid();
356                        }
357
358                        chunk_has_fluid
359                    })
360            else {
361                return false;
362            };
363
364            has_fluid |= chunk_has_fluid;
365        }
366    }
367
368    has_fluid
369}
370
371#[cfg(test)]
372mod tests {
373    use steel_registry::fluid::FluidState;
374    use steel_registry::init_vanilla_registry;
375    use steel_registry::vanilla_fluids;
376
377    use super::*;
378
379    #[test]
380    fn scan_reports_fluid_height_above_entity_feet() {
381        init_vanilla_registry();
382        let bounding_box = WorldAabb::new(0.1, 10.0, 0.1, 0.9, 10.5, 0.9);
383
384        let contact = EntityFluidContact::scan_with(
385            bounding_box,
386            DVec3::new(0.5, 10.0, 0.5),
387            12.0,
388            |pos| {
389                if pos.y() == 10 {
390                    FluidState::source(&vanilla_fluids::WATER)
391                } else {
392                    FluidState::EMPTY
393                }
394            },
395            |_pos, _fluid_state| 1.0,
396        );
397
398        assert!((contact.water_height() - 1.0).abs() < f64::EPSILON);
399        assert!(contact.lava_height().abs() < f64::EPSILON);
400        assert!(!contact.eye_in_water());
401        assert!(!contact.eye_in_lava());
402    }
403
404    #[test]
405    fn scan_uses_effective_fluid_height() {
406        init_vanilla_registry();
407        let bounding_box = WorldAabb::new(0.1, 10.0, 0.1, 0.9, 10.5, 0.9);
408
409        let contact = EntityFluidContact::scan_with(
410            bounding_box,
411            DVec3::new(0.5, 10.0, 0.5),
412            12.0,
413            |pos| {
414                if pos.y() == 10 {
415                    FluidState::flowing(&vanilla_fluids::FLOWING_LAVA, 4, false)
416                } else {
417                    FluidState::EMPTY
418                }
419            },
420            |_pos, _fluid_state| 4.0 / 9.0,
421        );
422
423        assert!(contact.water_height().abs() < f64::EPSILON);
424        assert!((contact.lava_height() - 4.0 / 9.0).abs() < 1.0e-7);
425        assert!(!contact.eye_in_water());
426        assert!(!contact.eye_in_lava());
427    }
428
429    #[test]
430    fn scan_ignores_fluid_below_interaction_box() {
431        init_vanilla_registry();
432        let bounding_box = WorldAabb::new(0.1, 10.2, 0.1, 0.9, 10.6, 0.9);
433
434        let contact = EntityFluidContact::scan_with(
435            bounding_box,
436            DVec3::new(0.5, 10.0, 0.5),
437            10.3,
438            |pos| {
439                if pos.y() == 10 {
440                    FluidState::flowing(&vanilla_fluids::FLOWING_WATER, 1, false)
441                } else {
442                    FluidState::EMPTY
443                }
444            },
445            |_pos, _fluid_state| 1.0 / 9.0,
446        );
447
448        assert_eq!(contact, EntityFluidContact::default());
449    }
450
451    #[test]
452    fn scan_marks_eye_inside_matching_fluid_column() {
453        init_vanilla_registry();
454        let bounding_box = WorldAabb::new(0.1, 10.0, 0.1, 0.9, 11.0, 0.9);
455
456        let contact = EntityFluidContact::scan_with(
457            bounding_box,
458            DVec3::new(0.5, 10.0, 0.5),
459            10.8,
460            |pos| {
461                if pos.y() == 10 {
462                    FluidState::source(&vanilla_fluids::WATER)
463                } else {
464                    FluidState::EMPTY
465                }
466            },
467            |_pos, _fluid_state| 1.0,
468        );
469
470        assert!(contact.eye_in_water());
471        assert!(!contact.eye_in_lava());
472    }
473
474    #[test]
475    fn scan_accumulates_player_fluid_current_as_average_flow() {
476        init_vanilla_registry();
477        let bounding_box = WorldAabb::new(0.1, 10.0, 0.1, 1.9, 10.5, 0.9);
478
479        let contact = EntityFluidContact::scan_with_flow(
480            bounding_box,
481            DVec3::new(0.5, 10.0, 0.5),
482            12.0,
483            true,
484            |_pos| FluidState::source(&vanilla_fluids::WATER),
485            |_pos, _fluid_state| 1.0,
486            |pos, _fluid_state| {
487                if pos.x() == 0 { DVec3::X } else { DVec3::Z }
488            },
489        );
490
491        assert_eq!(
492            contact.water_current_impulse(true, DVec3::ZERO, 1.0),
493            DVec3::new(0.5, 0.0, 0.5)
494        );
495    }
496
497    #[test]
498    fn scan_accumulates_non_player_fluid_current_as_normalized_flow() {
499        init_vanilla_registry();
500        let bounding_box = WorldAabb::new(0.1, 10.0, 0.1, 1.9, 10.5, 0.9);
501
502        let contact = EntityFluidContact::scan_with_flow(
503            bounding_box,
504            DVec3::new(0.5, 10.0, 0.5),
505            12.0,
506            true,
507            |_pos| FluidState::source(&vanilla_fluids::WATER),
508            |_pos, _fluid_state| 1.0,
509            |pos, _fluid_state| {
510                if pos.x() == 0 { DVec3::X } else { DVec3::Z }
511            },
512        );
513
514        let expected = DVec3::new(1.0, 0.0, 1.0).normalize();
515        let impulse = contact.water_current_impulse(false, DVec3::ZERO, 1.0);
516        assert!((impulse - expected).length() < f64::EPSILON);
517    }
518
519    #[test]
520    fn shallow_current_is_scaled_by_fluid_height() {
521        init_vanilla_registry();
522        let bounding_box = WorldAabb::new(0.1, 10.0, 0.1, 0.9, 10.5, 0.9);
523
524        let contact = EntityFluidContact::scan_with_flow(
525            bounding_box,
526            DVec3::new(0.5, 10.0, 0.5),
527            12.0,
528            true,
529            |_pos| FluidState::source(&vanilla_fluids::WATER),
530            |_pos, _fluid_state| 0.2,
531            |_pos, _fluid_state| DVec3::X,
532        );
533
534        let impulse = contact.water_current_impulse(true, DVec3::new(0.01, 0.0, 0.0), 1.0);
535        assert!((impulse - DVec3::new(0.2, 0.0, 0.0)).length() < 1.0e-7);
536    }
537}