Skip to main content

steel_core/entity/ai/goal/
melee_attack.rs

1use glam::DVec3;
2use steel_utils::types::InteractionHand;
3
4use super::reduced_tick_delay;
5use super::selector::{Goal, GoalControls};
6use crate::entity::ai::path::Path;
7use crate::entity::entity_selector;
8use crate::entity::{LivingEntity, PathfinderMob, SharedEntity};
9
10const ATTACK_INTERVAL_TICKS: i32 = 20;
11const COOLDOWN_BETWEEN_CAN_USE_CHECKS: i64 = 20;
12const PATH_RECALC_BASE_TICKS: i32 = 4;
13const PATH_RECALC_RANDOM_TICKS: i32 = 7;
14const PATH_RECALC_LONG_DISTANCE_SQR: f64 = 1024.0;
15const PATH_RECALC_MEDIUM_DISTANCE_SQR: f64 = 256.0;
16const PATH_RECALC_LONG_DISTANCE_PENALTY: i32 = 10;
17const PATH_RECALC_MEDIUM_DISTANCE_PENALTY: i32 = 5;
18const PATH_RECALC_FAILED_MOVE_PENALTY: i32 = 15;
19const PATHED_TARGET_RECALC_DISTANCE_SQR: f64 = 1.0;
20const RANDOM_PATH_RECALC_CHANCE: f32 = 0.05;
21
22pub(crate) struct MeleeAttackGoal {
23    speed_modifier: f64,
24    following_target_even_if_not_seen: bool,
25    path: Option<Path>,
26    pathed_target: DVec3,
27    ticks_until_next_path_recalculation: i32,
28    ticks_until_next_attack: i32,
29    last_can_use_check: i64,
30}
31
32impl MeleeAttackGoal {
33    #[must_use]
34    pub(crate) const fn new(speed_modifier: f64, following_target_even_if_not_seen: bool) -> Self {
35        Self {
36            speed_modifier,
37            following_target_even_if_not_seen,
38            path: None,
39            pathed_target: DVec3::ZERO,
40            ticks_until_next_path_recalculation: 0,
41            ticks_until_next_attack: 0,
42            last_can_use_check: 0,
43        }
44    }
45
46    fn check_and_perform_attack(
47        &mut self,
48        mob: &dyn PathfinderMob,
49        entity: &SharedEntity,
50        target: &SharedEntity,
51    ) {
52        let Some(target_living) = target.as_living_entity() else {
53            return;
54        };
55        if !self.can_perform_attack(mob, target_living) {
56            return;
57        }
58
59        self.reset_attack_cooldown();
60        mob.swing_for_attack(InteractionHand::MainHand);
61        if let Some(world) = mob.level() {
62            let _ = mob.do_hurt_target(entity, &world, target);
63        }
64    }
65
66    const fn reset_attack_cooldown(&mut self) {
67        self.ticks_until_next_attack = Self::attack_interval();
68    }
69
70    const fn is_time_to_attack(&self) -> bool {
71        self.ticks_until_next_attack <= 0
72    }
73
74    fn can_perform_attack(&self, mob: &dyn PathfinderMob, target: &dyn LivingEntity) -> bool {
75        self.is_time_to_attack()
76            && mob.is_within_melee_attack_range(target)
77            && mob.has_line_of_sight_cached(target)
78    }
79
80    pub(crate) const fn get_ticks_until_next_attack(&self) -> i32 {
81        self.ticks_until_next_attack
82    }
83
84    const fn attack_interval() -> i32 {
85        reduced_tick_delay(ATTACK_INTERVAL_TICKS)
86    }
87
88    fn has_no_pathed_target(&self) -> bool {
89        self.pathed_target.x == 0.0 && self.pathed_target.y == 0.0 && self.pathed_target.z == 0.0
90    }
91
92    fn should_recalculate_path(&mut self, mob: &dyn PathfinderMob, target: &SharedEntity) -> bool {
93        let Some(target_living) = target.as_living_entity() else {
94            return false;
95        };
96        if !self.following_target_even_if_not_seen && !mob.has_line_of_sight_cached(target_living) {
97            return false;
98        }
99        if self.ticks_until_next_path_recalculation > 0 {
100            return false;
101        }
102        if self.has_no_pathed_target() {
103            return true;
104        }
105        if target.position().distance_squared(self.pathed_target)
106            >= PATHED_TARGET_RECALC_DISTANCE_SQR
107        {
108            return true;
109        }
110
111        rand::random::<f32>() < RANDOM_PATH_RECALC_CHANCE
112    }
113
114    fn recalculate_path(&mut self, mob: &dyn PathfinderMob, target: &SharedEntity) {
115        self.pathed_target = target.position();
116        let random_delay = rand::random_range(0..PATH_RECALC_RANDOM_TICKS);
117        self.ticks_until_next_path_recalculation = PATH_RECALC_BASE_TICKS + random_delay;
118
119        let target_distance_sqr = mob.position().distance_squared(target.position());
120        if target_distance_sqr > PATH_RECALC_LONG_DISTANCE_SQR {
121            self.ticks_until_next_path_recalculation += PATH_RECALC_LONG_DISTANCE_PENALTY;
122        } else if target_distance_sqr > PATH_RECALC_MEDIUM_DISTANCE_SQR {
123            self.ticks_until_next_path_recalculation += PATH_RECALC_MEDIUM_DISTANCE_PENALTY;
124        }
125
126        if !mob.move_to_pos(target.position(), self.speed_modifier) {
127            self.ticks_until_next_path_recalculation += PATH_RECALC_FAILED_MOVE_PENALTY;
128        }
129
130        self.ticks_until_next_path_recalculation =
131            reduced_tick_delay(self.ticks_until_next_path_recalculation);
132    }
133}
134
135impl Goal for MeleeAttackGoal {
136    fn controls(&self) -> GoalControls {
137        GoalControls::MOVE | GoalControls::LOOK
138    }
139
140    fn can_use(&mut self, mob: &dyn PathfinderMob) -> bool {
141        let Some(world) = mob.level() else {
142            return false;
143        };
144        let game_time = world.game_time();
145        if game_time - self.last_can_use_check < COOLDOWN_BETWEEN_CAN_USE_CHECKS {
146            return false;
147        }
148
149        self.last_can_use_check = game_time;
150        let Some(target) = mob.target() else {
151            return false;
152        };
153        let Some(target_living) = target.as_living_entity() else {
154            return false;
155        };
156        if !LivingEntity::is_alive(target_living) {
157            return false;
158        }
159
160        self.path = mob.create_path_to(target.block_position(), 0);
161        self.path.is_some() || mob.is_within_melee_attack_range(target_living)
162    }
163
164    fn can_continue_to_use(&mut self, mob: &dyn PathfinderMob) -> bool {
165        let Some(target) = mob.target() else {
166            return false;
167        };
168        if !target.is_alive() {
169            return false;
170        }
171        if !self.following_target_even_if_not_seen {
172            return !mob.mob_base().navigation().lock().is_done();
173        }
174        if !mob.is_within_home_pos(target.block_position()) {
175            return false;
176        }
177
178        entity_selector::no_creative_or_spectator(target.as_ref())
179    }
180
181    fn start(&mut self, mob: &dyn PathfinderMob) {
182        if let Some(path) = self.path.take() {
183            mob.move_to_path(Some(path), self.speed_modifier);
184        } else {
185            mob.mob_base().navigation().lock().stop();
186        }
187        mob.set_aggressive(true);
188        self.ticks_until_next_path_recalculation = 0;
189        self.ticks_until_next_attack = 0;
190    }
191
192    fn stop(&mut self, mob: &dyn PathfinderMob) {
193        if mob
194            .target()
195            .as_ref()
196            .is_some_and(|target| !entity_selector::no_creative_or_spectator(target.as_ref()))
197        {
198            mob.set_target(None);
199        }
200
201        mob.set_aggressive(false);
202        mob.mob_base().navigation().lock().stop();
203    }
204
205    fn requires_update_every_tick(&self) -> bool {
206        true
207    }
208
209    fn tick(&mut self, mob: &dyn PathfinderMob, entity: &SharedEntity) {
210        let Some(target) = mob.target() else {
211            return;
212        };
213
214        let target_position = target.position();
215        mob.mob_base().controls().lock().look_control.set_look_at(
216            DVec3::new(target_position.x, target.get_eye_y(), target_position.z),
217            30.0,
218            30.0,
219        );
220
221        self.ticks_until_next_path_recalculation =
222            (self.ticks_until_next_path_recalculation - 1).max(0);
223        if self.should_recalculate_path(mob, &target) {
224            self.recalculate_path(mob, &target);
225        }
226
227        self.ticks_until_next_attack = (self.ticks_until_next_attack - 1).max(0);
228        self.check_and_perform_attack(mob, entity, &target);
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use std::sync::{Arc, Weak};
235
236    use glam::DVec3;
237    use steel_registry::{init_vanilla_registry, vanilla_entities};
238
239    use super::*;
240    use crate::entity::ai::goal::selector::Goal;
241    use crate::entity::{Mob, entities::PigEntity};
242
243    fn pig(id: i32, position: DVec3) -> PigEntity {
244        PigEntity::new(&vanilla_entities::PIG, id, position, Weak::new())
245    }
246
247    fn shared_pig(id: i32, position: DVec3) -> SharedEntity {
248        Arc::new(pig(id, position))
249    }
250
251    #[test]
252    fn melee_attack_goal_uses_move_and_look_controls() {
253        let goal = MeleeAttackGoal::new(1.0, true);
254
255        assert_eq!(goal.controls(), GoalControls::MOVE | GoalControls::LOOK);
256        assert!(goal.requires_update_every_tick());
257    }
258
259    #[test]
260    fn melee_attack_goal_requires_world_to_start() {
261        init_vanilla_registry();
262        let mut goal = MeleeAttackGoal::new(1.0, true);
263        let mob = pig(1, DVec3::ZERO);
264        let target = shared_pig(2, DVec3::new(1.0, 0.0, 0.0));
265        assert!(mob.set_target(Some(&target)));
266
267        assert!(!goal.can_use(&mob));
268    }
269
270    #[test]
271    fn melee_attack_goal_start_without_path_still_sets_aggressive() {
272        init_vanilla_registry();
273        let mut goal = MeleeAttackGoal::new(1.0, true);
274        let mob = pig(1, DVec3::ZERO);
275
276        goal.start(&mob);
277
278        assert!(mob.is_aggressive());
279        assert!(mob.mob_base().navigation().lock().is_done());
280        assert_eq!(goal.ticks_until_next_path_recalculation, 0);
281        assert_eq!(goal.get_ticks_until_next_attack(), 0);
282    }
283
284    #[test]
285    fn melee_attack_goal_stop_clears_aggression_and_navigation() {
286        init_vanilla_registry();
287        let mut goal = MeleeAttackGoal::new(1.0, true);
288        let mob = pig(1, DVec3::ZERO);
289        mob.set_aggressive(true);
290        mob.mob_base()
291            .navigation()
292            .lock()
293            .set_direct_target(DVec3::new(4.0, 0.0, 0.0), 1.0);
294
295        goal.stop(&mob);
296
297        assert!(!mob.is_aggressive());
298        assert!(mob.mob_base().navigation().lock().is_done());
299    }
300
301    #[test]
302    fn melee_attack_goal_without_unseen_following_requires_active_navigation() {
303        init_vanilla_registry();
304        let mut goal = MeleeAttackGoal::new(1.0, false);
305        let mob = pig(1, DVec3::ZERO);
306        let target = shared_pig(2, DVec3::new(4.0, 0.0, 0.0));
307        assert!(mob.set_target(Some(&target)));
308
309        assert!(!goal.can_continue_to_use(&mob));
310
311        mob.mob_base()
312            .navigation()
313            .lock()
314            .set_direct_target(DVec3::new(4.0, 0.0, 0.0), 1.0);
315
316        assert!(goal.can_continue_to_use(&mob));
317    }
318
319    #[test]
320    fn melee_attack_goal_tick_recalculates_path_with_failed_move_penalty() {
321        init_vanilla_registry();
322        let mut goal = MeleeAttackGoal::new(1.0, true);
323        let mob = Arc::new(pig(1, DVec3::ZERO));
324        let mob_entity: SharedEntity = Arc::<PigEntity>::clone(&mob);
325        let target = shared_pig(2, DVec3::new(4.0, 0.0, 0.0));
326        assert!(mob.set_target(Some(&target)));
327
328        goal.tick(mob.as_ref(), &mob_entity);
329
330        assert_eq!(goal.pathed_target, target.position());
331        assert!(goal.ticks_until_next_path_recalculation > 0);
332        assert!(mob.mob_base().navigation().lock().is_done());
333    }
334}