Skip to main content

steel_core/entity/entities/objects/projectiles/
fishing_hook.rs

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/// A fishing hook.
57#[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
67/// This struct holds entity specific state information per fishing hook entity.
68pub 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    /// Equivalent to Java's `currentState`
77    bobber_state: BobberState,
78    hooked_entity: Option<SharedEntity>,
79    luck: i32,
80    lure_speed: i32,
81}
82
83impl FishingHookState {
84    #[must_use]
85    /// Returns a new `FishingHookState` with the given lure speed and luck values.
86    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
103// SAFETY: This key is owned by Steel and uniquely identifies `FishingHookEntity`.
104unsafe impl DowncastType for FishingHookEntity {
105    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:entity/fishing_hook");
106}
107
108impl FishingHookEntity {
109    /// Creates a fishing hook entity.
110    /// We keep both this generic constructor and `shoot_from_player` in order to ensure future-proofing in terms of a future plugin API.
111    #[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    /// Mimics Java's `FishingHook(Player, Level, int, int)` constructor. (But we don't need `level` here)
124    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    /// Creates a fishing hook entity from saved base data.
179    #[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            // FIXME: `lure_speed` and `luck` are taken from the existing rod, but auto generation fails when doing this, refer to: https://mcsrc.dev/2/26.2/net/minecraft/world/entity/projectile/FishingHook#L75
187            hook_state: SyncMutex::new(FishingHookState::new(0, 0)),
188            synchronized_random: SyncMutex::new(LegacyRandom::from_seed(0)),
189        }
190    }
191
192    /// Sets the projectile owner and mirrors vanilla's `Player.fishing` update.
193    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    /// Determines if the player should stop fishing and removes the entity if so.
202    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    /// Determines if the fishing hook should hit a target or be deflected.
227    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    /// Stores the currently hooked entity inside `entity_data`.
234    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    /// Runs catching fish logic
251    #[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                    // Yes, according to the src, x and z are swapped in the second `DVec3`
322                    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                // I check for the world here first, because we don't need to invoke `y` (the only call using `world` is the one using `y` through `particle_pos`) if it doesn't exist
339                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                // same reason to call this early in here as well: no need to calculate the rest if there is no world to spawn the particle in.
392                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    /// Calculates if the area the player is currently fishing in is open water.
428    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    /// Returns an `OpenWaterType` for a given area.
460    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    /// Returns an `OpenWaterType` for a given block.
476    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    /// Retrieves the entity caught by this fishing hook and returns the resulting damage value.
497    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                    // TODO: criteria triggers (advancements)
520                    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                        // This is equivalent to `LootParams params` in the java src.
537                        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                        // TODO: criteria triggers (advancements)
547
548                        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    /// Modifies the hooked entities velocity in order to simulate a pulling motion.
590    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    /// Clears owner info of this `FishingHookEntity`
603    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    /// Clears owner info if `hook` is `None` and stores it, if it is `Some`
615    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    // I added this fn because I thought it would be cleaner this way, it's not in the vanilla src, but how I use it ensures vanilla behavior
627    /// Loops through a `vec` of `ItemStack`s (the fishing loot), spawns them as `ItemEntity`s in the world and awards the stat `FISH_CAUGHT`
628    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    // TODO: check if passing a lock is better here
656    /// Determines if the player should stop fishing.
657    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    /// Bobber specific ticking logic. We return a `bool` here, so we can return early inside `tick`.
665    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                // locks hooked.base.world
717                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    /// Responsible for all state-changes.
843    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                    // TODO: check if passing a lock is better here
899                    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    /// Marks entity as removed and clears owner info.
918    fn set_removed(&self, reason: RemovalReason) {
919        self.clear_owner_info();
920        self.base.set_removed(reason);
921    }
922
923    /// Returns the ID of the owner, or of this `FishingHookEntity`, if it has no owner.
924    fn spawn_data(&self) -> i32 {
925        self.get_owner().map_or(self.id(), |owner| owner.id())
926    }
927}
928
929impl Projectile for FishingHookEntity {
930    /// Returns this `FishingHook`s `ProjectileBase`.
931    fn projectile_base(&self) -> &ProjectileBase {
932        &self.projectile_base
933    }
934
935    /// Determines if it's possible to hit an entity.
936    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    /// Stores the hit entity inside `hooked_in`.
941    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/// Collection of possible states the fishing bobber of the `FishingHookEntity` can take on.
949/// Equivalent to Java's `FishingHook.FishHookState` (we renamed for clarity)
950#[derive(Debug, Clone, Copy, PartialEq, Eq)]
951enum BobberState {
952    Flying,
953    HookedInEntity,
954    Bobbing,
955}
956
957/// Collection of possible types associated with open water.
958#[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        // Straight down: pitch = 90.0, yaw = 0.0 -> Y velocity must be negative (downwards)
1057        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        // Straight up: pitch = -90.0, yaw = 0.0 -> Y velocity must be positive (upwards)
1070        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        // West: yaw = 90.0, pitch = 0.0 -> X velocity must be negative (-X is West)
1083        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        // East: yaw = -90.0, pitch = 0.0 -> X velocity must be positive (+X is East)
1096        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        // Position hook inside player's bounding box
1131        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        // Build 5x5 open water area:
1209        // y in -1..=0: water
1210        // y in 1..=2: air
1211        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        // Place a solid block in the water layer -> should no longer be open water
1241        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}