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