diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 779e6645f0..9f8f87331d 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -2528,11 +2528,19 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) { .push(MobEvent::Sound("entity.panda.bite", at, 1.0, 1.0)); } // Axolotl.playAttackSound: playSound(AXOLOTL_ATTACK, 1.0, 1.0). - if m.species() == Species::Axolotl { + if m.species() == Species::Axolotl && !m.silent { let at = [m.position.x, m.position.y, m.position.z]; cx.events .push(MobEvent::Sound("entity.axolotl.attack", at, 1.0, 1.0)); } + // Dolphin.playAttackSound: playSound(DOLPHIN_ATTACK, 1.0, 1.0), once + // the blow landed. A dolphin that is hurt takes its attacker as a target + // and strikes with the melee goal, so its bite is heard as it lands. + if m.species() == Species::Dolphin && !m.silent { + let at = [m.position.x, m.position.y, m.position.z]; + cx.events + .push(MobEvent::Sound("entity.dolphin.attack", at, 1.0, 1.0)); + } let _ = Fluid::None; } /// Mob.ate (sheep regrow wool, babies grow). @@ -3270,6 +3278,72 @@ mod hit_effect_tests { } } +#[cfg(test)] +mod attack_sound_tests { + use super::*; + use crate::mobs::world::with_test_world; + use std::sync::Arc; + + /// The sounds `do_hurt_target` raises for `kind` on a blow that landed, + /// with `silent` set on the attacker; `None` without the extracted data. + fn attack_sounds(kind: &str, silent: bool) -> Option> { + let mut heard = Vec::new(); + let ran = with_test_world(&[], |w, types| { + let mut m = Mob::new( + 1, + Arc::new(types.types[kind].clone()), + Vec3::new(0.5, 64.0, 0.5), + 0.0, + ); + m.silent = silent; + let (mut lr, mut events) = (horizon_core::random::LegacyRandom::new(0), Vec::new()); + let mut cx = Cx { + w, + lr: &mut lr, + events: &mut events, + }; + do_hurt_target(&mut m, Who::Mob(2), &mut cx); + heard = events + .iter() + .filter_map(|e| match e { + MobEvent::Sound(s, _, volume, pitch) => Some((*s, *volume, *pitch)), + _ => None, + }) + .collect(); + }); + if !ran { + return None; + } + Some(heard) + } + + /// `Mob.doHurtTarget` ends with `playAttackSound`, which `Dolphin`, + /// `Axolotl` and `Panda` override with their own bite at volume 1 and + /// pitch 1. Without the dolphin arm its bite is silent (no sound event at + /// all); the axolotl and the panda sit next to it as the control. + #[test] + fn a_dolphins_blow_plays_its_attack_sound() { + let Some(dolphin) = attack_sounds("minecraft:dolphin", false) else { + return; + }; + assert_eq!(dolphin, vec![("entity.dolphin.attack", 1.0, 1.0)]); + for (kind, sound) in [ + ("minecraft:axolotl", "entity.axolotl.attack"), + ("minecraft:panda", "entity.panda.bite"), + ] { + assert_eq!( + attack_sounds(kind, false), + Some(vec![(sound, 1.0, 1.0)]), + "{kind}" + ); + } + // Entity.playSound skips a silent entity, so neither attack sound plays. + for kind in ["minecraft:dolphin", "minecraft:axolotl", "minecraft:panda"] { + assert_eq!(attack_sounds(kind, true), Some(vec![]), "{kind}"); + } + } +} + #[cfg(test)] mod same_vehicle_push_tests { use super::*; diff --git a/tools/port_map/animal-1.tsv b/tools/port_map/animal-1.tsv index f818d35cff..f800dd04d0 100644 --- a/tools/port_map/animal-1.tsv +++ b/tools/port_map/animal-1.tsv @@ -53,6 +53,7 @@ Axolotl.applyImplicitComponent port crates/horizon-client/src/entity_nbt.rs::app Axolotl.getBreedOffspring port crates/horizon-client/src/game/creatures.rs::Game::breed gap: baby takes the first parent's colour; no random parent, no 1-in-1200 blue, not set persistent Axolotl.createAttributes skip:data attributes come from entity-types.json Axolotl.mobInteract port crates/horizon-client/src/game/creatures.rs::Game::interact_mob a water bucket scoops it up (bucketable) +Axolotl.playAttackSound port crates/horizon-client/src/mobs/ai.rs::do_hurt_target AXOLOTL_ATTACK at 1.0/1.0 after the blow lands, skipped for a silent mob Axolotl.loadFromBucketTag port crates/horizon-client/src/entity_nbt.rs::apply_item_components age, age lock and hunting cooldown from the bucket_entity_data component Axolotl.applySupportingEffects inline crates/horizon-client/src/game/creatures.rs::Game::handle_mob_events the AxolotlSupportPlayer event: regeneration and no mining fatigue, host player only Axolotl.usePlayerItem port crates/horizon-client/src/game/creatures.rs::Game::interact_mob a fed tropical fish bucket leaves a water bucket in its place (creative keeps the fish bucket and adds the bucket) diff --git a/tools/port_map/game-010.tsv b/tools/port_map/game-010.tsv index 74bc9ffc49..6d049102d5 100644 --- a/tools/port_map/game-010.tsv +++ b/tools/port_map/game-010.tsv @@ -114,6 +114,7 @@ Dolphin.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob Dolphin.registerGoals port crates/horizon-client/src/mobs/aquatic.rs::register gap: player: no MoveToItemGoal and PlayWithItemsGoal (dolphins ignore items thrown in the water), no FollowPlayerRiddenEntityGoal for boats or nautiluses, no AvoidEntity of guardians, and HurtByTarget does not skip guardians Dolphin.createAttributes skip:data attributes come from the entity tables Dolphin.canAttack port crates/horizon-client/src/mobs/goals.rs::spares_target a baby dolphin takes nobody as its target +Dolphin.playAttackSound port crates/horizon-client/src/mobs/ai.rs::do_hurt_target the dolphin arm: DOLPHIN_ATTACK at 1.0/1.0 after the blow lands, skipped for a silent mob Dolphin.pickUpItem port crates/horizon-client/src/game/foxes.rs::Game::tick_dolphins the game finds the first allowed item in the water within 8 blocks and takes it Dolphin.handleEntityEvent port crates/horizon-client-render/src/particles.rs::entity_event_burst event 38: seven happy villager particles about the dolphin Dolphin.closeToNextPos inline crates/horizon-client/src/mobs/dolphin.rs::SwimToTreasure::tick nav target within 12 blocks (144 squared) of the dolphin