steel_core/entity/ai/
targeting.rs1use 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}