Skip to main content

steel_core/entity/mob/
pathfinder.rs

1use 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_attributes, vanilla_blocks};
7use steel_utils::{BlockPos, ChunkPos};
8
9use super::{Mob, TARGET_REACH_DISTANCE_SQR};
10use crate::entity::ai::goal::{Goal, GoalControl};
11use crate::entity::ai::navigation::{
12    NavigationPathRequest, NavigationRecomputeRequest, NavigationTickContext,
13};
14use crate::entity::ai::path::Path;
15use crate::entity::ai::walk::{MobPathSettings, WalkNodeEvaluator};
16use crate::entity::{Entity, LivingEntity, SharedEntity};
17use crate::physics::WorldCollisionProvider;
18use crate::world::{LevelReader, World};
19
20pub(super) const LEASH_CLOSE_RANGE_WANTED_DISTANCE: f64 = 2.0;
21
22pub(super) fn tick_path_navigation_target<M: Mob + ?Sized>(
23    mob: &M,
24    world: &Arc<World>,
25    game_time: i64,
26    can_update_path: bool,
27) {
28    let (target, speed_modifier) = {
29        let mut navigation = mob.mob_base().navigation().lock();
30        let ground_mob_position =
31            ground_navigation_temp_mob_pos(mob, world.as_ref(), navigation.can_float());
32        let context = NavigationTickContext {
33            ground_mob_position,
34            mob_position: mob.position(),
35            mob_bounding_box_width: mob.bounding_box().width(),
36            mob_speed: mob.get_speed(),
37            game_time,
38        };
39        let next_target = if can_update_path {
40            navigation.next_move_target(context)
41        } else {
42            navigation.next_move_target_without_path_update(context, mob.on_ground())
43        };
44        let Some(target) = next_target else {
45            return;
46        };
47        target
48    };
49
50    let target_pos = BlockPos::containing(target.x, target.y, target.z);
51    let ground_y = if world.get_block_state(target_pos.below()).is_air() {
52        target.y
53    } else {
54        WalkNodeEvaluator::floor_level(world.as_ref(), target_pos)
55    };
56    mob.set_wanted_position(DVec3::new(target.x, ground_y, target.z), speed_modifier);
57}
58
59fn ground_navigation_temp_mob_pos<M: Mob + ?Sized>(
60    mob: &M,
61    world: &World,
62    can_float: bool,
63) -> DVec3 {
64    let position = mob.position();
65    DVec3::new(
66        position.x,
67        f64::from(ground_navigation_surface_y(mob, world, can_float)),
68        position.z,
69    )
70}
71
72fn ground_navigation_surface_y<M: Mob + ?Sized>(mob: &M, world: &World, can_float: bool) -> i32 {
73    if !mob.is_in_water() || !can_float {
74        return fast_floor(mob.position().y + 0.5);
75    }
76
77    let position = mob.position();
78    let block_y = mob.block_position().y();
79    let mut surface = block_y;
80    let mut state = world.get_block_state(BlockPos::containing(
81        position.x,
82        f64::from(surface),
83        position.z,
84    ));
85    let mut steps = 0;
86    while state.get_block() == &vanilla_blocks::WATER {
87        surface += 1;
88        state = world.get_block_state(BlockPos::containing(
89            position.x,
90            f64::from(surface),
91            position.z,
92        ));
93        steps += 1;
94        if steps > 16 {
95            return block_y;
96        }
97    }
98
99    surface
100}
101
102pub trait PathfinderMob: Mob {
103    fn controlled_pathfinder_vehicle(&self) -> Option<SharedEntity> {
104        let vehicle = self.controlled_mob_vehicle()?;
105        vehicle.as_pathfinder_mob()?;
106        Some(vehicle)
107    }
108
109    /// Scores pathfinding positions: animals favor light, monsters darkness.
110    fn get_walk_target_value(&self, pos: BlockPos) -> f32 {
111        if let Some(monster) = self.as_monster() {
112            monster.get_walk_target_value_monster(pos)
113        } else if let Some(animal) = self.as_animal() {
114            animal.animal_walk_target_value(pos)
115        } else {
116            0.0
117        }
118    }
119
120    fn can_update_path(&self) -> bool {
121        self.on_ground() || self.is_in_water() || self.is_in_lava() || self.is_passenger()
122    }
123
124    fn can_path_to_targets_below_surface(&self) -> bool {
125        if let Some(vehicle) = self.controlled_pathfinder_vehicle()
126            && let Some(pathfinder) = vehicle.as_pathfinder_mob()
127        {
128            return pathfinder.can_path_to_targets_below_surface();
129        }
130
131        self.mob_base()
132            .navigation()
133            .lock()
134            .can_path_to_targets_below_surface()
135    }
136
137    fn can_reach_living_target(&self, target: &dyn LivingEntity) -> bool {
138        let target_pos = target.block_position();
139        self.create_path_to(target_pos, 0)
140            .is_some_and(|path| path_end_node_can_reach_target(&path, target_pos))
141    }
142
143    fn tick_pathfinder_path_navigation(&self) {
144        let Some(world) = self.level() else {
145            return;
146        };
147        let game_time = world.game_time();
148        let recompute_request = {
149            let mut navigation = self.mob_base().navigation().lock();
150            navigation.tick();
151            navigation.take_delayed_recompute_request(game_time, self.can_update_path())
152        };
153        if let Some(request) = recompute_request {
154            self.recompute_path(request);
155        }
156
157        tick_path_navigation_target(self, &world, game_time, self.can_update_path());
158    }
159
160    fn tick_pathfinder_goal_selectors(&self, entity: &SharedEntity)
161    where
162        Self: Sized,
163    {
164        let id_based_tick_count = self.tick_count().wrapping_add(self.id());
165        let mut target_selector = self.mob_base().target_selector().lock();
166        let mut goal_selector = self.mob_base().goal_selector().lock();
167        if id_based_tick_count % 2 != 0 && self.tick_count() > 1 {
168            target_selector.tick_running_goals(self, entity, false);
169            goal_selector.tick_running_goals(self, entity, false);
170        } else {
171            target_selector.tick(self, entity);
172            goal_selector.tick(self, entity);
173        }
174    }
175
176    fn is_stable_destination(&self, pos: BlockPos) -> bool {
177        self.level()
178            .is_some_and(|world| world.get_block_state(pos.below()).is_solid_render())
179    }
180
181    fn create_path_to(&self, target: BlockPos, reach_range: i32) -> Option<Path> {
182        if let Some(vehicle) = self.controlled_pathfinder_vehicle()
183            && let Some(pathfinder) = vehicle.as_pathfinder_mob()
184        {
185            return pathfinder.create_path_to(target, reach_range);
186        }
187
188        let world = self.level()?;
189        if !world.has_full_chunk(ChunkPos::from_block_pos(target)) {
190            return None;
191        }
192
193        let target = path_target_for_mob(self, world.as_ref(), target);
194        let targets = [target];
195        self.create_path_to_targets(&world, &targets, reach_range)
196    }
197
198    fn recompute_path(&self, request: NavigationRecomputeRequest) {
199        if let Some(vehicle) = self.controlled_pathfinder_vehicle()
200            && let Some(pathfinder) = vehicle.as_pathfinder_mob()
201        {
202            pathfinder.recompute_path(request);
203            return;
204        }
205
206        let path = self.create_path_to(request.target_pos, request.reach_range);
207        self.mob_base()
208            .navigation()
209            .lock()
210            .complete_recompute_path(path, request.game_time);
211    }
212
213    fn move_to_pos(&self, target: DVec3, speed_modifier: f64) -> bool {
214        self.move_to_pos_with_reach(target, 1, speed_modifier)
215    }
216
217    fn move_to_pos_with_reach(&self, target: DVec3, reach_range: i32, speed_modifier: f64) -> bool {
218        if let Some(vehicle) = self.controlled_pathfinder_vehicle()
219            && let Some(pathfinder) = vehicle.as_pathfinder_mob()
220        {
221            return pathfinder.move_to_pos_with_reach(target, reach_range, speed_modifier);
222        }
223
224        let target_pos = BlockPos::containing(target.x, target.y, target.z);
225        let Some(world) = self.level() else {
226            self.mob_base().navigation().lock().stop();
227            return false;
228        };
229        if !world.has_full_chunk(ChunkPos::from_block_pos(target_pos)) {
230            self.mob_base().navigation().lock().stop();
231            return false;
232        }
233
234        let target_pos = path_target_for_mob(self, world.as_ref(), target_pos);
235        let targets = [target_pos];
236        if self
237            .mob_base()
238            .navigation()
239            .lock()
240            .reuse_current_path_to_targets(
241                world.as_ref(),
242                &targets,
243                speed_modifier,
244                self.position(),
245            )
246        {
247            return true;
248        }
249
250        let path = self.create_path_to_targets(&world, &targets, reach_range);
251        self.move_to_path(path, speed_modifier)
252    }
253
254    fn move_to_path(&self, path: Option<Path>, speed_modifier: f64) -> bool {
255        if let Some(vehicle) = self.controlled_pathfinder_vehicle()
256            && let Some(pathfinder) = vehicle.as_pathfinder_mob()
257        {
258            return pathfinder.move_to_path(path, speed_modifier);
259        }
260
261        let Some(world) = self.level() else {
262            self.mob_base().navigation().lock().stop();
263            return false;
264        };
265        let mut navigation = self.mob_base().navigation().lock();
266        let Some(path) = path else {
267            navigation.stop();
268            return false;
269        };
270
271        navigation.move_to(world.as_ref(), path, speed_modifier, self.position())
272    }
273
274    fn is_path_finding(&self) -> bool {
275        if let Some(vehicle) = self.controlled_pathfinder_vehicle()
276            && let Some(pathfinder) = vehicle.as_pathfinder_mob()
277        {
278            return pathfinder.is_path_finding();
279        }
280
281        !self.mob_base().navigation().lock().is_done()
282    }
283
284    fn is_panicking(&self) -> bool {
285        self.mob_base()
286            .goal_selector()
287            .lock()
288            .has_running_panic_goal()
289    }
290
291    fn should_stay_close_to_leash_holder(&self) -> bool {
292        true
293    }
294
295    fn follow_leash_speed(&self) -> f64 {
296        1.0
297    }
298
299    fn when_leashed_to_pathfinder(&self, holder: &dyn Entity) {
300        let radius = self.leash_elastic_distance() as i32 - 1;
301        self.set_home_to(holder.block_position(), radius);
302        holder.notify_leash_holder(self.as_entity_event_source());
303    }
304
305    /// Re-enables MOVE and follows a nearby holder unless panicking.
306    ///
307    /// Brain-based panic detection awaits Brain support; Steel uses vanilla's
308    /// running-`PanicGoal` fallback.
309    fn close_range_leash_behaviour_pathfinder(&self, holder: &dyn Entity) {
310        if !self.should_stay_close_to_leash_holder() || self.is_panicking() {
311            return;
312        }
313
314        self.mob_base()
315            .goal_selector()
316            .lock()
317            .enable_control(GoalControl::Move);
318        let distance_to = (holder.position() - self.position()).length();
319        let delta = (holder.position() - self.position()).normalize_or_zero()
320            * (distance_to - LEASH_CLOSE_RANGE_WANTED_DISTANCE).max(0.0);
321        self.move_to_pos(self.position() + delta, self.follow_leash_speed());
322    }
323
324    /// Stops and removes matching goals. Lives on `PathfinderMob` rather than
325    /// vanilla's `Mob` because Steel's goal hooks require pathfinding mobs.
326    fn remove_all_goals(&self, predicate: impl FnMut(&dyn Goal) -> bool)
327    where
328        Self: Sized,
329    {
330        self.mob_base()
331            .goal_selector()
332            .lock()
333            .remove_all_goals(self, predicate);
334    }
335
336    fn remove_free_will(&self)
337    where
338        Self: Sized,
339    {
340        self.remove_all_goals(|_| true);
341        // Vanilla also removes all brain behaviors; Steel has no brain system.
342    }
343
344    fn create_path_to_targets(
345        &self,
346        world: &Arc<World>,
347        targets: &[BlockPos],
348        reach_range: i32,
349    ) -> Option<Path> {
350        if let Some(vehicle) = self.controlled_pathfinder_vehicle()
351            && let Some(pathfinder) = vehicle.as_pathfinder_mob()
352        {
353            return pathfinder.create_path_to_targets(world, targets, reach_range);
354        }
355
356        if targets.is_empty()
357            || self.position().y < f64::from(world.min_y())
358            || !self.can_update_path()
359        {
360            return None;
361        }
362
363        let follow_range = self
364            .attributes()
365            .lock()
366            .required_value(vanilla_attributes::FOLLOW_RANGE);
367        let max_path_length = {
368            let mut navigation = self.mob_base().navigation().lock();
369            navigation.update_pathfinder_max_visited_nodes(follow_range);
370            navigation.max_path_length(follow_range)
371        };
372
373        let mob_position = self.block_position();
374        let settings = MobPathSettings::from_mob(self);
375        let mut evaluator = WalkNodeEvaluator::new(settings);
376        let collision_world =
377            WorldCollisionProvider::for_path_navigation(world, self.as_entity_event_source());
378        let mut collision = |aabb| {
379            collision_world.has_entity_context_collision(
380                aabb,
381                self.position().y,
382                self.is_descending(),
383            )
384        };
385
386        self.mob_base().navigation().lock().create_path(
387            &mut evaluator,
388            world.as_ref(),
389            &mut collision,
390            NavigationPathRequest {
391                mob_position,
392                targets,
393                max_path_length,
394                reach_range,
395            },
396        )
397    }
398}
399
400pub(super) fn path_end_node_can_reach_target(path: &Path, target: BlockPos) -> bool {
401    let Some(end_node) = path.end_node() else {
402        return false;
403    };
404    let dx = end_node.x - target.x();
405    let dz = end_node.z - target.z();
406    f64::from(dx * dx + dz * dz) <= TARGET_REACH_DISTANCE_SQR
407}
408
409fn path_target_for_mob<M: PathfinderMob + ?Sized, L: LevelReader>(
410    mob: &M,
411    level: &L,
412    target: BlockPos,
413) -> BlockPos {
414    if mob.can_path_to_targets_below_surface() {
415        target
416    } else {
417        find_ground_path_target_surface(level, target)
418    }
419}
420
421pub(super) fn find_ground_path_target_surface(
422    level: &impl LevelReader,
423    mut pos: BlockPos,
424) -> BlockPos {
425    if level.get_block_state(pos).is_air() {
426        let mut column_pos = pos.below();
427        while column_pos.y() >= level.min_y() && level.get_block_state(column_pos).is_air() {
428            column_pos = column_pos.below();
429        }
430        if column_pos.y() >= level.min_y() {
431            return column_pos.above();
432        }
433
434        column_pos = pos.at_y(pos.y() + 1);
435        while column_pos.y() < level.max_y_exclusive() && level.get_block_state(column_pos).is_air()
436        {
437            column_pos = column_pos.above();
438        }
439        pos = column_pos;
440    }
441
442    if !level.get_block_state(pos).is_solid() {
443        return pos;
444    }
445
446    let mut column_pos = pos.above();
447    while column_pos.y() < level.max_y_exclusive() && level.get_block_state(column_pos).is_solid() {
448        column_pos = column_pos.above();
449    }
450    column_pos
451}