Play the happy ghast's riding sound while the player rides one

LocalPlayer.startRiding starts a RidingEntitySoundInstance for a happy
ghast (entity.happy_ghast.riding, volume 5 * clampedLerp(speed, 0, 1)),
as it does the two inside sounds for a minecart; Horizon only had the
minecart's. Port the instance: it stops when the player gets off or the
ghast is silenced, and is silent under water and below 0.01 speed.

Part of #3518.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rx5M6NS763D2WVnb2HmcTH
This commit is contained in:
Claude committed 2026-10-30 18:26:58 +00:00
1 parent a3726f4bf8
commit 648531c6da
4 files changed
+132 -3

No files matched your search

@@ -60,6 +60,8 @@ pub enum Ticking {
GuardianAttack { guardian: u32 },
/// `ElytraOnPlayerSoundInstance`: ticks since it started.
Elytra { time: i32 },
/// `RidingEntitySoundInstance` for the player riding a happy ghast.
RidingHappyGhast { ghast: u32 },
}
/// What a minecart's sound instances read about it.
#[derive(Clone, Copy, Debug)]
@@ -84,6 +86,15 @@ pub struct MobSound {
/// `Guardian.getAttackAnimationScale(0)`.
pub attack_scale: f32,
}
/// What a `RidingEntitySoundInstance` reads about the mob the player rides.
#[derive(Clone, Copy, Debug)]
pub struct RiddenSound {
pub id: u32,
/// `getDeltaMovement().length()`.
pub speed: f64,
/// `isSilent`.
pub silent: bool,
}
/// What tickable instances read about the player and the entities they follow.
#[derive(Clone, Debug, Default)]
pub struct TickContext {
@@ -103,6 +114,8 @@ pub struct TickContext {
pub player_speed_sqr: f64,
/// `isFallFlying()` of the player.
pub player_fall_flying: bool,
/// The mob the player rides, when it is not a minecart.
pub ridden: Option<RiddenSound>,
}
/// `AbstractSoundInstance` and its subclasses' fields.
#[derive(Clone, Debug)]
@@ -345,6 +358,26 @@ impl SoundInstance {
};
s
}
/// `new RidingEntitySoundInstance(player, happyGhast, false,
/// HAPPY_GHAST_RIDING, happyGhast.getSoundSource(), 0, 1, 5)`, from
/// `LocalPlayer.startRiding`.
pub fn riding_happy_ghast(ghast: &RiddenSound, random: LegacyRandom) -> Self {
let mut s = Self::new(
"minecraft:entity.happy_ghast.riding",
NEUTRAL,
0.0,
1.0,
random,
true,
0,
Attenuation::None,
[0.0; 3],
false,
);
s.can_start_silent = true;
s.ticking = Ticking::RidingHappyGhast { ghast: ghast.id };
s
}
/// `new BeeFlyingSoundInstance(bee)` (or `BeeAggressiveSoundInstance`):
/// silent until the bee moves.
pub fn bee(bee: &MobSound, aggressive: bool, random: LegacyRandom) -> Self {
@@ -569,6 +602,23 @@ impl SoundInstance {
self.volume = 0.0 + 1.0 * scale * scale;
self.pitch = 0.7 + 0.5 * scale;
}
Ticking::RidingHappyGhast { ghast } => {
// RidingEntitySoundInstance.tick: canPlaySound, then while the
// player still rides it, silent under water (the ghast's sound
// is not the underwater one) and else
// `volumeAmplifier * Mth.clampedLerp(speed, volumeMin, volumeMax)`.
let id = *ghast;
let Some(ghast) = ctx.ridden.filter(|r| r.id == id && !r.silent) else {
self.stop();
return;
};
let speed = ghast.speed as f32;
self.volume = if ctx.player_underwater || speed < 0.01 {
0.0
} else {
5.0 * speed.clamp(0.0, 1.0)
};
}
Ticking::RidingMinecart { cart, underwater } => {
let (id, underwater) = (*cart, *underwater);
let found = ctx.carts.iter().find(|c| c.id == id);
@@ -1328,3 +1378,48 @@ pub mod test_backend {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn happy_ghast_riding_sound_follows_the_ghast_speed() {
let ghast = RiddenSound {
id: 7,
speed: 0.1,
silent: false,
};
let mut s = SoundInstance::riding_happy_ghast(&ghast, SoundEngine::unseeded());
assert_eq!(s.volume, 0.0);
let mut ctx = TickContext {
ridden: Some(ghast),
..TickContext::default()
};
// 5 * clampedLerp(speed, 0, 1).
s.tick(&ctx);
assert!((s.volume - 0.5).abs() < 1e-6, "{}", s.volume);
ctx.ridden = Some(RiddenSound {
speed: 3.0,
..ghast
});
s.tick(&ctx);
assert_eq!(s.volume, 5.0);
ctx.ridden = Some(RiddenSound {
speed: 0.005,
..ghast
});
s.tick(&ctx);
assert_eq!(s.volume, 0.0);
// Under water the ghast's sound is not the underwater one.
ctx.ridden = Some(ghast);
ctx.player_underwater = true;
s.tick(&ctx);
assert_eq!(s.volume, 0.0);
assert!(!s.stopped);
// Off the ghast, it stops.
ctx.ridden = None;
s.tick(&ctx);
assert!(s.stopped);
}
}
+3
View File
@@ -1814,6 +1814,8 @@ struct Game<'a> {
cart_sounds: HashSet<u32>,
/// The minecart the riding sounds were started for (`LocalPlayer.startRiding`).
riding_cart_sounds: Option<u32>,
/// The happy ghast its riding sound was started for (`LocalPlayer.startRiding`).
riding_ghast_sound: Option<u32>,
/// `LocalPlayer.wasFallFlying` for the elytra's flying sound.
was_fall_flying: bool,
/// Bees whose buzz is playing, and whether it is the aggressive one.
@@ -14972,6 +14974,7 @@ impl<'a> Game<'a> {
sound_paused: false,
cart_sounds: HashSet::new(),
riding_cart_sounds: None,
riding_ghast_sound: None,
was_fall_flying: false,
bee_sounds: HashMap::new(),
guardian_sounds: HashSet::new(),
+32 -3
View File
@@ -1,14 +1,43 @@
//! The looping sound instances of mobs: a bee's buzz
//! (`ClientPacketListener.postAddEntitySoundInstance`) and a guardian's
//! attack hum (entity event 21). The sound engine ticks them with what
//! `start_mob_sounds` reads off the mobs.
//! attack hum (entity event 21), and the happy ghast's riding sound. The
//! sound engine ticks them with what `start_mob_sounds` and
//! `start_riding_sound` read off the mobs.
use super::Game;
use horizon_client::{
mobs::{Species, bee, guardian},
sound_engine::{MobSound, SoundEngine, SoundInstance, Sounds},
sound_engine::{MobSound, RiddenSound, SoundEngine, SoundInstance, Sounds},
};
impl Game<'_> {
/// `LocalPlayer.startRiding` on a happy ghast: its riding sound (a
/// minecart's are `start_cart_sounds`'). Then what the instance reads
/// about the mob the player rides.
pub(super) fn start_riding_sound(&mut self) -> Option<RiddenSound> {
let ridden = self
.riding
.and_then(|id| self.mobs.iter().find(|m| m.id == id && !m.removed))
.map(|m| {
let ghast = m.species() == Species::HappyGhast;
let sound = RiddenSound {
id: m.id,
// Vec3.length.
speed: m.velocity.length(),
silent: m.silent,
};
(ghast, sound)
});
let ghast = ridden.filter(|r| r.0).map(|r| r.1);
if let Some(sound) = ghast
&& self.riding_ghast_sound != Some(sound.id)
&& !sound.silent
{
let instance = SoundInstance::riding_happy_ghast(&sound, SoundEngine::unseeded());
self.audio.play_instance(instance, false);
}
self.riding_ghast_sound = ghast.map(|g| g.id);
ridden.map(|r| r.1)
}
/// Start the buzz of each bee that has none (or whose anger changed, which
/// swaps the flying loop for the aggressive one and back), forget the mobs
/// that are gone, and read what the instances follow.
+2
View File
@@ -35,6 +35,7 @@ impl Game<'_> {
}
let (vehicle, carts) = self.start_cart_sounds();
let mobs = self.start_mob_sounds();
let ridden = self.start_riding_sound();
let (player_position, player_speed_sqr, player_fall_flying) =
self.player.as_ref().map_or(([0.0; 3], 0.0, false), |p| {
let at = p.position;
@@ -61,6 +62,7 @@ impl Game<'_> {
player_position,
player_speed_sqr,
player_fall_flying,
ridden,
};
self.audio.tick(self.pause, &ctx);
}