1use 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#[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 #[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 #[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 #[must_use]
149 pub const fn water_height(self) -> f64 {
150 self.water_height
151 }
152
153 #[must_use]
155 pub const fn lava_height(self) -> f64 {
156 self.lava_height
157 }
158
159 #[must_use]
161 pub const fn any_fluid(self) -> bool {
162 self.water_height > 0.0 || self.lava_height > 0.0
163 }
164
165 #[must_use]
167 pub const fn eye_in_water(self) -> bool {
168 self.eye_in_water
169 }
170
171 #[must_use]
173 pub const fn eye_in_lava(self) -> bool {
174 self.eye_in_lava
175 }
176
177 #[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 #[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}