steel_core/entity/ai/goal/
breath_air.rs1use 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}