Skip to main content

steel_core/entity/ai/goal/
move_to_block.rs

1use 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}