mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Break sound and crumbs for equipment a server says broke
A vanilla server never plays an item's break itself: LivingEntity.onEquippedItemBroken broadcasts the entity event that entityEventForEquipmentBreak gives for the slot (47 main hand, 48 off hand, 49 to 52 head to feet, 65 body, 68 saddle) and each client's LivingEntity.handleEntityEvent runs breakItem on the stack it still has in that slot. The remote client had no case for these events, so on a server a breaking pickaxe, armour piece or wolf armour was silent and left no crumbs, for this player and for everyone else. Handle them: find the stack in the slot (the own inventory, or the mob's or other player's equipment), play its break_sound component at the entity's feet unless the entity is silent, and spawn the five item crumbs of spawnItemParticles. Entity.DATA_SILENT is now kept for remote entities so the silent check works. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
68f036059e
commit
3891dd9410
5 files changed
+230
No files matched your search
@@ -3,9 +3,13 @@
|
||||
use super::Game;
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
item_render::stack_view,
|
||||
items::ItemStack,
|
||||
particles::{self, ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_world::player_inventory::EquipmentSlot;
|
||||
use serde_json::Value;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ItemParticleOption(ITEM, stack)`: the layer particle sprites of the
|
||||
@@ -36,6 +40,75 @@ impl Game<'_> {
|
||||
self.particles.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.handleEntityEvent` 47 to 52, 65 and 68 from a server:
|
||||
/// the entity's item in `slot` broke, so `breakItem` the stack this
|
||||
/// client still has there (the slot is emptied by a later update).
|
||||
pub(super) fn remote_equipment_break(&mut self, id: i32, slot: EquipmentSlot) {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == id) {
|
||||
let stack = match slot {
|
||||
EquipmentSlot::MainHand => self.held().cloned(),
|
||||
EquipmentSlot::OffHand => self.offhand.clone(),
|
||||
EquipmentSlot::Head => self.armor[0].clone(),
|
||||
EquipmentSlot::Chest => self.armor[1].clone(),
|
||||
EquipmentSlot::Legs => self.armor[2].clone(),
|
||||
EquipmentSlot::Feet => self.armor[3].clone(),
|
||||
EquipmentSlot::Body | EquipmentSlot::Saddle => None,
|
||||
};
|
||||
let Some(stack) = stack else {
|
||||
return;
|
||||
};
|
||||
if let Some(p) = self.player.as_ref().map(|p| p.position) {
|
||||
self.break_sound_at(&stack, [p.x, p.y, p.z]);
|
||||
}
|
||||
self.eat_particles(&stack, 5);
|
||||
return;
|
||||
}
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id as u32) else {
|
||||
return;
|
||||
};
|
||||
let Some(stack) = m.item_by_slot(slot) else {
|
||||
return;
|
||||
};
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let (eye, pitch, yaw, silent) = (m.eye_height(), m.head_pitch, m.yaw, m.silent);
|
||||
if !silent {
|
||||
self.break_sound_at(&stack, at);
|
||||
}
|
||||
let options = self.item_particle(&stack);
|
||||
let mut spawned = Vec::new();
|
||||
particles::item_use_particles(
|
||||
&mut self.world.random,
|
||||
pitch,
|
||||
yaw,
|
||||
at,
|
||||
at[1] + eye,
|
||||
5,
|
||||
|p, v| spawned.push((p, v)),
|
||||
);
|
||||
for (p, v) in spawned {
|
||||
self.particles.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.breakItem`'s sound: the stack's `break_sound` (none when
|
||||
/// the component is removed), heard by this client only
|
||||
/// (`playLocalSound`) at the entity's feet, at 0.8 volume and a pitch of
|
||||
/// 0.8 to 1.2.
|
||||
fn break_sound_at(&mut self, stack: &ItemStack, at: [f64; 3]) {
|
||||
let models = &self.pack.item_renderer.models;
|
||||
let sound = match models
|
||||
.components(&stack_view(stack))
|
||||
.get("minecraft:break_sound")
|
||||
{
|
||||
Some(Value::String(id)) => id.clone(),
|
||||
Some(Value::Object(inline)) => match inline.get("sound_id") {
|
||||
Some(Value::String(id)) => id.clone(),
|
||||
_ => return,
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.4;
|
||||
self.audio.play_unshared(&sound, Some(at), 0.8, pitch);
|
||||
}
|
||||
/// The slime ball, snowball and cobweb models behind `item_slime`,
|
||||
/// `item_snowball` and `item_cobweb`, resolved on first use.
|
||||
pub(super) fn ensure_item_particle_sprites(&mut self) {
|
||||
|
||||
@@ -2665,6 +2665,12 @@ impl Game<'_> {
|
||||
{
|
||||
std::mem::swap(&mut m.extra.main_hand, &mut m.extra.off_hand);
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 47 to 52, 65 and 68: an
|
||||
// equipped item broke (onEquippedItemBroken).
|
||||
if let Some(slot) = horizon_client::mobs::item_break::equipment_break_slot(p.event)
|
||||
{
|
||||
self.remote_equipment_break(p.id, slot);
|
||||
}
|
||||
// EntityEvent.DEATH: start the death animation.
|
||||
if p.event == 3
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
|
||||
@@ -50,6 +50,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
m.invisible = invisible;
|
||||
}
|
||||
}
|
||||
// Entity.DATA_SILENT: Entity.isSilent, which keeps the sounds
|
||||
// a client plays itself quiet (LivingEntity.breakItem's).
|
||||
(4, Value::Bool(silent)) => m.silent = *silent,
|
||||
// Entity.DATA_POSE: the pose the species animates by.
|
||||
(6, Value::Id(pose)) => sync::set_pose(m, (*pose).clamp(0, 17) as u8),
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the
|
||||
|
||||
@@ -44,6 +44,7 @@ pub mod happy_ghast;
|
||||
pub mod hostile;
|
||||
pub mod illager;
|
||||
pub mod illusioner;
|
||||
pub mod item_break;
|
||||
pub mod keyframes;
|
||||
pub mod leash;
|
||||
pub mod living;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! `LivingEntity.breakItem` as a client sees it. A server never plays the
|
||||
//! break itself: `LivingEntity.onEquippedItemBroken` broadcasts the entity
|
||||
//! event `entityEventForEquipmentBreak` gives for the slot, and each client's
|
||||
//! `LivingEntity.handleEntityEvent` breaks the item it still draws in that
|
||||
//! slot (`getItemBySlot`), with the sound and the crumbs.
|
||||
use super::{Mob, drops::head_is_body};
|
||||
use crate::items::ItemStack;
|
||||
use horizon_world::player_inventory::EquipmentSlot;
|
||||
|
||||
/// `LivingEntity.handleEntityEvent` 47 to 52, 65 and 68: the slot whose
|
||||
/// item broke (the inverse of `entityEventForEquipmentBreak`).
|
||||
pub fn equipment_break_slot(event: i8) -> Option<EquipmentSlot> {
|
||||
Some(match event {
|
||||
47 => EquipmentSlot::MainHand,
|
||||
48 => EquipmentSlot::OffHand,
|
||||
49 => EquipmentSlot::Head,
|
||||
50 => EquipmentSlot::Chest,
|
||||
51 => EquipmentSlot::Legs,
|
||||
52 => EquipmentSlot::Feet,
|
||||
65 => EquipmentSlot::Body,
|
||||
68 => EquipmentSlot::Saddle,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
impl Mob {
|
||||
/// `LivingEntity.getItemBySlot` for a mob or another player a server
|
||||
/// sends: the stack it wears or holds in `slot`, with the components
|
||||
/// `ClientboundSetEquipmentPacket` gave it where they are kept.
|
||||
pub fn item_by_slot(&self, slot: EquipmentSlot) -> Option<ItemStack> {
|
||||
let e = &self.extra;
|
||||
let body = head_is_body(self.species());
|
||||
let plain = |id: &Option<std::sync::Arc<str>>| id.as_deref().map(|i| ItemStack::new(i, 1));
|
||||
let worn = |index: usize, id: &Option<std::sync::Arc<str>>| {
|
||||
e.armor[index]
|
||||
.clone()
|
||||
.filter(|s| id.as_ref() == Some(&s.item))
|
||||
.or_else(|| plain(id))
|
||||
};
|
||||
match slot {
|
||||
EquipmentSlot::MainHand => e.main_hand_stack().cloned().or_else(|| plain(&e.main_hand)),
|
||||
EquipmentSlot::OffHand => plain(&e.off_hand),
|
||||
EquipmentSlot::Head if !body => worn(0, &e.head),
|
||||
EquipmentSlot::Chest => worn(1, &e.chest),
|
||||
EquipmentSlot::Legs => worn(2, &e.legs),
|
||||
EquipmentSlot::Feet => worn(3, &e.feet),
|
||||
// The body armour (a wolf's, a horse's, a llama's carpet) is
|
||||
// kept in `head`, with the durability it has lost.
|
||||
EquipmentSlot::Body if body => plain(&e.head).map(|s| ItemStack {
|
||||
damage: e.body_damage.max(0) as u32,
|
||||
..s
|
||||
}),
|
||||
EquipmentSlot::Saddle => self.saddle_item(),
|
||||
EquipmentSlot::Head | EquipmentSlot::Body => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mobs::Species;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn mob(id: &str, species: Species) -> Mob {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let k = Arc::make_mut(&mut kind);
|
||||
k.id = id.into();
|
||||
k.species = species;
|
||||
Mob::new(1, kind, Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
/// `LivingEntity.entityEventForEquipmentBreak`: 47 main hand, 48 off hand,
|
||||
/// 49 to 52 head to feet, 65 body, 68 saddle; other events break nothing.
|
||||
#[test]
|
||||
fn each_equipment_break_event_names_its_slot() {
|
||||
let slots = [
|
||||
(47, EquipmentSlot::MainHand),
|
||||
(48, EquipmentSlot::OffHand),
|
||||
(49, EquipmentSlot::Head),
|
||||
(50, EquipmentSlot::Chest),
|
||||
(51, EquipmentSlot::Legs),
|
||||
(52, EquipmentSlot::Feet),
|
||||
(65, EquipmentSlot::Body),
|
||||
(68, EquipmentSlot::Saddle),
|
||||
];
|
||||
for (event, slot) in slots {
|
||||
assert_eq!(equipment_break_slot(event), Some(slot), "event {event}");
|
||||
}
|
||||
for event in [2, 3, 35, 46, 53, 54, 55, 60, 64, 66, 67, 69] {
|
||||
assert_eq!(equipment_break_slot(event), None, "event {event}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The stack another player (an avatar, as a mannequin) holds or wears
|
||||
/// is the one that breaks.
|
||||
#[test]
|
||||
fn a_player_breaks_what_it_holds_and_wears() {
|
||||
let mut p = mob("minecraft:player", Species::Mannequin);
|
||||
let mut pick = ItemStack::new("minecraft:diamond_pickaxe", 1);
|
||||
pick.damage = 1560;
|
||||
p.extra.set_main_hand_stack(Some(pick.clone()));
|
||||
p.extra.off_hand = Some("minecraft:shield".into());
|
||||
p.extra.head = Some("minecraft:iron_helmet".into());
|
||||
let mut boots = ItemStack::new("minecraft:leather_boots", 1);
|
||||
boots.damage = 64;
|
||||
p.extra.feet = Some(boots.item.clone());
|
||||
p.extra.armor[3] = Some(boots.clone());
|
||||
let item = |slot| p.item_by_slot(slot).map(|s: ItemStack| (s.item, s.damage));
|
||||
assert_eq!(
|
||||
item(EquipmentSlot::MainHand),
|
||||
Some((pick.item.clone(), 1560))
|
||||
);
|
||||
assert_eq!(
|
||||
item(EquipmentSlot::OffHand),
|
||||
Some(("minecraft:shield".into(), 0))
|
||||
);
|
||||
assert_eq!(
|
||||
item(EquipmentSlot::Head),
|
||||
Some(("minecraft:iron_helmet".into(), 0))
|
||||
);
|
||||
assert_eq!(item(EquipmentSlot::Feet), Some((boots.item.clone(), 64)));
|
||||
assert_eq!(item(EquipmentSlot::Chest), None);
|
||||
assert_eq!(item(EquipmentSlot::Body), None);
|
||||
assert_eq!(item(EquipmentSlot::Saddle), None);
|
||||
}
|
||||
|
||||
/// A wolf's armour is its body slot (kept in `head`), and a horse's
|
||||
/// saddle its saddle slot.
|
||||
#[test]
|
||||
fn body_armour_and_saddles_break_from_their_slots() {
|
||||
let mut wolf = mob("minecraft:wolf", Species::Wolf);
|
||||
wolf.extra.head = Some("minecraft:wolf_armor".into());
|
||||
wolf.extra.body_damage = 63;
|
||||
let armour = wolf.item_by_slot(EquipmentSlot::Body).unwrap();
|
||||
assert_eq!((&*armour.item, armour.damage), ("minecraft:wolf_armor", 63));
|
||||
assert_eq!(wolf.item_by_slot(EquipmentSlot::Head), None);
|
||||
|
||||
let mut horse = mob("minecraft:horse", Species::Horse);
|
||||
horse.set_saddle(Some(ItemStack::new("minecraft:saddle", 1)));
|
||||
assert_eq!(
|
||||
horse.item_by_slot(EquipmentSlot::Saddle).map(|s| s.item),
|
||||
Some("minecraft:saddle".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user