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