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::{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}