Skip to main content

steel_core/entity/ai/goal/
look_at_player.rs

1use glam::DVec3;
2
3use super::reduced_tick_delay;
4use crate::entity::ai::control::{DEFAULT_LOOK_X_MAX_ROT_ANGLE, DEFAULT_LOOK_Y_MAX_ROT_SPEED};
5use crate::entity::ai::goal::selector::{Goal, GoalControls};
6use crate::entity::ai::targeting::TargetingConditions;
7use crate::entity::{LivingEntity, PathfinderMob, SharedEntity};
8use crate::world::World;
9
10const DEFAULT_PROBABILITY: f32 = 0.02;
11
12type LookAtEntitySelector = Box<dyn Fn(&dyn LivingEntity, &World) -> bool + Send + Sync>;
13
14enum LookAtTargetType {
15    Player,
16    LivingEntity(LookAtEntitySelector),
17}
18
19pub struct LookAtPlayerGoal {
20    look_at: Option<SharedEntity>,
21    look_distance: f64,
22    look_time: i32,
23    probability: f32,
24    only_horizontal: bool,
25    controls: GoalControls,
26    look_at_type: LookAtTargetType,
27    look_at_context: TargetingConditions,
28}
29
30impl LookAtPlayerGoal {
31    #[must_use]
32    pub(crate) fn new(look_distance: f64) -> Self {
33        Self::new_with_probability(look_distance, DEFAULT_PROBABILITY)
34    }
35
36    #[must_use]
37    pub(crate) fn new_with_probability(look_distance: f64, probability: f32) -> Self {
38        Self::new_with_probability_and_horizontal(look_distance, probability, false)
39    }
40
41    #[must_use]
42    pub(crate) fn new_with_probability_and_horizontal(
43        look_distance: f64,
44        probability: f32,
45        only_horizontal: bool,
46    ) -> Self {
47        Self::new_for_players_with_controls(
48            look_distance,
49            probability,
50            only_horizontal,
51            GoalControls::LOOK,
52        )
53    }
54
55    #[must_use]
56    pub(crate) fn new_for_living_entities(
57        look_distance: f64,
58        probability: f32,
59        selector: impl Fn(&dyn LivingEntity, &World) -> bool + Send + Sync + 'static,
60    ) -> Self {
61        Self::new_for_living_entities_with_controls(
62            look_distance,
63            probability,
64            false,
65            GoalControls::LOOK,
66            selector,
67        )
68    }
69
70    #[must_use]
71    pub(super) fn new_for_players_with_controls(
72        look_distance: f64,
73        probability: f32,
74        only_horizontal: bool,
75        controls: GoalControls,
76    ) -> Self {
77        Self {
78            look_at: None,
79            look_distance,
80            look_time: 0,
81            probability,
82            only_horizontal,
83            controls,
84            look_at_type: LookAtTargetType::Player,
85            look_at_context: TargetingConditions::for_non_combat().range(look_distance),
86        }
87    }
88
89    #[must_use]
90    pub(super) fn new_for_living_entities_with_controls(
91        look_distance: f64,
92        probability: f32,
93        only_horizontal: bool,
94        controls: GoalControls,
95        selector: impl Fn(&dyn LivingEntity, &World) -> bool + Send + Sync + 'static,
96    ) -> Self {
97        Self {
98            look_at: None,
99            look_distance,
100            look_time: 0,
101            probability,
102            only_horizontal,
103            controls,
104            look_at_type: LookAtTargetType::LivingEntity(Box::new(selector)),
105            look_at_context: TargetingConditions::for_non_combat().range(look_distance),
106        }
107    }
108}
109
110impl Goal for LookAtPlayerGoal {
111    fn controls(&self) -> GoalControls {
112        self.controls
113    }
114
115    fn can_use(&mut self, mob: &dyn PathfinderMob) -> bool {
116        if rand::random::<f32>() >= self.probability {
117            return false;
118        }
119
120        let Some(world) = mob.level() else {
121            return false;
122        };
123
124        let position = mob.position();
125        let origin = DVec3::new(position.x, mob.get_eye_y(), position.z);
126        self.look_at = match &self.look_at_type {
127            LookAtTargetType::Player => world
128                .nearest_player(origin, self.look_distance, |player| {
129                    !mob.has_indirect_passenger(player)
130                        && self.look_at_context.test(world.as_ref(), Some(mob), player)
131                })
132                .map(|player| -> SharedEntity { player }),
133            LookAtTargetType::LivingEntity(selector) => {
134                let search_box =
135                    mob.bounding_box()
136                        .inflate_xyz(self.look_distance, 3.0, self.look_distance);
137                world.nearest_entity_in_aabb_matching(&search_box, origin, |entity| {
138                    entity.as_living_entity().is_some_and(|living| {
139                        selector(living, world.as_ref())
140                            && self.look_at_context.test(world.as_ref(), Some(mob), living)
141                    })
142                })
143            }
144        };
145
146        self.look_at.is_some()
147    }
148
149    fn can_continue_to_use(&mut self, mob: &dyn PathfinderMob) -> bool {
150        let Some(look_at) = &self.look_at else {
151            return false;
152        };
153        if !look_at.is_alive() {
154            return false;
155        }
156        if mob.position().distance_squared(look_at.position())
157            > self.look_distance * self.look_distance
158        {
159            return false;
160        }
161
162        self.look_time > 0
163    }
164
165    fn start(&mut self, _mob: &dyn PathfinderMob) {
166        self.look_time = reduced_tick_delay(40 + rand::random_range(0..40));
167    }
168
169    fn stop(&mut self, _mob: &dyn PathfinderMob) {
170        self.look_at = None;
171    }
172
173    fn tick(&mut self, mob: &dyn PathfinderMob) {
174        let Some(look_at) = &self.look_at else {
175            return;
176        };
177        if !look_at.is_alive() {
178            return;
179        }
180
181        let position = look_at.position();
182        let target_y = if self.only_horizontal {
183            mob.get_eye_y()
184        } else {
185            look_at.get_eye_y()
186        };
187        mob.mob_base().controls().lock().look_control.set_look_at(
188            DVec3::new(position.x, target_y, position.z),
189            DEFAULT_LOOK_Y_MAX_ROT_SPEED,
190            DEFAULT_LOOK_X_MAX_ROT_ANGLE,
191        );
192        self.look_time -= 1;
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use std::sync::Weak;
199
200    use super::*;
201    use crate::entity::entities::PigEntity;
202    use glam::DVec3;
203    use steel_registry::{init_vanilla_registry, vanilla_entities};
204
205    #[test]
206    fn look_at_player_goal_claims_only_look_control() {
207        let goal = LookAtPlayerGoal::new(6.0);
208
209        assert_eq!(goal.controls(), GoalControls::LOOK);
210    }
211
212    #[test]
213    fn look_at_player_goal_can_claim_custom_controls_for_vanilla_subclasses() {
214        let goal = LookAtPlayerGoal::new_for_players_with_controls(
215            6.0,
216            1.0,
217            false,
218            GoalControls::LOOK | GoalControls::MOVE,
219        );
220
221        assert_eq!(goal.controls(), GoalControls::LOOK | GoalControls::MOVE);
222    }
223
224    #[test]
225    fn look_at_player_goal_supports_selector_based_living_targets() {
226        let goal = LookAtPlayerGoal::new_for_living_entities(8.0, 1.0, |living, _| {
227            living.entity_type() == &vanilla_entities::PIG
228        });
229
230        assert_eq!(goal.controls(), GoalControls::LOOK);
231    }
232
233    #[test]
234    fn look_at_player_goal_uses_vanilla_adjusted_look_time() {
235        init_vanilla_registry();
236        let pig = PigEntity::new(&vanilla_entities::PIG, 1, DVec3::ZERO, Weak::new());
237        let mut goal = LookAtPlayerGoal::new(6.0);
238
239        goal.start(&pig);
240
241        assert!(
242            (reduced_tick_delay(40)..=reduced_tick_delay(79)).contains(&goal.look_time),
243            "look_time was {}",
244            goal.look_time
245        );
246    }
247}