Skip to main content

steel_core/entity/ai/
targeting.rs

1use std::sync::Arc;
2
3use steel_utils::types::Difficulty;
4
5use crate::entity::LivingEntity;
6use crate::world::World;
7
8const MIN_VISIBILITY_DISTANCE_FOR_INVISIBLE_TARGET: f64 = 2.0;
9
10pub(crate) type TargetingSelector = Arc<dyn Fn(&dyn LivingEntity, &World) -> bool + Send + Sync>;
11
12#[derive(Clone)]
13pub(crate) struct TargetingConditions {
14    is_combat: bool,
15    range: f64,
16    check_line_of_sight: bool,
17    test_invisible: bool,
18    selector: Option<TargetingSelector>,
19}
20
21impl TargetingConditions {
22    #[must_use]
23    pub(crate) const fn for_combat() -> Self {
24        Self::new(true)
25    }
26
27    #[must_use]
28    pub(crate) const fn for_non_combat() -> Self {
29        Self::new(false)
30    }
31
32    const fn new(is_combat: bool) -> Self {
33        Self {
34            is_combat,
35            range: -1.0,
36            check_line_of_sight: true,
37            test_invisible: true,
38            selector: None,
39        }
40    }
41
42    #[must_use]
43    pub(crate) const fn range(mut self, range: f64) -> Self {
44        self.range = range;
45        self
46    }
47
48    #[must_use]
49    pub(crate) const fn ignore_line_of_sight(mut self) -> Self {
50        self.check_line_of_sight = false;
51        self
52    }
53
54    #[must_use]
55    pub(crate) const fn ignore_invisibility_testing(mut self) -> Self {
56        self.test_invisible = false;
57        self
58    }
59
60    #[must_use]
61    pub(crate) fn selector(
62        mut self,
63        selector: impl Fn(&dyn LivingEntity, &World) -> bool + Send + Sync + 'static,
64    ) -> Self {
65        self.selector = Some(Arc::new(selector));
66        self
67    }
68
69    #[must_use]
70    pub(crate) fn test(
71        &self,
72        world: &World,
73        targeter: Option<&dyn LivingEntity>,
74        target: &dyn LivingEntity,
75    ) -> bool {
76        if targeter.is_some_and(|targeter| targeter.uuid() == target.uuid()) {
77            return false;
78        }
79        if !target.can_be_seen_by_anyone() {
80            return false;
81        }
82        if let Some(selector) = &self.selector
83            && !selector(target, world)
84        {
85            return false;
86        }
87
88        let Some(targeter) = targeter else {
89            return !self.is_combat
90                || target.can_be_seen_as_enemy() && world.difficulty() != Difficulty::Peaceful;
91        };
92
93        if self.is_combat && (!targeter.can_attack(target) || targeter.is_allied_to(target)) {
94            return false;
95        }
96
97        if self.range > 0.0 {
98            let modifier = if self.test_invisible {
99                target.get_visibility_percent(Some(targeter))
100            } else {
101                1.0
102            };
103            let visibility_distance =
104                (self.range * modifier).max(MIN_VISIBILITY_DISTANCE_FOR_INVISIBLE_TARGET);
105            if targeter.position().distance_squared(target.position())
106                > visibility_distance * visibility_distance
107            {
108                return false;
109            }
110        }
111
112        if self.check_line_of_sight
113            && let Some(mob) = targeter.as_mob()
114            && !mob.has_line_of_sight_cached(target)
115        {
116            return false;
117        }
118
119        true
120    }
121}
122
123impl Default for TargetingConditions {
124    fn default() -> Self {
125        Self::for_combat()
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use std::sync::{Arc, Weak};
132
133    use glam::DVec3;
134    use steel_registry::entity_type::EntityTypeRef;
135    use steel_registry::{init_vanilla_registry, vanilla_blocks, vanilla_entities};
136    use steel_utils::locks::SyncMutex;
137    use steel_utils::types::UpdateFlags;
138    use steel_utils::{BlockPos, ChunkPos};
139
140    use super::*;
141    use crate::behavior::init_behaviors;
142    use crate::entity::{Entity, EntityBase, LivingEntityBase, Mob, MobBase};
143    use crate::test_support::{fresh_test_world, insert_ready_full_chunk};
144    use crate::world::World;
145
146    struct HoveringTestMob {
147        base: EntityBase,
148        living_base: LivingEntityBase,
149        mob_base: MobBase,
150        mob_flags: SyncMutex<i8>,
151        health: SyncMutex<f32>,
152    }
153
154    impl HoveringTestMob {
155        fn new(id: i32, position: DVec3, world: Weak<World>) -> Self {
156            init_vanilla_registry();
157            Self {
158                base: EntityBase::new(id, position, vanilla_entities::PIG.dimensions, world),
159                living_base: LivingEntityBase::new(&vanilla_entities::PIG),
160                mob_base: MobBase::new(),
161                mob_flags: SyncMutex::new(0),
162                health: SyncMutex::new(10.0),
163            }
164        }
165    }
166
167    crate::entity::impl_test_downcast_type!(HoveringTestMob);
168
169    impl Entity for HoveringTestMob {
170        fn base(&self) -> &EntityBase {
171            &self.base
172        }
173
174        fn entity_type(&self) -> EntityTypeRef {
175            &vanilla_entities::PIG
176        }
177    }
178
179    impl LivingEntity for HoveringTestMob {
180        fn living_base(&self) -> &LivingEntityBase {
181            &self.living_base
182        }
183
184        fn get_health(&self) -> f32 {
185            *self.health.lock()
186        }
187
188        fn set_health(&self, health: f32) {
189            *self.health.lock() = health;
190        }
191    }
192
193    impl Mob for HoveringTestMob {
194        fn mob_base(&self) -> &MobBase {
195            &self.mob_base
196        }
197
198        fn mob_flags(&self) -> i8 {
199            *self.mob_flags.lock()
200        }
201
202        fn set_mob_flags(&self, flags: i8) {
203            *self.mob_flags.lock() = flags;
204        }
205    }
206
207    #[test]
208    fn sight_check_applies_to_a_mob_that_does_not_pathfind() {
209        init_vanilla_registry();
210        init_behaviors();
211        let world = fresh_test_world("targeting_sight_check_scope");
212        let wall = BlockPos::new(2, 64, 0);
213        insert_ready_full_chunk(&world, ChunkPos::from_block_pos(wall));
214        let targeter = HoveringTestMob::new(1, DVec3::new(0.5, 64.0, 0.5), Arc::downgrade(&world));
215        let target = HoveringTestMob::new(2, DVec3::new(4.5, 64.0, 0.5), Arc::downgrade(&world));
216
217        assert!(
218            targeter.as_pathfinder_mob().is_none(),
219            "the targeter must not pathfind or this test proves nothing"
220        );
221
222        assert!(
223            TargetingConditions::for_non_combat().test(world.as_ref(), Some(&targeter), &target),
224            "a mob with a clear view should pick its target"
225        );
226
227        assert!(world.set_block(
228            wall,
229            vanilla_blocks::STONE.default_state(),
230            UpdateFlags::UPDATE_ALL,
231        ));
232        targeter.mob_base().sensing().lock().tick();
233        assert!(
234            !TargetingConditions::for_non_combat().test(world.as_ref(), Some(&targeter), &target),
235            "a mob that cannot see its target should not pick it, whether or not it pathfinds"
236        );
237        assert!(
238            TargetingConditions::for_non_combat()
239                .ignore_line_of_sight()
240                .test(world.as_ref(), Some(&targeter), &target),
241            "the same target should be picked once sight is not required"
242        );
243    }
244}