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