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#[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
66pub 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 bobber_state: BobberState,
77 hooked_entity: Option<SharedEntity>,
78 luck: i32,
79 lure_speed: i32,
80}
81
82impl FishingHookState {
83 #[must_use]
84 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
102unsafe impl DowncastType for FishingHookEntity {
104 const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:entity/fishing_hook");
105}
106
107impl FishingHookEntity {
108 #[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 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 #[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 hook_state: SyncMutex::new(FishingHookState::new(0, 0)),
187 synchronized_random: SyncMutex::new(LegacyRandom::from_seed(0)),
188 }
189 }
190
191 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn set_removed(&self, reason: RemovalReason) {
921 self.clear_owner_info();
922 self.base.set_removed(reason);
923 }
924
925 fn spawn_data(&self) -> i32 {
927 self.get_owner().map_or(self.id(), |owner| owner.id())
928 }
929}
930
931impl Projectile for FishingHookEntity {
932 fn projectile_base(&self) -> &ProjectileBase {
934 &self.projectile_base
935 }
936
937 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
953enum BobberState {
954 Flying,
955 HookedInEntity,
956 Bobbing,
957}
958
959#[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 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 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 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 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 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 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 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}