steel_core/entity/ai/goal/
move_to_block.rs1use super::reduced_tick_delay;
2use super::selector::{Goal, GoalControls};
3use crate::entity::{PathfinderMob, SharedEntity};
4use crate::world::World;
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(&World, 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(&World, 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(&World, 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: &World) -> 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, _entity: &SharedEntity) {
188 let move_to_target = self.move_to_target();
189 if move_to_target.closer_to_center_than(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 find_nearest_block_from(
214 mob_pos: BlockPos,
215 search_range: i32,
216 vertical_search_start: i32,
217 vertical_search_range: i32,
218 mut is_valid_target: impl FnMut(BlockPos) -> bool,
219) -> Option<BlockPos> {
220 let mut y = vertical_search_start;
221 while y <= vertical_search_range {
222 for r in 0..search_range {
223 let mut x = 0;
224 while x <= r {
225 let mut z = if x < r && x > -r { r } else { 0 };
226 while z <= r {
227 let pos = mob_pos.offset(x, y - 1, z);
228 if is_valid_target(pos) {
229 return Some(pos);
230 }
231 z = next_mirrored_offset(z);
232 }
233 x = next_mirrored_offset(x);
234 }
235 }
236 y = next_mirrored_offset(y);
237 }
238
239 None
240}
241
242const fn next_mirrored_offset(value: i32) -> i32 {
243 if value > 0 { -value } else { 1 - value }
244}
245
246#[cfg(test)]
247mod tests {
248 use std::sync::Arc;
249
250 use std::sync::Weak;
251
252 use glam::DVec3;
253 use steel_registry::{init_vanilla_registry, vanilla_entities};
254
255 use super::*;
256 use crate::entity::entities::PigEntity;
257
258 #[test]
259 fn move_to_block_goal_uses_move_and_jump_controls() {
260 let goal = MoveToBlockGoal::new(1.0, 8, |_, _| false);
261
262 assert_eq!(goal.controls(), GoalControls::MOVE | GoalControls::JUMP);
263 assert!(goal.requires_update_every_tick());
264 }
265
266 #[test]
267 fn move_to_block_goal_requires_world_after_start_delay() {
268 init_vanilla_registry();
269 let mut goal = MoveToBlockGoal::new(1.0, 8, |_, _| true);
270 let pig = PigEntity::new(&vanilla_entities::PIG, 1, DVec3::ZERO, Weak::new());
271
272 assert!(!goal.can_use(&pig));
273 }
274
275 #[test]
276 fn move_to_block_goal_counts_down_next_start_tick_before_world_lookup() {
277 init_vanilla_registry();
278 let mut goal = MoveToBlockGoal::new(1.0, 8, |_, _| true);
279 goal.next_start_tick = 2;
280 let pig = PigEntity::new(&vanilla_entities::PIG, 1, DVec3::ZERO, Weak::new());
281
282 assert!(!goal.can_use(&pig));
283
284 assert_eq!(goal.next_start_tick, 1);
285 }
286
287 #[test]
288 fn move_to_block_search_uses_vanilla_below_first_order() {
289 let mob_pos = BlockPos::new(10, 64, 10);
290 let expected = BlockPos::new(10, 63, 10);
291 let later = BlockPos::new(11, 63, 10);
292
293 let found =
294 find_nearest_block_from(mob_pos, 8, 0, 1, |pos| pos == later || pos == expected);
295
296 assert_eq!(found, Some(expected));
297 }
298
299 #[test]
300 fn move_to_block_search_respects_vertical_search_start() {
301 let mob_pos = BlockPos::new(10, 64, 10);
302 let expected = BlockPos::new(10, 61, 10);
303 let skipped = BlockPos::new(10, 63, 10);
304
305 let found =
306 find_nearest_block_from(mob_pos, 8, -2, 6, |pos| pos == skipped || pos == expected);
307
308 assert_eq!(found, Some(expected));
309 }
310
311 #[test]
312 fn move_to_block_goal_tracks_reached_target() {
313 init_vanilla_registry();
314 let mut goal = MoveToBlockGoal::new(1.0, 8, |_, _| false);
315 goal.block_pos = BlockPos::new(0, -1, 0);
316 let pig = Arc::new(PigEntity::new(
317 &vanilla_entities::PIG,
318 1,
319 DVec3::new(0.5, 0.5, 0.5),
320 Weak::new(),
321 ));
322 let pig_entity: SharedEntity = Arc::<PigEntity>::clone(&pig);
323
324 goal.tick(pig.as_ref(), &pig_entity);
325
326 assert!(goal.is_reached_target());
327 assert_eq!(goal.try_ticks, -1);
328 }
329}