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(|| BlockPos::containing(position.x, position.y + 8.0, position.z));
72
73 mob.move_to_pos(
74 DVec3::new(
75 f64::from(destination_pos.x()),
76 f64::from(destination_pos.y() + 1),
77 f64::from(destination_pos.z()),
78 ),
79 AIR_NAVIGATION_SPEED_MODIFIER,
80 );
81}
82
83fn first_air_position(level: &dyn LevelReader, position: DVec3) -> Option<BlockPos> {
84 let (min, max) = air_search_bounds(position);
85 first_matching_pos_in_closed_box(min, max, |pos| gives_air(level, pos))
86}
87
88fn air_search_bounds(position: DVec3) -> (BlockPos, BlockPos) {
89 (
90 BlockPos::new(
91 fast_floor(position.x - AIR_SEARCH_HORIZONTAL_RADIUS),
92 fast_floor(position.y),
93 fast_floor(position.z - AIR_SEARCH_HORIZONTAL_RADIUS),
94 ),
95 BlockPos::new(
96 fast_floor(position.x + AIR_SEARCH_HORIZONTAL_RADIUS),
97 fast_floor(position.y + AIR_SEARCH_VERTICAL_ABOVE),
98 fast_floor(position.z + AIR_SEARCH_HORIZONTAL_RADIUS),
99 ),
100 )
101}
102
103fn first_matching_pos_in_closed_box(
104 min: BlockPos,
105 max: BlockPos,
106 mut predicate: impl FnMut(BlockPos) -> bool,
107) -> Option<BlockPos> {
108 for z in min.z()..=max.z() {
109 for y in min.y()..=max.y() {
110 for x in min.x()..=max.x() {
111 let pos = BlockPos::new(x, y, z);
112 if predicate(pos) {
113 return Some(pos);
114 }
115 }
116 }
117 }
118 None
119}
120
121fn gives_air(level: &dyn LevelReader, pos: BlockPos) -> bool {
122 let state = level.get_block_state(pos);
123 (!state.has_fluid() || state.get_block() == &vanilla_blocks::BUBBLE_COLUMN)
124 && state.is_pathfindable(PathComputationType::Land)
125}
126
127#[cfg(test)]
128mod tests {
129 use std::sync::Weak;
130
131 use glam::DVec3;
132 use steel_registry::{init_vanilla_registry, vanilla_entities};
133
134 use super::*;
135 use crate::entity::entities::PigEntity;
136 use crate::entity::{Entity, LivingEntity, LivingTravelInput};
137
138 #[test]
139 fn breath_air_goal_uses_move_and_look_controls() {
140 let goal = BreathAirGoal::new();
141
142 assert_eq!(goal.controls(), GoalControls::MOVE | GoalControls::LOOK);
143 assert!(!goal.is_interruptable());
144 }
145
146 #[test]
147 fn breath_air_goal_uses_vanilla_air_threshold() {
148 init_vanilla_registry();
149 let mut goal = BreathAirGoal::new();
150 let mob = PigEntity::new(&vanilla_entities::PIG, 1, DVec3::ZERO, Weak::new());
151
152 mob.set_air_supply(BREATH_AIR_THRESHOLD);
153 assert!(!goal.can_use(&mob));
154
155 mob.set_air_supply(BREATH_AIR_THRESHOLD - 1);
156 assert!(goal.can_use(&mob));
157 assert!(goal.can_continue_to_use(&mob));
158 }
159
160 #[test]
161 fn breath_air_goal_tick_applies_travel_input_to_velocity() {
162 init_vanilla_registry();
163 let mut goal = BreathAirGoal::new();
164 let mob = PigEntity::new(&vanilla_entities::PIG, 1, DVec3::ZERO, Weak::new());
165 mob.set_travel_input(LivingTravelInput::new(1.0, 0.0, 0.0));
166
167 goal.tick(&mob);
168
169 assert!(mob.velocity().length_squared() > 0.0);
170 }
171
172 #[test]
173 fn air_search_bounds_match_vanilla_offsets() {
174 let (min, max) = air_search_bounds(DVec3::new(-0.25, 64.9, 0.25));
175
176 assert_eq!(min, BlockPos::new(-2, 64, -1));
177 assert_eq!(max, BlockPos::new(0, 72, 1));
178 }
179
180 #[test]
181 fn closed_box_scan_uses_vanilla_x_then_y_then_z_order() {
182 let min = BlockPos::new(10, 20, 30);
183 let max = BlockPos::new(12, 22, 32);
184 let expected = BlockPos::new(11, 20, 30);
185 let later_y = BlockPos::new(10, 21, 30);
186 let later_z = BlockPos::new(10, 20, 31);
187
188 let found = first_matching_pos_in_closed_box(min, max, |pos| {
189 pos == expected || pos == later_y || pos == later_z
190 });
191
192 assert_eq!(found, Some(expected));
193 }
194}