Skip to main content

steel_core/entity/ai/goal/
breath_air.rs

1use std::sync::Arc;
2
3use glam::DVec3;
4use steel_math::fast_floor;
5use steel_registry::blocks::block_state_ext::BlockStateExt as _;
6use steel_registry::vanilla_blocks;
7use steel_utils::BlockPos;
8
9use super::selector::{Goal, GoalControls};
10use crate::behavior::BlockStateBehaviorExt as _;
11use crate::entity::ai::path::PathComputationType;
12use crate::entity::{PathfinderMob, SharedEntity};
13use crate::physics::MoverType;
14use crate::world::World;
15
16const BREATH_AIR_THRESHOLD: i32 = 140;
17const AIR_SEARCH_HORIZONTAL_RADIUS: f64 = 1.0;
18const AIR_SEARCH_VERTICAL_ABOVE: f64 = 8.0;
19const BREATH_MOVE_SPEED: f32 = 0.02;
20const AIR_NAVIGATION_SPEED_MODIFIER: f64 = 1.0;
21
22pub struct BreathAirGoal;
23
24impl BreathAirGoal {
25    #[must_use]
26    pub(crate) const fn new() -> Self {
27        Self
28    }
29}
30
31impl Goal for BreathAirGoal {
32    fn controls(&self) -> GoalControls {
33        GoalControls::MOVE | GoalControls::LOOK
34    }
35
36    fn can_use(&mut self, mob: &dyn PathfinderMob) -> bool {
37        mob.air_supply() < BREATH_AIR_THRESHOLD
38    }
39
40    fn can_continue_to_use(&mut self, mob: &dyn PathfinderMob) -> bool {
41        self.can_use(mob)
42    }
43
44    fn is_interruptable(&self) -> bool {
45        false
46    }
47
48    fn start(&mut self, mob: &dyn PathfinderMob) {
49        find_air_position(mob);
50        mob.mob_base().navigation().lock().stop();
51    }
52
53    fn tick(&mut self, mob: &dyn PathfinderMob, entity: &SharedEntity) {
54        find_air_position(mob);
55        let input = mob.travel_input();
56        mob.move_relative(
57            BREATH_MOVE_SPEED,
58            DVec3::new(
59                f64::from(input.sideways()),
60                f64::from(input.vertical()),
61                f64::from(input.forward()),
62            ),
63        );
64        Arc::clone(entity).move_entity(MoverType::SelfMovement, mob.velocity());
65    }
66}
67
68fn find_air_position(mob: &dyn PathfinderMob) {
69    let position = mob.position();
70    let destination_pos = mob
71        .level()
72        .and_then(|world| first_air_position(world.as_ref(), position))
73        .unwrap_or_else(|| {
74            BlockPos::containing(
75                position.x,
76                position.y + AIR_SEARCH_VERTICAL_ABOVE,
77                position.z,
78            )
79        });
80
81    mob.move_to_pos(
82        DVec3::new(
83            f64::from(destination_pos.x()),
84            f64::from(destination_pos.y() + 1),
85            f64::from(destination_pos.z()),
86        ),
87        AIR_NAVIGATION_SPEED_MODIFIER,
88    );
89}
90
91fn first_air_position(level: &World, position: DVec3) -> Option<BlockPos> {
92    let (min, max) = air_search_bounds(position);
93    first_matching_pos_in_closed_box(min, max, |pos| gives_air(level, pos))
94}
95
96fn air_search_bounds(position: DVec3) -> (BlockPos, BlockPos) {
97    (
98        BlockPos::new(
99            fast_floor(position.x - AIR_SEARCH_HORIZONTAL_RADIUS),
100            fast_floor(position.y),
101            fast_floor(position.z - AIR_SEARCH_HORIZONTAL_RADIUS),
102        ),
103        BlockPos::new(
104            fast_floor(position.x + AIR_SEARCH_HORIZONTAL_RADIUS),
105            fast_floor(position.y + AIR_SEARCH_VERTICAL_ABOVE),
106            fast_floor(position.z + AIR_SEARCH_HORIZONTAL_RADIUS),
107        ),
108    )
109}
110
111fn first_matching_pos_in_closed_box(
112    min: BlockPos,
113    max: BlockPos,
114    mut predicate: impl FnMut(BlockPos) -> bool,
115) -> Option<BlockPos> {
116    for z in min.z()..=max.z() {
117        for y in min.y()..=max.y() {
118            for x in min.x()..=max.x() {
119                let pos = BlockPos::new(x, y, z);
120                if predicate(pos) {
121                    return Some(pos);
122                }
123            }
124        }
125    }
126    None
127}
128
129fn gives_air(level: &World, pos: BlockPos) -> bool {
130    let state = level.get_block_state(pos);
131    (!state.has_fluid() || state.get_block() == &vanilla_blocks::BUBBLE_COLUMN)
132        && state.is_pathfindable(PathComputationType::Land)
133}
134
135#[cfg(test)]
136mod tests {
137    use std::sync::{Arc, Weak};
138
139    use glam::DVec3;
140    use steel_registry::{init_vanilla_registry, vanilla_entities};
141
142    use super::*;
143    use crate::entity::entities::PigEntity;
144    use crate::entity::{Entity, LivingEntity, LivingTravelInput};
145
146    #[test]
147    fn breath_air_goal_uses_move_and_look_controls() {
148        let goal = BreathAirGoal::new();
149
150        assert_eq!(goal.controls(), GoalControls::MOVE | GoalControls::LOOK);
151        assert!(!goal.is_interruptable());
152    }
153
154    #[test]
155    fn breath_air_goal_uses_vanilla_air_threshold() {
156        init_vanilla_registry();
157        let mut goal = BreathAirGoal::new();
158        let mob = PigEntity::new(&vanilla_entities::PIG, 1, DVec3::ZERO, Weak::new());
159
160        mob.set_air_supply(BREATH_AIR_THRESHOLD);
161        assert!(!goal.can_use(&mob));
162
163        mob.set_air_supply(BREATH_AIR_THRESHOLD - 1);
164        assert!(goal.can_use(&mob));
165        assert!(goal.can_continue_to_use(&mob));
166    }
167
168    #[test]
169    fn breath_air_goal_tick_applies_travel_input_to_velocity() {
170        init_vanilla_registry();
171        let mut goal = BreathAirGoal::new();
172        let mob = Arc::new(PigEntity::new(
173            &vanilla_entities::PIG,
174            1,
175            DVec3::ZERO,
176            Weak::new(),
177        ));
178        let mob_entity: SharedEntity = Arc::<PigEntity>::clone(&mob);
179        mob.set_travel_input(LivingTravelInput::new(1.0, 0.0, 0.0));
180
181        goal.tick(mob.as_ref(), &mob_entity);
182
183        assert!(mob.velocity().length_squared() > 0.0);
184    }
185
186    #[test]
187    fn air_search_bounds_match_vanilla_offsets() {
188        let (min, max) = air_search_bounds(DVec3::new(-0.25, 64.9, 0.25));
189
190        assert_eq!(min, BlockPos::new(-2, 64, -1));
191        assert_eq!(max, BlockPos::new(0, 72, 1));
192    }
193
194    #[test]
195    fn closed_box_scan_uses_vanilla_x_then_y_then_z_order() {
196        let min = BlockPos::new(10, 20, 30);
197        let max = BlockPos::new(12, 22, 32);
198        let expected = BlockPos::new(11, 20, 30);
199        let later_y = BlockPos::new(10, 21, 30);
200        let later_z = BlockPos::new(10, 20, 31);
201
202        let found = first_matching_pos_in_closed_box(min, max, |pos| {
203            pos == expected || pos == later_y || pos == later_z
204        });
205
206        assert_eq!(found, Some(expected));
207    }
208}