Keep level-up sounds on each player clock

Co-Authored-By: Codex GPT-6 <[email protected]>
This commit is contained in:
levzzzandCodex GPT-6 committed 2026-11-05 18:33:16 +05:00
1 parent 591ce12184
commit f4c4ab173c
7 files changed
+136 -43

No files matched your search

+52 -23
View File
@@ -1482,40 +1482,69 @@ impl Experience {
}
sound_level
}
/// `Player.giveExperienceLevels`: play `entity.player.levelup` when
/// `amount` is positive, the level landed on is a multiple of 5, and
/// 100 ticks have passed since the last one (`lastLevelUpTime < tickCount
/// - 100`). The volume is `level / 30` (1 above 30) times 0.75.
pub fn level_up_volume(amount: i32, level: i32, ticks: u64, last: u64) -> Option<f32> {
if amount > 0 && level % 5 == 0 && last < ticks.saturating_sub(100) {
let vol = if level > 30 { 1.0 } else { level as f32 / 30.0 };
Some(vol * 0.75)
}
/// A player's `Entity.tickCount` and `Player.lastLevelUpTime`. This state belongs
/// to the player entity, not the level or the server's clock.
#[derive(Clone, Copy, Debug, Default)]
pub struct LevelUpSound {
ticks: i32,
last: i32,
}
impl LevelUpSound {
/// `Entity.commonTick`: called once for this player, including spectators.
pub fn tick(&mut self) {
self.ticks = self.ticks.wrapping_add(1);
}
/// `Player.giveExperienceLevels`: return the sound's volume and start its
/// cooldown when a positive change lands on a fifth level.
pub fn give_levels(&mut self, amount: i32, level: i32) -> Option<f32> {
if amount > 0 && level % 5 == 0 && (self.last as f32) < self.ticks as f32 - 100.0 {
self.last = self.ticks;
Some((if level > 30 { 1.0 } else { level as f32 / 30.0 }) * 0.75)
} else {
None
}
}
}
#[cfg(test)]
mod level_up_sound {
use super::Experience;
use super::LevelUpSound;
#[test]
fn give_experience_levels_sounds_on_every_fifth_level_after_a_hundred_ticks() {
// 5/30 * 0.75 = 0.125, 10/30 * 0.75 = 0.25, and 30/30 and anything
// above 30 are both 0.75. A level that is not a multiple of 5, a
// loss of levels, and a cooldown that has not elapsed stay quiet.
assert_eq!(Experience::level_up_volume(1, 5, 200, 0), Some(0.125));
assert_eq!(Experience::level_up_volume(1, 10, 200, 0), Some(0.25));
assert_eq!(Experience::level_up_volume(1, 30, 101, 0), Some(0.75));
assert_eq!(Experience::level_up_volume(1, 35, 200, 0), Some(0.75));
assert_eq!(Experience::level_up_volume(1, 6, 200, 0), None);
assert_eq!(Experience::level_up_volume(-1, 5, 200, 0), None);
assert_eq!(Experience::level_up_volume(1, 5, 100, 0), None);
assert_eq!(Experience::level_up_volume(1, 5, 101, 0), Some(0.125));
assert_eq!(Experience::level_up_volume(1, 5, 250, 150), None);
assert_eq!(Experience::level_up_volume(1, 5, 251, 150), Some(0.125));
fn players_have_independent_ages_and_cooldowns() {
let mut a = LevelUpSound::default();
let mut b = LevelUpSound::default();
for _ in 0..100 {
a.tick();
b.tick();
}
assert_eq!(a.give_levels(1, 5), None);
a.tick();
b.tick();
assert_eq!(a.give_levels(1, 5), Some(0.125));
assert_eq!(b.give_levels(1, 10), Some(0.25));
assert_eq!(a.give_levels(1, 10), None);
for _ in 0..100 {
a.tick();
}
assert_eq!(a.give_levels(1, 30), None);
a.tick();
assert_eq!(a.give_levels(1, 30), Some(0.75));
let mut respawned = LevelUpSound::default();
assert_eq!(respawned.give_levels(1, 35), None);
for _ in 0..101 {
respawned.tick();
}
assert_eq!(respawned.give_levels(-1, 35), None);
assert_eq!(respawned.give_levels(1, 34), None);
assert_eq!(respawned.give_levels(1, 35), Some(0.75));
}
}
/// A painting (`Painting`, a `HangingEntity`): the block it hangs in, the
/// 2D data value of the direction it faces, and its variant.
#[derive(Clone, Debug)]
+4 -10
View File
@@ -2759,7 +2759,7 @@ struct Game<'a> {
orbs: horizon_client::join_seq::JoinList<ExperienceOrb>,
xp_delay: u32,
ordered: ordered_tick::OrderedTicks,
last_level_up: u64,
level_up_sound: horizon_client::entities::LevelUpSound,
/// The arm swing (`LivingEntity.SwingState`) and how many swings started.
swing: horizon_client::first_person::SwingState,
swings_started: u32,
@@ -14194,19 +14194,13 @@ impl Game<'_> {
return;
};
let gained = gained.max(0);
let at = self.eye_position();
let old_level = self.experience.level;
let gave = self.experience.give(gained);
self.native_player_level_changed(old_level);
// Player.giveExperienceLevels runs per level, so the first multiple of 5
// crossed plays the sound (and starts the cooldown for the rest).
if let Some(level) = gave
&& self.ticks > self.last_level_up + 100
{
self.last_level_up = self.ticks;
let volume = if level > 30 { 1.0 } else { level as f32 / 30.0 };
self.audio
.play("minecraft:entity.player.levelup", at, volume * 0.75, 1.0);
if let Some(level) = gave {
self.play_level_up(1, level);
}
}
/// BowItem.releaseUsing: power from charge time, arrows at power * 3.
@@ -19476,7 +19470,7 @@ impl<'a> Game<'a> {
orbs: Default::default(),
xp_delay: 0,
ordered: Default::default(),
last_level_up: 0,
level_up_sound: Default::default(),
swing: Default::default(),
swings_started: 0,
heights: Default::default(),
@@ -883,7 +883,10 @@ impl Game<'_> {
if a.rewards.experience > 0 {
let old_level = self.experience.level;
let points = self.xp_change(a.rewards.experience).map_or(0, |p| p.max(0));
self.experience.give(points);
let gained_level = self.experience.give(points);
if let Some(level) = gained_level {
self.play_level_up(1, level);
}
self.native_player_level_changed(old_level);
}
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
@@ -3055,6 +3055,7 @@ impl Game<'_> {
/// The caller reports the level change to the mods.
fn give_points(&mut self, amount: i32) {
let e = &mut self.experience;
let mut sound_level = None;
e.progress += amount as f32 / horizon_client::entities::Experience::needed(e.level) as f32;
e.total = e.total.saturating_add(amount).clamp(0, i32::MAX);
while e.progress < 0.0 {
@@ -3071,8 +3072,14 @@ impl Game<'_> {
e.progress =
(e.progress - 1.0) * horizon_client::entities::Experience::needed(e.level) as f32;
e.level += 1;
if e.level % 5 == 0 {
sound_level.get_or_insert(e.level);
}
e.progress /= horizon_client::entities::Experience::needed(e.level) as f32;
}
if let Some(level) = sound_level {
self.play_level_up(1, level);
}
}
/// AdvancementCommands.perform / performCriterion.
fn cmd_advancement(&mut self, ctx: &Context) -> R<i32> {
@@ -118,6 +118,8 @@ pub(super) struct GuestPlay {
pub(super) respawn_requested: bool,
/// `Player.takeXpDelay`: ticks until the next orb can be taken.
xp_delay: u32,
/// This player entity's age and level-up sound cooldown.
level_up_sound: horizon_client::entities::LevelUpSound,
/// What last killed the guest outright (`/kill`): the death message's key.
death_cause: Option<String>,
/// The equipment modifiers on the guest's attributes (`apply_equipment`)
@@ -166,6 +168,7 @@ pub(super) struct GuestSwap {
/// The host's command permission, swapped for the guest's for its turn.
permission: u8,
xp: Experience,
level_up_sound: horizon_client::entities::LevelUpSound,
host_keys: horizon_client::held::HeldKeys,
screen: Screen,
host_sign: Option<super::sign_edit::SignEditor>,
@@ -486,6 +489,7 @@ impl Game<'_> {
total: g.xp_total,
},
);
let level_up_sound = std::mem::replace(&mut self.level_up_sound, play.level_up_sound);
// The host's held keys stay theirs: the guest holds only sneak, and a
// screen the guest opens (`keys.clear()`) leaves the host walking.
let host_keys = std::mem::take(&mut self.keys);
@@ -617,6 +621,7 @@ impl Game<'_> {
block_reach,
permission,
xp,
level_up_sound,
host_keys,
screen,
host_sign,
@@ -717,6 +722,7 @@ impl Game<'_> {
block_reach,
permission,
xp,
level_up_sound,
host_keys,
screen,
host_sign,
@@ -966,6 +972,7 @@ impl Game<'_> {
.insert(key, BlockEntity::Chest(self.ender_items.clone()));
self.ender_open = Some(key);
}
play.level_up_sound = std::mem::replace(&mut self.level_up_sound, level_up_sound);
let new_xp = std::mem::replace(&mut self.experience, xp);
g.xp_level = new_xp.level;
g.xp_progress = new_xp.progress;
@@ -3539,6 +3546,7 @@ impl Game<'_> {
// empty: the mods hear each worn item again from empty (`native_players_equipment`).
if let Some(play) = host.play.get_mut(&conn) {
play.equipment_heard = None;
play.level_up_sound = Default::default();
}
// PlayerList.respawn: a server's player comes back in its respawn
// point's level: the Overworld, where the world spawn and beds are,
@@ -3702,6 +3710,13 @@ impl Game<'_> {
self.tick_player_menus(host);
self.native_players_equipment(host);
self.announce_guest_advancements(host);
// ServerLevel.tickNonPassenger calls Entity.commonTick for each player,
// including spectators and players whose client has not loaded yet.
for g in &host.hub.guests {
if host.hub.here(g) && !self.on_server_trip(g.conn) {
host.play.entry(g.conn).or_default().level_up_sound.tick();
}
}
let conns: Vec<u32> = host
.hub
.guests
+6 -9
View File
@@ -1000,14 +1000,12 @@ impl Game<'_> {
xp.total = 0;
}
let level = xp.level;
if let Some(volume) = horizon_client::entities::Experience::level_up_volume(
levels,
level,
self.ticks,
self.last_level_up,
) {
self.last_level_up = self.ticks;
// playSound(null, feet): the player hears their own level-up.
self.play_level_up(levels, level);
self.native_player_level_changed(old_level);
}
/// `Player.giveExperienceLevels`: all players near its feet hear the sound.
pub(super) fn play_level_up(&mut self, amount: i32, level: i32) {
if let Some(volume) = self.level_up_sound.give_levels(amount, level) {
let at = self
.player
.as_ref()
@@ -1015,7 +1013,6 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.levelup", at, volume, 1.0);
}
self.native_player_level_changed(old_level);
}
/// Whether the result slot may be taken (`mayPickup`).
fn may_take_result(&self) -> bool {
+48
View File
@@ -13089,5 +13089,53 @@ CHECKS += ['pig-saddle-shears']
CHECK_FNS['pig-saddle-shears'] = check_pig_saddle_shears
def check_levelup_sounds(host, guest, out):
"""Each server player has its own age and level-up cooldown, including point gains."""
sound = 'minecraft:entity.player.levelup'
guest('command', command=f'tp @s {host.player}')
for player in (host, guest):
player('command', command='xp set @s 0 levels')
player('command', command='xp set @s 0 points')
host.wait(120)
before = {p.name: heard(p, sound) for p in (host, guest)}
for player in (host, guest):
command_says(player, 'xp add @s 5 levels')
host.wait(10)
first = {p.name: heard(p, sound) - before[p.name] for p in (host, guest)}
assert first == {p.name: 2 for p in (host, guest)}, first
command_says(host, 'xp add @s 5 levels')
host.wait(5)
cooldown = {p.name: heard(p, sound) - before[p.name] for p in (host, guest)}
assert cooldown == first, cooldown
host.wait(110)
command_says(host, 'xp set @s 4 levels')
command_says(host, 'xp set @s 0 points')
command_says(host, 'xp add @s 15 points')
host.wait(10)
points = {p.name: heard(p, sound) - before[p.name] for p in (host, guest)}
assert points == {p.name: 3 for p in (host, guest)}, points
command_says(guest, 'spawnpoint @s ~ ~ ~')
guest('command', command='kill @s')
guest.wait(30)
guest.raw('respawn')
guest.wait(5)
command_says(host, 'xp set Guest 0 levels')
command_says(host, 'xp add Guest 5 levels')
guest.wait(5)
after_respawn = {p.name: heard(p, sound) - before[p.name] for p in (host, guest)}
assert after_respawn == points, after_respawn
guest.wait(110)
command_says(host, 'xp add Guest 5 levels')
guest.wait(10)
aged = {p.name: heard(p, sound) - before[p.name] for p in (host, guest)}
assert aged == {p.name: 4 for p in (host, guest)}, aged
return {'independent': first, 'cooldown': cooldown, 'points': points,
'respawn': after_respawn, 'respawn_aged': aged}
CHECKS += ['levelup-sounds']
CHECK_FNS['levelup-sounds'] = check_levelup_sounds
if __name__ == '__main__':
main()