1use crate::entity::entities::{ExperienceOrbEntity, ItemEntity};
2use crate::entity::projectile::triangle_random;
3use crate::entity::{
4 Entity, EntityBase, EntityBaseLoad, EntityMovementEmission, EntitySyncedData, LivingEntity,
5 Projectile, ProjectileBase, RemovalReason, SharedEntity, ThrowableProjectile, entity_loot_ref,
6 next_entity_id,
7};
8use crate::fluid::get_height;
9use crate::physics::MoverType;
10use crate::player::Player;
11use crate::world::{LevelReader, World};
12use glam::DVec3;
13use rand::{RngExt, rng};
14use std::cmp::PartialEq;
15use std::f32::consts::PI;
16use std::ops::Add;
17use std::sync::{Arc, Weak};
18use steel_macros::entity_behavior;
19use steel_math::{DEG_TO_RAD, DEGREE_360, trig};
20use steel_registry::blocks::block_state_ext::BlockStateExt;
21use steel_registry::entity_type::EntityTypeRef;
22use steel_registry::fluid::FluidStateExt;
23use steel_registry::item_stack::ItemStack;
24use steel_registry::loot_table::LootContext;
25use steel_registry::particle_type::ParticleData;
26use steel_registry::vanilla_entity_data::FishingBobberEntityData;
27use steel_registry::vanilla_item_tags::ItemTag;
28use steel_registry::vanilla_particle_types::{BUBBLE, FISHING, SPLASH};
29use steel_registry::{
30 sound_events, vanilla_blocks, vanilla_custom_stats, vanilla_entities, vanilla_items,
31 vanilla_loot_tables,
32};
33use steel_utils::entity_events::EntityStatus;
34use steel_utils::locks::SyncMutex;
35use steel_utils::random::Random;
36use steel_utils::random::legacy_random::LegacyRandom;
37use steel_utils::types::InteractionHand;
38use steel_utils::{BlockPos, Downcast, DowncastType, DowncastTypeKey};
39
40pub const MAX_OUT_OF_WATER_TIME: i32 = 10;
41const MAX_DISTANCE_SQR: f64 = 32.0 * 32.0;
42
43const DMG_DEFAULT: i32 = 5;
44const DMG_ITEM_ENTITY: i32 = 3;
45const DMG_ON_GROUND: i32 = 2;
46const DMG_CAUGHT: i32 = 1;
47
48const ONE_SECOND: i32 = 20;
49const TWO_SECONDS: i32 = 40;
50const THREE_SECONDS: i32 = 60;
51const FOUR_SECONDS: i32 = 80;
52const FIVE_SECONDS: i32 = 100;
53const THIRTY_SECONDS: i32 = 600;
54const ONE_MINUTE: i32 = 1200;
55
56#[entity_behavior(class = "FishingHook")]
58pub struct FishingHookEntity {
59 base: EntityBase,
60 entity_type: EntityTypeRef,
61 entity_data: SyncMutex<FishingBobberEntityData>,
62 projectile_base: ProjectileBase,
63 hook_state: SyncMutex<FishingHookState>,
64 synchronized_random: SyncMutex<LegacyRandom>,
65}
66
67pub struct FishingHookState {
69 out_of_water_time: i32,
70 life: i32,
71 nibble: i32,
72 time_until_lured: i32,
73 time_until_hooked: i32,
74 fish_angle: f32,
75 open_water: bool,
76 bobber_state: BobberState,
78 hooked_entity: Option<SharedEntity>,
79 luck: i32,
80 lure_speed: i32,
81}
82
83impl FishingHookState {
84 #[must_use]
85 pub fn new(lure_speed: i32, luck: i32) -> Self {
87 Self {
88 out_of_water_time: 0,
89 life: 0,
90 nibble: 0,
91 time_until_lured: 0,
92 time_until_hooked: 0,
93 fish_angle: 0.0,
94 open_water: false,
95 bobber_state: BobberState::Flying,
96 hooked_entity: None,
97 luck: luck.max(0),
98 lure_speed: lure_speed.max(0),
99 }
100 }
101}
102
103unsafe impl DowncastType for FishingHookEntity {
105 const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:entity/fishing_hook");
106}
107
108impl FishingHookEntity {
109 #[must_use]
112 pub fn new(entity_type: EntityTypeRef, id: i32, position: DVec3, world: Weak<World>) -> Self {
113 Self {
114 base: EntityBase::new(id, position, entity_type.dimensions, world),
115 entity_type,
116 entity_data: SyncMutex::new(FishingBobberEntityData::new()),
117 projectile_base: ProjectileBase::new(),
118 hook_state: SyncMutex::new(FishingHookState::new(0, 0)),
119 synchronized_random: SyncMutex::new(LegacyRandom::from_seed(0)),
120 }
121 }
122
123 pub fn shoot_from_player(self: &Arc<Self>, player: &Arc<Player>, luck: i32, lure_speed: i32) {
125 const MAGIC_OFFSET: f64 = 0.010_336_5;
126
127 {
128 let mut state = self.hook_state.lock();
129 state.luck = luck.max(0);
130 state.lure_speed = lure_speed.max(0);
131 }
132
133 let (yaw, pitch) = player.rotation();
134 let player_shared: SharedEntity = Arc::<Player>::clone(player);
135
136 self.set_owner(&player_shared);
137
138 let y_cos = trig::cos(-yaw * DEG_TO_RAD - PI);
139 let y_sin = trig::sin(-yaw * DEG_TO_RAD - PI);
140 let x_cos = -trig::cos(-pitch * DEG_TO_RAD);
141 let x_sin = trig::sin(-pitch * DEG_TO_RAD);
142
143 let x = player_shared.position().x - f64::from(y_sin) * 0.3;
144 let y = player_shared.get_eye_y();
145 let z = player_shared.position().z - f64::from(y_cos) * 0.3;
146
147 self.snap_to(DVec3::new(x, y, z), yaw, pitch);
148
149 let clamped_y = f64::from((-(x_sin / x_cos)).clamp(-5.0, 5.0));
150
151 let mut new_movement = DVec3::new(-f64::from(y_sin), clamped_y, -f64::from(y_cos));
152
153 let distance = new_movement.length();
154
155 let random_x = triangle_random(0.5, MAGIC_OFFSET);
156 let random_y = triangle_random(0.5, MAGIC_OFFSET);
157 let random_z = triangle_random(0.5, MAGIC_OFFSET);
158
159 let factor_x = 0.6 / distance + random_x;
160 let factor_y = 0.6 / distance + random_y;
161 let factor_z = 0.6 / distance + random_z;
162
163 new_movement *= DVec3::new(factor_x, factor_y, factor_z);
164
165 self.set_velocity(new_movement);
166
167 let yaw_new = new_movement.x.atan2(new_movement.z).to_degrees() as f32;
168
169 let horizontal_distance =
170 (new_movement.x * new_movement.x + new_movement.z * new_movement.z).sqrt();
171
172 let pitch_new = new_movement.y.atan2(horizontal_distance).to_degrees() as f32;
173
174 self.set_rotation((yaw_new, pitch_new));
175 self.base().set_old_rotation_to_current();
176 }
177
178 #[must_use]
180 pub fn from_saved(entity_type: EntityTypeRef, load: EntityBaseLoad) -> Self {
181 Self {
182 base: EntityBase::from_load(load, entity_type.dimensions),
183 entity_type,
184 entity_data: SyncMutex::new(FishingBobberEntityData::new()),
185 projectile_base: ProjectileBase::new(),
186 hook_state: SyncMutex::new(FishingHookState::new(0, 0)),
188 synchronized_random: SyncMutex::new(LegacyRandom::from_seed(0)),
189 }
190 }
191
192 pub(crate) fn set_owner(self: &Arc<Self>, owner: &SharedEntity) {
194 self.set_owner_entity(Some(owner));
195 if let Some(player) = owner.as_player() {
196 player.set_fishing_hook(self);
197 }
198 self.update_owner_info(self.into());
199 }
200
201 fn should_stop_fishing(
203 &self,
204 owner: &Player,
205 mainhand_item: &ItemStack,
206 offhand_item: &ItemStack,
207 ) -> bool {
208 if !owner.can_interact_with_level() {
209 self.set_removed(RemovalReason::Discarded);
210 return true;
211 }
212
213 let mainhand_fishing = mainhand_item.is(&vanilla_items::FISHING_ROD);
214 let offhand_fishing = offhand_item.is(&vanilla_items::FISHING_ROD);
215
216 if (mainhand_fishing || offhand_fishing)
217 && self.distance_to_sqr(owner.position()) <= MAX_DISTANCE_SQR
218 {
219 return false;
220 }
221
222 self.set_removed(RemovalReason::Discarded);
223 true
224 }
225
226 fn check_collision(self: &Arc<Self>) {
228 if let Some(hit_result) = self.get_hit_result_on_move_vector() {
229 Arc::clone(self).hit_target_or_deflect_self(&hit_result);
230 }
231 }
232
233 fn set_hooked_entity(&self, hooked: Option<SharedEntity>) {
235 let hooked_entity_id = hooked.as_ref().map_or(0, |entity| {
236 let id = entity.base().id();
237 id + 1
238 });
239
240 {
241 let mut hook_state = self.hook_state.lock();
242 hook_state.hooked_entity = hooked;
243 }
244
245 let mut entity_data = self.entity_data.lock();
246
247 entity_data.fishing_hook.hooked_entity.set(hooked_entity_id);
248 }
249
250 #[expect(
252 clippy::too_many_lines,
253 clippy::similar_names,
254 reason = "Logic that belongs together is being kept together + X and Z movement components intentionally have similar names"
255 )]
256 fn catching_fish(&self, pos: BlockPos, state: &mut FishingHookState) {
257 const RAINING_BONUS_PROBABILITY: f64 = 0.25;
258 const SKY_OBSTRUCTION_NERF_PROBABILITY: f64 = 0.5;
259
260 let mut fishing_speed = 1;
261 let above = pos.above();
262
263 let Some(world) = self.level() else {
264 return;
265 };
266
267 if rng().random::<f64>() < RAINING_BONUS_PROBABILITY && world.is_raining_at(above) {
268 fishing_speed += 1;
269 }
270
271 if rng().random::<f64>() < SKY_OBSTRUCTION_NERF_PROBABILITY && !world.can_see_sky(above) {
272 fishing_speed -= 1;
273 }
274
275 if state.nibble > 0 {
276 state.nibble -= 1;
277
278 if state.nibble <= 0 {
279 state.time_until_lured = 0;
280 state.time_until_hooked = 0;
281 self.entity_data.lock().fishing_hook_mut().biting.set(false);
282 }
283 } else if state.time_until_hooked > 0 {
284 state.time_until_hooked -= fishing_speed;
285
286 if state.time_until_hooked > 0 {
287 state.fish_angle += triangle_random(0.0, 9.188) as f32;
288
289 let angle = state.fish_angle * DEG_TO_RAD;
290 let angle_sin = trig::sin(angle);
291 let angle_cos = trig::cos(angle);
292
293 let fish_x = self.position().x
294 + f64::from(angle_sin) * f64::from(state.time_until_hooked) * 0.1;
295 let fish_y = self.position().y.floor() + 1.0;
296 let fish_z = self.position().z
297 + f64::from(angle_cos) * f64::from(state.time_until_hooked) * 0.1;
298
299 let Some(world) = self.level() else {
300 return;
301 };
302
303 let splash_block_state =
304 world.get_block_state(BlockPos::containing(fish_x, fish_y - 1.0, fish_z));
305
306 if splash_block_state.get_block() == &vanilla_blocks::WATER {
307 const PARTICLE_SPAWN_PROBABILITY: f32 = 0.15;
308 if rng().random::<f32>() < PARTICLE_SPAWN_PROBABILITY {
309 world.send_particles(
310 ParticleData::simple(&BUBBLE),
311 DVec3::new(fish_x, fish_y - 0.1, fish_z),
312 1,
313 DVec3::new(angle_sin.into(), 0.1, angle_cos.into()),
314 0.0,
315 );
316 }
317
318 let particle_x_mov = angle_sin * 0.04;
319 let particle_z_mov = angle_cos * 0.04;
320
321 world.send_particles(
323 ParticleData::simple(&FISHING),
324 DVec3::new(fish_x, fish_y, fish_z),
325 0,
326 DVec3::new(particle_z_mov.into(), 0.01, -f64::from(particle_x_mov)),
327 1.0,
328 );
329 world.send_particles(
330 ParticleData::simple(&FISHING),
331 DVec3::new(fish_x, fish_y, fish_z),
332 0,
333 DVec3::new(-f64::from(particle_z_mov), 0.01, particle_x_mov.into()),
334 1.0,
335 );
336 }
337 } else {
338 let Some(world) = self.level() else {
340 return;
341 };
342
343 self.play_sound(
344 &sound_events::ENTITY_FISHING_BOBBER_SPLASH,
345 0.25,
346 1.0 + (rng().random::<f32>() - rng().random::<f32>()) * 0.4,
347 );
348
349 let bb_width = self.bounding_box().width();
350 let y = self.position().y + 0.5;
351 let particle_pos = DVec3::new(self.position().x, y, self.position().z);
352 let particle_count = (1.0 + bb_width * 20.0) as i32;
353 let particle_spread = DVec3::new(bb_width, 0.0, bb_width);
354
355 world.send_particles(
356 ParticleData::simple(&BUBBLE),
357 particle_pos,
358 particle_count,
359 particle_spread,
360 0.2,
361 );
362 world.send_particles(
363 ParticleData::simple(&FISHING),
364 particle_pos,
365 particle_count,
366 particle_spread,
367 0.2,
368 );
369
370 state.nibble = rng().random_range(ONE_SECOND..=TWO_SECONDS);
371 self.entity_data.lock().fishing_hook_mut().biting.set(true);
372 }
373 } else if state.time_until_lured > 0 {
374 state.time_until_lured -= fishing_speed;
375 let mut tease_chance: f32 = 0.15;
376
377 match state.time_until_lured {
378 0..ONE_SECOND => {
379 tease_chance += (ONE_SECOND - state.time_until_lured) as f32 * 0.05;
380 }
381 ONE_SECOND..TWO_SECONDS => {
382 tease_chance += (TWO_SECONDS - state.time_until_lured) as f32 * 0.02;
383 }
384 TWO_SECONDS..THREE_SECONDS => {
385 tease_chance += (THREE_SECONDS - state.time_until_lured) as f32 * 0.01;
386 }
387 _ => {}
388 }
389
390 if rng().random::<f32>() < tease_chance {
391 let Some(world) = self.level() else {
393 return;
394 };
395
396 let angle = rng().random_range(0.0..=DEGREE_360) * DEG_TO_RAD;
397 let dist = rng().random_range(25.0..=60.0);
398
399 let fish_x = self.position().x + f64::from(trig::sin(angle)) * dist * 0.1;
400 let fish_y = self.position().y.floor() + 1.0;
401 let fish_z = self.position().z + f64::from(trig::cos(angle)) * dist * 0.1;
402
403 let splash_block_state =
404 world.get_block_state(BlockPos::containing(fish_x, fish_y - 1.0, fish_z));
405
406 if splash_block_state.get_block() == &vanilla_blocks::WATER {
407 world.send_particles(
408 ParticleData::simple(&SPLASH),
409 DVec3::new(fish_x, fish_y, fish_z),
410 2 + rng().random_range(0..=2),
411 DVec3::new(0.1, 0.0, 0.1),
412 0.0,
413 );
414 }
415 }
416
417 if state.time_until_lured <= 0 {
418 state.fish_angle = rng().random_range(0.0..=DEGREE_360);
419 state.time_until_hooked = rng().random_range(ONE_SECOND..=FOUR_SECONDS);
420 }
421 } else {
422 state.time_until_lured = rng().random_range(FIVE_SECONDS..=THIRTY_SECONDS);
423 state.time_until_lured -= state.lure_speed;
424 }
425 }
426
427 fn calculate_open_water(&self, pos: BlockPos) -> bool {
429 let mut prev_layer = OpenWaterType::Invalid;
430
431 for y in -1..=2 {
432 let offset_from = BlockPos::new(pos.x() - 2, pos.y() + y, pos.z() - 2);
433 let offset_to = BlockPos::new(pos.x() + 2, pos.y() + y, pos.z() + 2);
434
435 let layer = self.get_open_water_type_for_area(offset_from, offset_to);
436
437 match layer {
438 OpenWaterType::AboveWater => {
439 if prev_layer == OpenWaterType::Invalid {
440 return false;
441 }
442 }
443 OpenWaterType::InsideWater => {
444 if prev_layer == OpenWaterType::AboveWater {
445 return false;
446 }
447 }
448
449 OpenWaterType::Invalid => {
450 return false;
451 }
452 }
453 prev_layer = layer;
454 }
455
456 true
457 }
458
459 fn get_open_water_type_for_area(&self, from: BlockPos, to: BlockPos) -> OpenWaterType {
461 let mut iter =
462 BlockPos::between_closed(from, to).map(|pos| self.get_open_water_type_for_block(pos));
463
464 let Some(first) = iter.next() else {
465 return OpenWaterType::Invalid;
466 };
467
468 if iter.all(|value| value == first) {
469 first
470 } else {
471 OpenWaterType::Invalid
472 }
473 }
474
475 fn get_open_water_type_for_block(&self, pos: BlockPos) -> OpenWaterType {
477 let Some(world) = self.level() else {
478 return OpenWaterType::Invalid;
479 };
480
481 let block_state = world.get_block_state(pos);
482 let collision_shape = block_state.get_collision_shape_at(pos);
483
484 if !block_state.is_air() && !(block_state.get_block() == &vanilla_blocks::LILY_PAD) {
485 let fluid_state = block_state.get_fluid_state();
486 if fluid_state.is_water() && fluid_state.is_source() && collision_shape.is_empty() {
487 OpenWaterType::InsideWater
488 } else {
489 OpenWaterType::Invalid
490 }
491 } else {
492 OpenWaterType::AboveWater
493 }
494 }
495
496 pub fn retrieve(&self, rod: &ItemStack) -> i32 {
498 let mut damage = 0;
499
500 if let Some(owner) = self.get_owner()
501 && let Some(player) = owner.as_player()
502 {
503 let can_retrieve = {
504 let inventory = player.inventory.lock();
505 let mainhand_item = inventory.get_item_in_hand(InteractionHand::MainHand);
506 let offhand_item = inventory.get_offhand_item();
507
508 !Self::should_stop_fishing(self, player, mainhand_item, offhand_item)
509 };
510
511 if can_retrieve {
512 let hooked_in = {
513 let hook_state = self.hook_state.lock();
514 hook_state.hooked_entity.clone()
515 };
516
517 if let Some(hooked_in) = hooked_in {
518 self.pull_entity(&hooked_in);
519 self.broadcast_entity_event(EntityStatus::FishingRodReelIn);
521 damage = if hooked_in.as_ref().is::<ItemEntity>() {
522 DMG_ITEM_ENTITY
523 } else {
524 DMG_DEFAULT
525 };
526 } else {
527 let luck = {
528 let state = self.hook_state.lock();
529
530 (state.nibble > 0).then_some(state.luck)
531 };
532
533 if let Some(luck) = luck {
534 let mut rng = rng();
535
536 let mut loot_ctx = LootContext::new(&mut rng)
538 .with_origin(self.position().x, self.position().y, self.position().z)
539 .with_tool(rod)
540 .with_this_entity(entity_loot_ref(self, None))
541 .with_luck(luck as f32 + player.get_luck());
542
543 let items =
544 vanilla_loot_tables::GAMEPLAY_FISHING.get_random_items(&mut loot_ctx);
545
546 let Some(world) = self.level() else {
549 return damage;
550 };
551
552 self.spawn_loot_award_stat(items, &world, owner.as_ref());
553
554 let orb_pos = DVec3::new(
555 player.position().x,
556 player.position().y + 0.5,
557 player.position().z + 0.5,
558 );
559
560 let orb = ExperienceOrbEntity::new(
561 &vanilla_entities::EXPERIENCE_ORB,
562 next_entity_id(),
563 orb_pos,
564 Arc::downgrade(&world),
565 );
566
567 orb.set_value(rand::random_range(1..=6));
568
569 let entity: SharedEntity = Arc::new(orb);
570
571 if let Err(error) = world.try_add_entity(Arc::clone(&entity)) {
572 log::error!("Failed to spawn experience orb: {error}");
573 }
574
575 damage = DMG_CAUGHT;
576 }
577 }
578
579 if self.base.on_ground() {
580 damage = DMG_ON_GROUND;
581 }
582
583 self.set_removed(RemovalReason::Discarded);
584 }
585 }
586 damage
587 }
588
589 fn pull_entity(&self, entity: &Arc<dyn Entity>) {
591 if let Some(owner) = self.get_owner() {
592 let base = owner.base();
593 let delta = DVec3::new(
594 base.position().x - self.base.position().x,
595 base.position().y - self.base.position().y,
596 base.position().z - self.base.position().z,
597 ) * 0.1;
598 entity.set_velocity(entity.velocity().add(delta));
599 }
600 }
601
602 fn clear_owner_info(&self) {
604 let Some(owner) = self.get_owner() else {
605 return;
606 };
607 let Some(player) = owner.as_player() else {
608 return;
609 };
610
611 player.clear_fishing_hook(self);
612 }
613
614 fn update_owner_info(&self, hook: Option<&Arc<FishingHookEntity>>) {
616 if let Some(owner) = self.get_owner()
617 && let Some(player) = owner.as_player()
618 {
619 match hook {
620 Some(hook) => player.set_fishing_hook(hook),
621 None => player.clear_fishing_hook(self),
622 }
623 }
624 }
625
626 fn spawn_loot_award_stat(&self, items: Vec<ItemStack>, world: &Arc<World>, owner: &dyn Entity) {
629 for item_stack in items {
630 const SPEED: f64 = 0.1;
631 const INVERSE_CUBE: f64 = 0.08;
632
633 if let Some(player) = owner.as_player() {
634 let xa = player.position().x - self.position().x;
635 let ya = player.position().y - self.position().y;
636 let za = player.position().z - self.position().z;
637
638 let vel = DVec3::new(
639 xa * SPEED,
640 ya * SPEED + (xa * xa + ya * ya + za * za).sqrt().sqrt() * INVERSE_CUBE,
641 za * SPEED,
642 );
643
644 World::spawn_item_with_velocity(world, self.position(), item_stack.clone(), vel);
645
646 if item_stack.item().has_tag(&ItemTag::FISHES) {
647 player.award_custom_stat(&vanilla_custom_stats::FISH_CAUGHT);
648 }
649 } else {
650 return;
651 }
652 }
653 }
654
655 fn should_stop(&self) -> bool {
658 let state = self.hook_state.lock();
659
660 state.bobber_state == BobberState::Flying
661 && (self.base.on_ground() || self.base.horizontal_collision())
662 }
663
664 fn tick_bobber(
666 self: &Arc<Self>,
667 bobber_state: BobberState,
668 world: &World,
669 is_in_water: bool,
670 pos: BlockPos,
671 liquid_height: f32,
672 ) -> bool {
673 match bobber_state {
674 BobberState::Flying => {
675 let should_check_collision = {
676 let mut state = self.hook_state.lock();
677
678 if state.hooked_entity.is_some() {
679 self.base.set_velocity(DVec3::ZERO);
680 state.bobber_state = BobberState::HookedInEntity;
681 return false;
682 }
683
684 if is_in_water {
685 self.base
686 .set_velocity(self.base.velocity() * DVec3::new(0.3, 0.2, 0.3));
687 state.bobber_state = BobberState::Bobbing;
688 return false;
689 }
690
691 !self.on_ground()
692 };
693
694 if should_check_collision {
695 self.check_collision();
696 }
697
698 true
699 }
700
701 BobberState::HookedInEntity => {
702 let hooked = {
703 let state = self.hook_state.lock();
704 state.hooked_entity.clone()
705 };
706
707 let Some(hooked) = hooked else {
708 let mut state = self.hook_state.lock();
709 state.bobber_state = BobberState::Flying;
710 return false;
711 };
712
713 let removed = hooked.is_removed();
714 let can_interact = hooked.can_interact_with_level();
715
716 let same_dimension = if let Some(hooked_world) = hooked.level() {
718 world.dimension_type == hooked_world.dimension_type
719 } else {
720 false
721 };
722
723 if !removed && can_interact && same_dimension {
724 let pos = hooked.position();
725 let height = hooked.bounding_box().height();
726
727 if let Err(error) =
728 self.try_set_position(DVec3::new(pos.x, pos.y + height * 0.8, pos.z))
729 {
730 self.set_removed(RemovalReason::Discarded);
731 log::error!("Failed to set position of fishing hook: {error}");
732 }
733 } else {
734 self.set_hooked_entity(None);
735
736 let mut state = self.hook_state.lock();
737 state.bobber_state = BobberState::Flying;
738 }
739
740 false
741 }
742
743 BobberState::Bobbing => {
744 let mut state = self.hook_state.lock();
745
746 let velocity = self.base.velocity();
747
748 let mut force: f64 =
749 self.position().y + velocity.y - f64::from(pos.y()) - f64::from(liquid_height);
750
751 if force.abs() < 0.01 {
752 force += force.signum() * 0.1;
753 }
754
755 self.base.set_velocity(DVec3::new(
756 velocity.x * 0.9,
757 velocity.y - force * rng().random_range(0.0..0.2),
758 velocity.z * 0.9,
759 ));
760
761 if state.nibble <= 0 && state.time_until_hooked <= 0 {
762 state.open_water = true;
763 } else {
764 state.open_water = state.open_water
765 && state.out_of_water_time < MAX_OUT_OF_WATER_TIME
766 && self.calculate_open_water(pos);
767 }
768
769 if is_in_water {
770 state.out_of_water_time = (state.out_of_water_time - 1).max(0);
771 if *self.entity_data.lock().fishing_hook().biting.get() {
772 let mut synchronized_random = self.synchronized_random.lock();
773 self.base.set_velocity(self.base.velocity().add(DVec3::new(
774 0.0,
775 f64::from(
776 -0.1 * synchronized_random.next_f32()
777 * synchronized_random.next_f32(),
778 ),
779 0.0,
780 )));
781 }
782
783 self.catching_fish(pos, &mut state);
784 } else {
785 state.out_of_water_time =
786 (state.out_of_water_time + 1).min(MAX_OUT_OF_WATER_TIME);
787 }
788
789 true
790 }
791 }
792 }
793
794 fn tick_life(&self) {
795 if self.on_ground() {
796 let should_remove = {
797 let mut state = self.hook_state.lock();
798 state.life += 1;
799 state.life >= ONE_MINUTE
800 };
801
802 if should_remove {
803 self.set_removed(RemovalReason::Discarded);
804 }
805 } else {
806 self.hook_state.lock().life = 0;
807 }
808 }
809
810 fn is_hooked_in(&self) -> bool {
811 let state = self.hook_state.lock();
812 state.hooked_entity.is_some()
813 }
814
815 fn bobber_state(&self) -> BobberState {
816 let state = self.hook_state.lock();
817 state.bobber_state
818 }
819
820 fn can_fish(&self, player: &Player) -> bool {
821 let inventory = player.inventory.lock();
822 let mainhand_item = inventory.get_item_in_hand(InteractionHand::MainHand);
823 let offhand_item = inventory.get_offhand_item();
824
825 !self.should_stop_fishing(player, mainhand_item, offhand_item)
826 }
827}
828
829impl Entity for FishingHookEntity {
830 fn base(&self) -> &EntityBase {
831 &self.base
832 }
833
834 fn entity_type(&self) -> EntityTypeRef {
835 self.entity_type
836 }
837
838 fn movement_emission(&self) -> EntityMovementEmission {
839 EntityMovementEmission::None
840 }
841
842 fn tick(self: Arc<Self>) {
844 {
845 let mut synchronized_random = self.synchronized_random.lock();
846 let least_significant_bits = self.uuid().as_u64_pair().1;
847
848 if let Some(world) = self.level() {
849 let game_time = world.game_time();
850 let seed = least_significant_bits as i64 ^ game_time;
851
852 synchronized_random.set_seed(seed);
853 }
854 }
855
856 self.projectile_base_tick();
857 if let Some(owner) = self.get_owner()
858 && let Some(player) = owner.as_player()
859 {
860 if self.can_fish(player) {
861 self.tick_life();
862
863 let pos = BlockPos::from(self.base.position());
864
865 if let Some(world) = self.level() {
866 let block_state = world.get_block_state(pos);
867 let fluid_state = block_state.get_fluid_state();
868
869 let liquid_height = {
870 if fluid_state.is_water() {
871 get_height(&world, pos, fluid_state)
872 } else {
873 0.0
874 }
875 };
876
877 let is_in_water = liquid_height > 0.0;
878
879 if !self.tick_bobber(
880 self.bobber_state(),
881 &world,
882 is_in_water,
883 pos,
884 liquid_height,
885 ) {
886 return;
887 }
888
889 if !fluid_state.is_water() && !self.base.on_ground() && !self.is_hooked_in() {
890 self.base
891 .set_velocity(self.base.velocity().add(DVec3::new(0.0, -0.03, 0.0)));
892 }
893
894 Arc::clone(&self).move_entity(MoverType::SelfMovement, self.base.velocity());
895 self.apply_effects_from_blocks();
896 self.update_rotation();
897
898 if self.should_stop() {
900 self.base.set_velocity(DVec3::ZERO);
901 }
902
903 let inertia: f64 = 0.92;
904 self.base.set_velocity(self.base.velocity() * inertia);
905 self.base.set_old_position_to_current();
906 }
907 } else {
908 self.set_removed(RemovalReason::Discarded);
909 }
910 }
911 }
912
913 fn synced_data(&self) -> Option<&dyn EntitySyncedData> {
914 Some(&self.entity_data)
915 }
916
917 fn set_removed(&self, reason: RemovalReason) {
919 self.clear_owner_info();
920 self.base.set_removed(reason);
921 }
922
923 fn spawn_data(&self) -> i32 {
925 self.get_owner().map_or(self.id(), |owner| owner.id())
926 }
927}
928
929impl Projectile for FishingHookEntity {
930 fn projectile_base(&self) -> &ProjectileBase {
932 &self.projectile_base
933 }
934
935 fn can_hit_entity(&self, entity: &dyn Entity) -> bool {
937 self.base_can_hit_entity(entity) || (entity.is_alive() && entity.is::<ItemEntity>())
938 }
939
940 fn on_hit_entity(self: Arc<Self>, entity: &SharedEntity, _location: DVec3) {
942 self.set_hooked_entity(Some(Arc::clone(entity)));
943 }
944}
945
946impl ThrowableProjectile for FishingHookEntity {}
947
948#[derive(Debug, Clone, Copy, PartialEq, Eq)]
951enum BobberState {
952 Flying,
953 HookedInEntity,
954 Bobbing,
955}
956
957#[derive(Debug, Clone, Copy, PartialEq, Eq)]
959enum OpenWaterType {
960 AboveWater,
961 InsideWater,
962 Invalid,
963}
964
965#[cfg(test)]
966mod tests {
967 use steel_registry::item_stack::ItemStack;
968 use steel_registry::vanilla_entities;
969 use uuid::Uuid;
970
971 use super::*;
972 use crate::behavior::init_behaviors;
973 use crate::test_support::{TestPlayerBuilder, fresh_test_world};
974
975 fn test_hook(world: &Arc<World>, id: i32) -> Arc<FishingHookEntity> {
976 Arc::new(FishingHookEntity::new(
977 &vanilla_entities::FISHING_BOBBER,
978 id,
979 DVec3::ZERO,
980 Arc::downgrade(world),
981 ))
982 }
983
984 #[test]
985 fn spawn_data_identifies_the_owning_player() {
986 let world_fixture = fresh_test_world("fishing_hook_spawn_data");
987 let world = &world_fixture.world;
988 let player = TestPlayerBuilder::new(Arc::clone(world), "Player", 37)
989 .uuid(Uuid::from_u128(1))
990 .build();
991 let owner: SharedEntity = player;
992 let hook = test_hook(world, 38);
993 hook.set_owner_entity(Some(&owner));
994
995 assert_eq!(hook.spawn_data(), owner.id());
996 }
997
998 #[test]
999 fn removal_only_clears_the_matching_active_hook() {
1000 let world_fixture = fresh_test_world("fishing_hook_owner_lifecycle");
1001 let world = &world_fixture.world;
1002 let player = TestPlayerBuilder::new(Arc::clone(world), "Player", 40)
1003 .uuid(Uuid::from_u128(2))
1004 .build();
1005 let player_owner = Arc::clone(&player);
1006 let owner: SharedEntity = player_owner;
1007 let first = test_hook(world, 41);
1008 let second = test_hook(world, 42);
1009
1010 first.set_owner(&owner);
1011 assert!(
1012 player
1013 .fishing_hook()
1014 .is_some_and(|active| Arc::ptr_eq(&active, &first))
1015 );
1016
1017 second.set_owner(&owner);
1018 first.set_removed(RemovalReason::Discarded);
1019 assert!(
1020 player
1021 .fishing_hook()
1022 .is_some_and(|active| Arc::ptr_eq(&active, &second))
1023 );
1024
1025 second.set_removed(RemovalReason::Discarded);
1026 assert!(player.fishing_hook().is_none());
1027 }
1028
1029 #[test]
1030 fn retrieving_discards_the_active_hook() {
1031 let world_fixture = fresh_test_world("fishing_hook_retrieve_lifecycle");
1032 let world = &world_fixture.world;
1033 let player = TestPlayerBuilder::new(Arc::clone(world), "Player", 50)
1034 .uuid(Uuid::from_u128(3))
1035 .build();
1036 player
1037 .inventory
1038 .lock()
1039 .set_selected_item(ItemStack::new(&vanilla_items::FISHING_ROD));
1040 let player_owner = Arc::clone(&player);
1041 let owner: SharedEntity = player_owner;
1042 let hook = test_hook(world, 51);
1043 hook.set_owner(&owner);
1044 let rod = ItemStack::new(&vanilla_items::FISHING_ROD);
1045
1046 assert_eq!(hook.retrieve(&rod), 0);
1047 assert!(hook.is_removed());
1048 assert!(player.fishing_hook().is_none());
1049 }
1050
1051 #[test]
1052 fn shoot_from_player_respects_pitch_and_yaw_signs() {
1053 let world_fixture = fresh_test_world("fishing_hook_shoot_signs");
1054 let world = &world_fixture.world;
1055
1056 let player_down = TestPlayerBuilder::new(Arc::clone(world), "Player", 100)
1058 .uuid(Uuid::from_u128(10))
1059 .build();
1060 player_down.set_rotation((0.0, 90.0));
1061 let hook_down = test_hook(world, 101);
1062 hook_down.shoot_from_player(&player_down, 0, 0);
1063 assert!(
1064 hook_down.velocity().y < -2.0,
1065 "Looking straight down must throw downwards, got y={}",
1066 hook_down.velocity().y
1067 );
1068
1069 let player_up = TestPlayerBuilder::new(Arc::clone(world), "Player", 110)
1071 .uuid(Uuid::from_u128(11))
1072 .build();
1073 player_up.set_rotation((0.0, -90.0));
1074 let hook_up = test_hook(world, 111);
1075 hook_up.shoot_from_player(&player_up, 0, 0);
1076 assert!(
1077 hook_up.velocity().y > 2.0,
1078 "Looking straight up must throw upwards, got y={}",
1079 hook_up.velocity().y
1080 );
1081
1082 let player_west = TestPlayerBuilder::new(Arc::clone(world), "Player", 120)
1084 .uuid(Uuid::from_u128(12))
1085 .build();
1086 player_west.set_rotation((90.0, 0.0));
1087 let hook_west = test_hook(world, 121);
1088 hook_west.shoot_from_player(&player_west, 0, 0);
1089 assert!(
1090 hook_west.velocity().x < -0.8,
1091 "Looking West must throw in -X direction, got x={}",
1092 hook_west.velocity().x
1093 );
1094
1095 let player_east = TestPlayerBuilder::new(Arc::clone(world), "Player", 130)
1097 .uuid(Uuid::from_u128(13))
1098 .build();
1099 player_east.set_rotation((-90.0, 0.0));
1100 let hook_east = test_hook(world, 131);
1101 hook_east.shoot_from_player(&player_east, 0, 0);
1102 assert!(
1103 hook_east.velocity().x > 0.8,
1104 "Looking East must throw in +X direction, got x={}",
1105 hook_east.velocity().x
1106 );
1107 }
1108
1109 #[test]
1110 fn grounded_hook_does_not_hook_owner_when_player_stands_on_it() {
1111 steel_registry::init_vanilla_registry();
1112 init_behaviors();
1113
1114 let world_fixture = fresh_test_world("fishing_hook_grounded_owner");
1115 let world = &world_fixture.world;
1116 let player = TestPlayerBuilder::new(Arc::clone(world), "Player", 200)
1117 .uuid(Uuid::from_u128(20))
1118 .build();
1119 player
1120 .inventory
1121 .lock()
1122 .set_selected_item(ItemStack::new(&vanilla_items::FISHING_ROD));
1123 let player_owner = Arc::clone(&player);
1124 let owner: SharedEntity = player_owner;
1125 let hook = test_hook(world, 201);
1126 hook.set_owner(&owner);
1127 hook.set_on_ground(true);
1128 hook.set_velocity(DVec3::ZERO);
1129
1130 hook.try_set_position(player.position())
1132 .expect("should position hook");
1133
1134 Arc::clone(&hook).tick();
1135
1136 let hooked_entity = hook.hook_state.lock().hooked_entity.clone();
1137 assert!(
1138 hooked_entity.is_none(),
1139 "Grounded stationary hook must not hook player standing on it"
1140 );
1141 }
1142
1143 #[test]
1144 fn submerged_hook_in_water_experiences_upward_buoyancy() {
1145 use crate::test_support::insert_ready_full_chunk;
1146 use steel_utils::ChunkPos;
1147 use steel_utils::types::UpdateFlags;
1148
1149 steel_registry::init_vanilla_registry();
1150 init_behaviors();
1151
1152 let world_fixture = fresh_test_world("fishing_hook_buoyancy");
1153 let world = &world_fixture.world;
1154 insert_ready_full_chunk(world, ChunkPos::new(0, 0));
1155
1156 let water = vanilla_blocks::WATER.default_state();
1157 let pos_submerged = BlockPos::new(0, 60, 0);
1158 let pos_above = BlockPos::new(0, 61, 0);
1159 world.set_block(pos_submerged, water, UpdateFlags::UPDATE_NONE);
1160 world.set_block(pos_above, water, UpdateFlags::UPDATE_NONE);
1161
1162 let player = TestPlayerBuilder::new(Arc::clone(world), "Player", 300)
1163 .uuid(Uuid::from_u128(30))
1164 .build();
1165 player
1166 .try_set_position(DVec3::new(0.5, 61.0, 0.5))
1167 .expect("should position player near water");
1168 player
1169 .inventory
1170 .lock()
1171 .set_selected_item(ItemStack::new(&vanilla_items::FISHING_ROD));
1172 let player_owner = Arc::clone(&player);
1173 let owner: SharedEntity = player_owner;
1174
1175 let hook = test_hook(world, 301);
1176 hook.set_owner(&owner);
1177 hook.try_set_position(DVec3::new(0.5, 60.5, 0.5))
1178 .expect("should position hook");
1179 hook.set_velocity(DVec3::ZERO);
1180 hook.hook_state.lock().bobber_state = BobberState::Bobbing;
1181
1182 Arc::clone(&hook).tick();
1183
1184 assert!(
1185 hook.velocity().y > 0.0,
1186 "Submerged hook must accelerate upwards towards the water surface, got velocity.y={}",
1187 hook.velocity().y
1188 );
1189 }
1190
1191 #[test]
1192 fn open_water_calculation_identifies_open_lake_and_shallow_puddle() {
1193 use crate::test_support::insert_ready_full_chunk;
1194 use steel_utils::ChunkPos;
1195 use steel_utils::types::UpdateFlags;
1196
1197 steel_registry::init_vanilla_registry();
1198 init_behaviors();
1199
1200 let world_fixture = fresh_test_world("fishing_hook_open_water");
1201 let world = &world_fixture.world;
1202 insert_ready_full_chunk(world, ChunkPos::new(0, 0));
1203
1204 let water = vanilla_blocks::WATER.default_state();
1205 let air = vanilla_blocks::AIR.default_state();
1206 let center = BlockPos::new(8, 64, 8);
1207
1208 for y in -1..=0 {
1212 for dx in -2..=2 {
1213 for dz in -2..=2 {
1214 world.set_block(
1215 BlockPos::new(center.x() + dx, center.y() + y, center.z() + dz),
1216 water,
1217 UpdateFlags::UPDATE_NONE,
1218 );
1219 }
1220 }
1221 }
1222 for y in 1..=2 {
1223 for dx in -2..=2 {
1224 for dz in -2..=2 {
1225 world.set_block(
1226 BlockPos::new(center.x() + dx, center.y() + y, center.z() + dz),
1227 air,
1228 UpdateFlags::UPDATE_NONE,
1229 );
1230 }
1231 }
1232 }
1233
1234 let hook = test_hook(world, 401);
1235 assert!(
1236 hook.calculate_open_water(center),
1237 "5x5 open water lake must be considered open water"
1238 );
1239
1240 world.set_block(
1242 BlockPos::new(center.x() + 1, center.y(), center.z()),
1243 vanilla_blocks::STONE.default_state(),
1244 UpdateFlags::UPDATE_NONE,
1245 );
1246 assert!(
1247 !hook.calculate_open_water(center),
1248 "Obstructed water area must not be considered open water"
1249 );
1250 }
1251}