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::{LivingEntity, PathfinderMob, SharedEntity};
8
9const ATTACK_INTERVAL_TICKS: i32 = 20;
10const COOLDOWN_BETWEEN_CAN_USE_CHECKS: i64 = 20;
11const PATH_RECALC_BASE_TICKS: i32 = 4;
12const PATH_RECALC_RANDOM_TICKS: i32 = 7;
13const PATH_RECALC_LONG_DISTANCE_SQR: f64 = 1024.0;
14const PATH_RECALC_MEDIUM_DISTANCE_SQR: f64 = 256.0;
15const PATH_RECALC_LONG_DISTANCE_PENALTY: i32 = 10;
16const PATH_RECALC_MEDIUM_DISTANCE_PENALTY: i32 = 5;
17const PATH_RECALC_FAILED_MOVE_PENALTY: i32 = 15;
18const PATHED_TARGET_RECALC_DISTANCE_SQR: f64 = 1.0;
19const RANDOM_PATH_RECALC_CHANCE: f32 = 0.05;
20
21pub(crate) struct MeleeAttackGoal {
22    speed_modifier: f64,
23    following_target_even_if_not_seen: bool,
24    path: Option<Path>,
25    pathed_target: DVec3,
26    ticks_until_next_path_recalculation: i32,
27    ticks_until_next_attack: i32,
28    last_can_use_check: i64,
29}
30
31impl MeleeAttackGoal {
32    #[must_use]
33    pub(crate) const fn new(speed_modifier: f64, following_target_even_if_not_seen: bool) -> Self {
34        Self {
35            speed_modifier,
36            following_target_even_if_not_seen,
37            path: None,
38            pathed_target: DVec3::ZERO,
39            ticks_until_next_path_recalculation: 0,
40            ticks_until_next_attack: 0,
41            last_can_use_check: 0,
42        }
43    }
44
45    fn check_and_perform_attack(&mut self, mob: &dyn PathfinderMob, target: &SharedEntity) {
46        let Some(target_living) = target.as_living_entity() else {
47            return;
48        };
49        if !self.can_perform_attack(mob, target_living) {
50            return;
51        }
52
53        self.reset_attack_cooldown();
54        mob.swing(InteractionHand::MainHand, false);
55        if let Some(world) = mob.level() {
56            let _ = mob.do_hurt_target(&world, target);
57        }
58    }
59
60    const fn reset_attack_cooldown(&mut self) {
61        self.ticks_until_next_attack = Self::attack_interval();
62    }
63
64    const fn is_time_to_attack(&self) -> bool {
65        self.ticks_until_next_attack <= 0
66    }
67
68    fn can_perform_attack(&self, mob: &dyn PathfinderMob, target: &dyn LivingEntity) -> bool {
69        self.is_time_to_attack()
70            && mob.is_within_melee_attack_range(target)
71            && mob.has_line_of_sight_cached(target)
72    }
73
74    pub(crate) const fn get_ticks_until_next_attack(&self) -> i32 {
75        self.ticks_until_next_attack
76    }
77
78    const fn attack_interval() -> i32 {
79        reduced_tick_delay(ATTACK_INTERVAL_TICKS)
80    }
81
82    fn has_no_pathed_target(&self) -> bool {
83        self.pathed_target.x == 0.0 && self.pathed_target.y == 0.0 && self.pathed_target.z == 0.0
84    }
85
86    fn should_recalculate_path(&mut self, mob: &dyn PathfinderMob, target: &SharedEntity) -> bool {
87        let Some(target_living) = target.as_living_entity() else {
88            return false;
89        };
90        if !self.following_target_even_if_not_seen && !mob.has_line_of_sight_cached(target_living) {
91            return false;
92        }
93        if self.ticks_until_next_path_recalculation > 0 {
94            return false;
95        }
96        if self.has_no_pathed_target() {
97            return true;
98        }
99        if target.position().distance_squared(self.pathed_target)
100            >= PATHED_TARGET_RECALC_DISTANCE_SQR
101        {
102            return true;
103        }
104
105        rand::random::<f32>() < RANDOM_PATH_RECALC_CHANCE
106    }
107
108    fn recalculate_path(&mut self, mob: &dyn PathfinderMob, target: &SharedEntity) {
109        self.pathed_target = target.position();
110        let random_delay = rand::random_range(0..PATH_RECALC_RANDOM_TICKS);
111        self.ticks_until_next_path_recalculation = PATH_RECALC_BASE_TICKS + random_delay;
112
113        let target_distance_sqr = mob.position().distance_squared(target.position());
114        if target_distance_sqr > PATH_RECALC_LONG_DISTANCE_SQR {
115            self.ticks_until_next_path_recalculation += PATH_RECALC_LONG_DISTANCE_PENALTY;
116        } else if target_distance_sqr > PATH_RECALC_MEDIUM_DISTANCE_SQR {
117            self.ticks_until_next_path_recalculation += PATH_RECALC_MEDIUM_DISTANCE_PENALTY;
118        }
119
120        if !mob.move_to_pos(target.position(), self.speed_modifier) {
121            self.ticks_until_next_path_recalculation += PATH_RECALC_FAILED_MOVE_PENALTY;
122        }
123
124        self.ticks_until_next_path_recalculation =
125            reduced_tick_delay(self.ticks_until_next_path_recalculation);
126    }
127}
128
129impl Goal for MeleeAttackGoal {
130    fn controls(&self) -> GoalControls {
131        GoalControls::MOVE | GoalControls::LOOK
132    }
133
134    fn can_use(&mut self, mob: &dyn PathfinderMob) -> bool {
135        let Some(world) = mob.level() else {
136            return false;
137        };
138        let game_time = world.game_time();
139        if game_time - self.last_can_use_check < COOLDOWN_BETWEEN_CAN_USE_CHECKS {
140            return false;
141        }
142
143        self.last_can_use_check = game_time;
144        let Some(target) = mob.target() else {
145            return false;
146        };
147        let Some(target_living) = target.as_living_entity() else {
148            return false;
149        };
150        if !LivingEntity::is_alive(target_living) {
151            return false;
152        }
153
154        self.path = mob.create_path_to(target.block_position(), 0);
155        self.path.is_some() || mob.is_within_melee_attack_range(target_living)
156    }
157
158    fn can_continue_to_use(&mut self, mob: &dyn PathfinderMob) -> bool {
159        let Some(target) = mob.target() else {
160            return false;
161        };
162        if !target.is_alive() {
163            return false;
164        }
165        if !self.following_target_even_if_not_seen {
166            return !mob.mob_base().navigation().lock().is_done();
167        }
168        if !mob.is_within_home_pos(target.block_position()) {
169            return false;
170        }
171
172        is_no_creative_or_spectator(&target)
173    }
174
175    fn start(&mut self, mob: &dyn PathfinderMob) {
176        if let Some(path) = self.path.take() {
177            mob.move_to_path(Some(path), self.speed_modifier);
178        } else {
179            mob.mob_base().navigation().lock().stop();
180        }
181        mob.set_aggressive(true);
182        self.ticks_until_next_path_recalculation = 0;
183        self.ticks_until_next_attack = 0;
184    }
185
186    fn stop(&mut self, mob: &dyn PathfinderMob) {
187        if mob
188            .target()
189            .as_ref()
190            .is_some_and(|target| !is_no_creative_or_spectator(target))
191        {
192            mob.set_target(None);
193        }
194
195        mob.set_aggressive(false);
196        mob.mob_base().navigation().lock().stop();
197    }
198
199    fn requires_update_every_tick(&self) -> bool {
200        true
201    }
202
203    fn tick(&mut self, mob: &dyn PathfinderMob) {
204        let Some(target) = mob.target() else {
205            return;
206        };
207
208        let target_position = target.position();
209        mob.mob_base().controls().lock().look_control.set_look_at(
210            DVec3::new(target_position.x, target.get_eye_y(), target_position.z),
211            30.0,
212            30.0,
213        );
214
215        self.ticks_until_next_path_recalculation =
216            (self.ticks_until_next_path_recalculation - 1).max(0);
217        if self.should_recalculate_path(mob, &target) {
218            self.recalculate_path(mob, &target);
219        }
220
221        self.ticks_until_next_attack = (self.ticks_until_next_attack - 1).max(0);
222        self.check_and_perform_attack(mob, &target);
223    }
224}
225
226fn is_no_creative_or_spectator(entity: &SharedEntity) -> bool {
227    !entity
228        .as_player()
229        .is_some_and(|player| entity.is_spectator() || player.has_infinite_materials())
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 = pig(1, DVec3::ZERO);
324        let target = shared_pig(2, DVec3::new(4.0, 0.0, 0.0));
325        assert!(mob.set_target(Some(&target)));
326
327        goal.tick(&mob);
328
329        assert_eq!(goal.pathed_target, target.position());
330        assert!(goal.ticks_until_next_path_recalculation > 0);
331        assert!(mob.mob_base().navigation().lock().is_done());
332    }
333}