steel_core/entity/ai/goal/
move_to_block.rs1use super::reduced_tick_delay;
2use super::selector::{Goal, GoalControls};
3use crate::entity::PathfinderMob;
4use crate::world::LevelReader;
5use glam::DVec3;
6use steel_utils::BlockPos;
7
8const GIVE_UP_TICKS: i32 = 1200;
9const STAY_TICKS: i32 = 1200;
10const INTERVAL_TICKS: i32 = 200;
11const DEFAULT_VERTICAL_SEARCH_RANGE: i32 = 1;
12const DEFAULT_RECALCULATE_PATH_INTERVAL: i32 = 40;
13const DEFAULT_ACCEPTED_DISTANCE: f64 = 1.0;
14
15type ValidTargetPredicate = Box<dyn Fn(&dyn LevelReader, BlockPos) -> bool + Send + Sync>;
16type MoveToTarget = Box<dyn Fn(BlockPos) -> BlockPos + Send + Sync>;
17
18pub struct MoveToBlockGoal {
19 block_pos: BlockPos,
20 target_predicate: ValidTargetPredicate,
21 move_to_target: MoveToTarget,
22 speed_modifier: f64,
23 next_start_tick: i32,
24 try_ticks: i32,
25 max_stay_ticks: i32,
26 reached_target: bool,
27 search_range: i32,
28 vertical_search_range: i32,
29 vertical_search_start: i32,
30 accepted_distance: f64,
31 recalculate_path_interval: i32,
32}
33
34impl MoveToBlockGoal {
35 #[must_use]
36 pub(crate) fn new(
37 speed_modifier: f64,
38 search_range: i32,
39 target_predicate: impl Fn(&dyn LevelReader, BlockPos) -> bool + Send + Sync + 'static,
40 ) -> Self {
41 Self::with_vertical_search_range(
42 speed_modifier,
43 search_range,
44 DEFAULT_VERTICAL_SEARCH_RANGE,
45 target_predicate,
46 )
47 }
48
49 #[must_use]
50 pub(crate) fn with_vertical_search_range(
51 speed_modifier: f64,
52 search_range: i32,
53 vertical_search_range: i32,
54 target_predicate: impl Fn(&dyn LevelReader, BlockPos) -> bool + Send + Sync + 'static,
55 ) -> Self {
56 Self {
57 block_pos: BlockPos::ZERO,
58 target_predicate: Box::new(target_predicate),
59 move_to_target: Box::new(|pos| pos.above()),
60 speed_modifier,
61 next_start_tick: 0,
62 try_ticks: 0,
63 max_stay_ticks: 0,
64 reached_target: false,
65 search_range,
66 vertical_search_range,
67 vertical_search_start: 0,
68 accepted_distance: DEFAULT_ACCEPTED_DISTANCE,
69 recalculate_path_interval: DEFAULT_RECALCULATE_PATH_INTERVAL,
70 }
71 }
72
73 #[must_use]
74 pub(crate) const fn with_vertical_search_start(mut self, vertical_search_start: i32) -> Self {
75 self.vertical_search_start = vertical_search_start;
76 self
77 }
78
79 #[must_use]
80 pub(crate) const fn with_accepted_distance(mut self, accepted_distance: f64) -> Self {
81 self.accepted_distance = accepted_distance;
82 self
83 }
84
85 #[must_use]
86 pub(crate) const fn with_recalculate_path_interval(
87 mut self,
88 recalculate_path_interval: i32,
89 ) -> Self {
90 self.recalculate_path_interval = recalculate_path_interval;
91 self
92 }
93
94 #[must_use]
95 pub(crate) fn with_move_to_target(
96 mut self,
97 move_to_target: impl Fn(BlockPos) -> BlockPos + Send + Sync + 'static,
98 ) -> Self {
99 self.move_to_target = Box::new(move_to_target);
100 self
101 }
102
103 #[must_use]
104 pub(crate) const fn block_pos(&self) -> BlockPos {
105 self.block_pos
106 }
107
108 #[must_use]
109 pub(crate) const fn is_reached_target(&self) -> bool {
110 self.reached_target
111 }
112
113 fn next_start_tick(_mob: &dyn PathfinderMob) -> i32 {
114 reduced_tick_delay(INTERVAL_TICKS + rand::random_range(0..200))
115 }
116
117 fn move_mob_to_block(&self, mob: &dyn PathfinderMob) {
118 mob.move_to_pos(
119 block_center_with_y(self.block_pos, self.block_pos.y() + 1),
120 self.speed_modifier,
121 );
122 }
123
124 fn move_to_target(&self) -> BlockPos {
125 (self.move_to_target)(self.block_pos)
126 }
127
128 const fn should_recalculate_path(&self) -> bool {
129 self.try_ticks % self.recalculate_path_interval == 0
130 }
131
132 fn find_nearest_block(&mut self, mob: &dyn PathfinderMob, level: &dyn LevelReader) -> bool {
133 let Some(block_pos) = find_nearest_block_from(
134 mob.block_position(),
135 self.search_range,
136 self.vertical_search_start,
137 self.vertical_search_range,
138 |pos| mob.is_within_home_pos(pos) && (self.target_predicate)(level, pos),
139 ) else {
140 return false;
141 };
142
143 self.block_pos = block_pos;
144 true
145 }
146}
147
148impl Goal for MoveToBlockGoal {
149 fn controls(&self) -> GoalControls {
150 GoalControls::MOVE | GoalControls::JUMP
151 }
152
153 fn requires_update_every_tick(&self) -> bool {
154 true
155 }
156
157 fn can_use(&mut self, mob: &dyn PathfinderMob) -> bool {
158 if self.next_start_tick > 0 {
159 self.next_start_tick -= 1;
160 return false;
161 }
162
163 self.next_start_tick = Self::next_start_tick(mob);
164 let Some(world) = mob.level() else {
165 return false;
166 };
167 self.find_nearest_block(mob, world.as_ref())
168 }
169
170 fn can_continue_to_use(&mut self, mob: &dyn PathfinderMob) -> bool {
171 let Some(world) = mob.level() else {
172 return false;
173 };
174
175 self.try_ticks >= -self.max_stay_ticks
176 && self.try_ticks <= GIVE_UP_TICKS
177 && (self.target_predicate)(world.as_ref(), self.block_pos)
178 }
179
180 fn start(&mut self, mob: &dyn PathfinderMob) {
181 self.move_mob_to_block(mob);
182 self.try_ticks = 0;
183 let inner_bound = rand::random_range(0..STAY_TICKS) + STAY_TICKS;
184 self.max_stay_ticks = rand::random_range(0..inner_bound) + STAY_TICKS;
185 }
186
187 fn tick(&mut self, mob: &dyn PathfinderMob) {
188 let move_to_target = self.move_to_target();
189 if block_pos_closer_to_center_than(move_to_target, mob.position(), self.accepted_distance) {
190 self.reached_target = true;
191 self.try_ticks -= 1;
192 } else {
193 self.reached_target = false;
194 self.try_ticks += 1;
195 if self.should_recalculate_path() {
196 mob.move_to_pos(
197 block_center_with_y(move_to_target, move_to_target.y()),
198 self.speed_modifier,
199 );
200 }
201 }
202 }
203}
204
205fn block_center_with_y(pos: BlockPos, y: i32) -> DVec3 {
206 DVec3::new(
207 f64::from(pos.x()) + 0.5,
208 f64::from(y),
209 f64::from(pos.z()) + 0.5,
210 )
211}
212
213fn block_pos_closer_to_center_than(pos: BlockPos, position: DVec3, distance: f64) -> bool {
214 let (x, y, z) = pos.get_center();
215 DVec3::new(x, y, z).distance_squared(position) < distance * distance
216}
217
218fn find_nearest_block_from(
219 mob_pos: BlockPos,
220 search_range: i32,
221 vertical_search_start: i32,
222 vertical_search_range: i32,
223 mut is_valid_target: impl FnMut(BlockPos) -> bool,
224) -> Option<BlockPos> {
225 let mut y = vertical_search_start;
226 while y <= vertical_search_range {
227 for r in 0..search_range {
228 let mut x = 0;
229 while x <= r {
230 let mut z = if x < r && x > -r { r } else { 0 };
231 while z <= r {
232 let pos = mob_pos.offset(x, y - 1, z);
233 if is_valid_target(pos) {
234 return Some(pos);
235 }
236 z = next_mirrored_offset(z);
237 }
238 x = next_mirrored_offset(x);
239 }
240 }
241 y = next_mirrored_offset(y);
242 }
243
244 None
245}
246
247const fn next_mirrored_offset(value: i32) -> i32 {
248 if value > 0 { -value } else { 1 - value }
249}
250
251#[cfg(test)]
252mod tests {
253 use std::sync::Weak;
254
255 use glam::DVec3;
256 use steel_registry::{init_vanilla_registry, vanilla_entities};
257
258 use super::*;
259 use crate::entity::entities::PigEntity;
260
261 #[test]
262 fn move_to_block_goal_uses_move_and_jump_controls() {
263 let goal = MoveToBlockGoal::new(1.0, 8, |_, _| false);
264
265 assert_eq!(goal.controls(), GoalControls::MOVE | GoalControls::JUMP);
266 assert!(goal.requires_update_every_tick());
267 }
268
269 #[test]
270 fn move_to_block_goal_requires_world_after_start_delay() {
271 init_vanilla_registry();
272 let mut goal = MoveToBlockGoal::new(1.0, 8, |_, _| true);
273 let mob = PigEntity::new(&vanilla_entities::PIG, 1, DVec3::ZERO, Weak::new());
274
275 assert!(!goal.can_use(&mob));
276 }
277
278 #[test]
279 fn move_to_block_goal_counts_down_next_start_tick_before_world_lookup() {
280 init_vanilla_registry();
281 let mut goal = MoveToBlockGoal::new(1.0, 8, |_, _| true);
282 goal.next_start_tick = 2;
283 let mob = PigEntity::new(&vanilla_entities::PIG, 1, DVec3::ZERO, Weak::new());
284
285 assert!(!goal.can_use(&mob));
286
287 assert_eq!(goal.next_start_tick, 1);
288 }
289
290 #[test]
291 fn move_to_block_search_uses_vanilla_below_first_order() {
292 let mob_pos = BlockPos::new(10, 64, 10);
293 let expected = BlockPos::new(10, 63, 10);
294 let later = BlockPos::new(11, 63, 10);
295
296 let found =
297 find_nearest_block_from(mob_pos, 8, 0, 1, |pos| pos == later || pos == expected);
298
299 assert_eq!(found, Some(expected));
300 }
301
302 #[test]
303 fn move_to_block_search_respects_vertical_search_start() {
304 let mob_pos = BlockPos::new(10, 64, 10);
305 let expected = BlockPos::new(10, 61, 10);
306 let skipped = BlockPos::new(10, 63, 10);
307
308 let found =
309 find_nearest_block_from(mob_pos, 8, -2, 6, |pos| pos == skipped || pos == expected);
310
311 assert_eq!(found, Some(expected));
312 }
313
314 #[test]
315 fn move_to_block_goal_tracks_reached_target() {
316 init_vanilla_registry();
317 let mut goal = MoveToBlockGoal::new(1.0, 8, |_, _| false);
318 goal.block_pos = BlockPos::new(0, -1, 0);
319 let mob = PigEntity::new(
320 &vanilla_entities::PIG,
321 1,
322 DVec3::new(0.5, 0.5, 0.5),
323 Weak::new(),
324 );
325
326 goal.tick(&mob);
327
328 assert!(goal.is_reached_target());
329 assert_eq!(goal.try_ticks, -1);
330 }
331}