From c57193aebac2f8b5dae2d08d1c91887d32f14b4c Mon Sep 17 00:00:00 2001 From: maxomatic458 <104733404+maxomatic458@users.noreply.github.com> Date: Wed, 23 Oct 2024 17:54:33 +0200 Subject: [PATCH] Add Equipment support (#663) # Objective - make valence work with entity equipment (armor, main/off hand items) # Solution adds the crate `valence_equipment` that exposes the Equipment Plugin. every `LivingEntity` will have a `Equipment` Component. The Equipment plugin will NOT be compatible with the inventory by default (thats intended, as that makes it possible to use the equipment feature without the inventory feature + I do think there are probably use cases where you want to make players appear as having armor, although they dont have it in their inventory) This PR would add Events when entities are (un)loaded by a player (used for sending equipment once the player loads an entity). I do believe this might also be a useful feature outside of the equipment feature. used #254 as reference + stole example idea fixes #662 Opening as a draft for now (might refactor the updating/event emitting system). Let me know what you think of the Entity Load/Unload events. --- Cargo.toml | 4 + assets/depgraph.svg | 362 ++++++------ crates/valence_equipment/Cargo.toml | 20 + crates/valence_equipment/README.md | 41 ++ .../valence_equipment/src/inventory_sync.rs | 92 +++ crates/valence_equipment/src/lib.rs | 257 +++++++++ crates/valence_inventory/src/lib.rs | 3 + crates/valence_server/src/client.rs | 97 +++- examples/equipment.rs | 140 +++++ src/lib.rs | 9 + src/tests.rs | 1 + src/tests/equipment.rs | 543 ++++++++++++++++++ 12 files changed, 1392 insertions(+), 177 deletions(-) create mode 100644 crates/valence_equipment/Cargo.toml create mode 100644 crates/valence_equipment/README.md create mode 100644 crates/valence_equipment/src/inventory_sync.rs create mode 100644 crates/valence_equipment/src/lib.rs create mode 100644 examples/equipment.rs create mode 100644 src/tests/equipment.rs diff --git a/Cargo.toml b/Cargo.toml index 1371871b4..aea1cee38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ default = [ "advancement", "anvil", "boss_bar", + "equipment", "inventory", "log", "network", @@ -32,6 +33,7 @@ default = [ advancement = ["dep:valence_advancement"] anvil = ["dep:valence_anvil"] boss_bar = ["dep:valence_boss_bar"] +equipment = ["dep:valence_equipment"] inventory = ["dep:valence_inventory"] log = ["dep:bevy_log"] network = ["dep:valence_network"] @@ -59,6 +61,7 @@ valence_command = { workspace = true, optional = true } valence_command_macros = { workspace = true, optional = true } valence_ident_macros.workspace = true valence_ident.workspace = true +valence_equipment = { workspace = true, optional = true } valence_inventory = { workspace = true, optional = true } valence_lang.workspace = true valence_network = { workspace = true, optional = true } @@ -192,6 +195,7 @@ valence_entity = { path = "crates/valence_entity", version = "0.2.0-alpha.1" } valence_generated = { path = "crates/valence_generated", version = "0.2.0-alpha.1" } valence_ident = { path = "crates/valence_ident", version = "0.2.0-alpha.1" } valence_ident_macros = { path = "crates/valence_ident_macros", version = "0.2.0-alpha.1" } +valence_equipment = { path = "crates/valence_equipment", version = "0.2.0-alpha.1" } valence_inventory = { path = "crates/valence_inventory", version = "0.2.0-alpha.1" } valence_lang = { path = "crates/valence_lang", version = "0.2.0-alpha.1" } valence_math = { path = "crates/valence_math", version = "0.2.0-alpha.1" } diff --git a/assets/depgraph.svg b/assets/depgraph.svg index 8060e3d12..0a9174593 100644 --- a/assets/depgraph.svg +++ b/assets/depgraph.svg @@ -4,16 +4,16 @@ - - + + %3 - + 0 - -java_string + +java_string @@ -24,140 +24,140 @@ 2 - -valence_server + +valence_server 1->2 - - + + 3 - -valence_entity + +valence_entity 2->3 - - + + 12 - -valence_registry + +valence_registry 2->12 - - + + 11 - -valence_server_common + +valence_server_common 3->11 - - + + 12->11 - - + + 9 - -valence_protocol + +valence_protocol 11->9 - - + + 4 - -valence_generated + +valence_generated 5 - -valence_ident + +valence_ident 4->5 - - + + 6 - -valence_math + +valence_math 4->6 - - + + 7 - -valence_build_utils + +valence_build_utils 8 - -valence_nbt + +valence_nbt 9->4 - - + + 10 - -valence_text + +valence_text 9->10 - - + + 10->5 - - + + 10->8 - - + + @@ -168,8 +168,8 @@ 13->2 - - + + @@ -180,8 +180,8 @@ 14->2 - - + + @@ -192,212 +192,224 @@ 15->2 - - + + 16 + +valence_equipment + + + +17 valence_inventory - + -16->2 - - +16->17 + + - - -17 - -valence_lang + + +17->2 + + 18 - -valence_network - - - -18->2 - - - - - -18->17 - - + +valence_lang 19 - -valence_player_list + +valence_network - + 19->2 - - + + + + + +19->18 + + 20 - -valence_scoreboard + +valence_player_list 20->2 - - + + 21 - -valence_spatial + +valence_scoreboard + + + +21->2 + + 22 - -valence_weather - - - -22->2 - - + +valence_spatial 23 - -valence_world_border + +valence_weather 23->2 - - + + 24 - -dump_schedule + +valence_world_border + + + +24->2 + + 25 - -valence + +dump_schedule - - -24->25 - - + + +26 + +valence - + -25->1 - - +25->26 + + - + -25->13 - - +26->1 + + - + -25->14 - - +26->13 + + - + -25->15 - - +26->14 + + - + -25->16 - - +26->15 + + - + -25->18 - - +26->16 + + - + -25->19 - - +26->19 + + - + -25->20 - - +26->20 + + - + -25->22 - - +26->21 + + - + -25->23 - - - - - -26 - -packet_inspector +26->23 + + - + -26->9 - - +26->24 + + 27 - -playground + +packet_inspector - + -27->25 - - +27->9 + + 28 - -stresser + +playground - + -28->9 - - +28->26 + + + + + +29 + +stresser + + + +29->9 + + diff --git a/crates/valence_equipment/Cargo.toml b/crates/valence_equipment/Cargo.toml new file mode 100644 index 000000000..81b1a3770 --- /dev/null +++ b/crates/valence_equipment/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "valence_equipment" +description = "Equipment support for Valence" +readme = "README.md" +version.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +bevy_app.workspace = true +bevy_ecs.workspace = true +derive_more.workspace = true +tracing.workspace = true +valence_server.workspace = true +valence_inventory.workspace = true diff --git a/crates/valence_equipment/README.md b/crates/valence_equipment/README.md new file mode 100644 index 000000000..651c26f21 --- /dev/null +++ b/crates/valence_equipment/README.md @@ -0,0 +1,41 @@ +# `valence_equipment` +Manages Minecraft's entity equipment (armor, held items) via the `Equipment` component. +By default this is separated from an entities `Inventory` (which means that changes are only visible to other players), but it can be synced by attaching the `EquipmentInventorySync` +component to a entity (currently only Players). + +## Example + +```rust +use bevy_ecs::prelude::*; +use valence_equipment::*; +use valence_server::{ + ItemStack, ItemKind, + entity::player::PlayerEntity, +}; +// Add equipment to players when they are added to the world. +fn init_equipment( + mut clients: Query< + &mut Equipment, + ( + Added, + With, + ), + >, +) { + for mut equipment in &mut clients + { + equipment.set_main_hand(ItemStack::new(ItemKind::DiamondSword, 1, None)); + equipment.set_off_hand(ItemStack::new(ItemKind::Shield, 1, None)); + equipment.set_feet(ItemStack::new(ItemKind::DiamondBoots, 1, None)); + equipment.set_legs(ItemStack::new(ItemKind::DiamondLeggings, 1, None)); + equipment.set_chest(ItemStack::new(ItemKind::DiamondChestplate, 1, None)); + equipment.set_head(ItemStack::new(ItemKind::DiamondHelmet, 1, None)); + } +} +``` + +### See also + +Examples related to inventories in the `valence/examples/` directory: +- `equipment` + diff --git a/crates/valence_equipment/src/inventory_sync.rs b/crates/valence_equipment/src/inventory_sync.rs new file mode 100644 index 000000000..1d1abf2c1 --- /dev/null +++ b/crates/valence_equipment/src/inventory_sync.rs @@ -0,0 +1,92 @@ +use valence_inventory::player_inventory::PlayerInventory; +use valence_inventory::{HeldItem, Inventory, UpdateSelectedSlotEvent}; +use valence_server::entity::player::PlayerEntity; + +use super::*; + +/// This component will sync a player's [`Equipment`], which is visible to other +/// players, with the player [`Inventory`]. +#[derive(Debug, Default, Clone, Component)] +pub struct EquipmentInventorySync; + +/// Syncs the player [`Equipment`] with the [`Inventory`]. +/// If a change in the player's inventory and in the equipment occurs in the +/// same tick, the equipment change has priority. +/// Note: This system only handles direct changes to the held item (not actual +/// changes from the client) see [`equipment_held_item_sync_from_client`] +pub(crate) fn equipment_inventory_sync( + mut clients: Query< + (&mut Equipment, &mut Inventory, &mut HeldItem), + ( + Or<(Changed, Changed, Changed)>, + With, + With, + ), + >, +) { + for (mut equipment, mut inventory, held_item) in &mut clients { + // Equipment change has priority over held item changes + if equipment.changed & (1 << Equipment::MAIN_HAND_IDX) != 0 { + let item = equipment.main_hand().clone(); + inventory.set_slot(held_item.slot(), item); + } else { + // If we change the inventory (e.g by pickung up an item) + // then the HeldItem slot wont be changed + + // This will only be called if we change the held item from valence, + // the client change is handled in `equipment_held_item_sync_from_client` + let item = inventory.slot(held_item.slot()).clone(); + equipment.set_main_hand(item); + } + + let slots = [ + (Equipment::OFF_HAND_IDX, PlayerInventory::SLOT_OFFHAND), + (Equipment::HEAD_IDX, PlayerInventory::SLOT_HEAD), + (Equipment::CHEST_IDX, PlayerInventory::SLOT_CHEST), + (Equipment::LEGS_IDX, PlayerInventory::SLOT_LEGS), + (Equipment::FEET_IDX, PlayerInventory::SLOT_FEET), + ]; + + // We cant rely on the changed bitfield of inventory here + // because that gets reset when the client changes the inventory + + for (equipment_slot, inventory_slot) in slots { + // Equipment has priority over inventory changes + if equipment.changed & (1 << equipment_slot) != 0 { + let item = equipment.slot(equipment_slot).clone(); + inventory.set_slot(inventory_slot, item); + } else if inventory.is_changed() { + let item = inventory.slot(inventory_slot).clone(); + equipment.set_slot(equipment_slot, item); + } + } + } +} + +/// Handles the case where the client changes the slot (the bevy change is +/// suppressed for this) +pub(crate) fn equipment_held_item_sync_from_client( + mut clients: Query<(&HeldItem, &Inventory, &mut Equipment), With>, + mut events: EventReader, +) { + for event in events.read() { + let Ok((held_item, inventory, mut equipment)) = clients.get_mut(event.client) else { + continue; + }; + + let item = inventory.slot(held_item.slot()).clone(); + equipment.set_main_hand(item); + } +} + +pub(crate) fn on_attach_inventory_sync( + entities: Query, (Added, With)>, +) { + for entity in &entities { + if entity.is_none() { + tracing::warn!( + "EquipmentInventorySync attached to non-player entity, this will have no effect" + ); + } + } +} diff --git a/crates/valence_equipment/src/lib.rs b/crates/valence_equipment/src/lib.rs new file mode 100644 index 000000000..a7e6b07c7 --- /dev/null +++ b/crates/valence_equipment/src/lib.rs @@ -0,0 +1,257 @@ +#![doc = include_str!("../README.md")] + +use bevy_app::prelude::*; +use bevy_ecs::prelude::*; +mod inventory_sync; +pub use inventory_sync::EquipmentInventorySync; +use valence_server::client::{Client, FlushPacketsSet, LoadEntityForClientEvent}; +use valence_server::entity::living::LivingEntity; +use valence_server::entity::{EntityId, EntityLayerId, Position}; +use valence_server::protocol::packets::play::entity_equipment_update_s2c::EquipmentEntry; +use valence_server::protocol::packets::play::EntityEquipmentUpdateS2c; +use valence_server::protocol::WritePacket; +use valence_server::{EntityLayer, ItemStack, Layer}; + +pub struct EquipmentPlugin; + +impl Plugin for EquipmentPlugin { + fn build(&self, app: &mut App) { + app.add_systems( + PreUpdate, + ( + on_entity_init, + inventory_sync::on_attach_inventory_sync, + inventory_sync::equipment_inventory_sync, + inventory_sync::equipment_held_item_sync_from_client, + ), + ) + .add_systems( + PostUpdate, + ( + update_equipment.before(FlushPacketsSet), + on_entity_load.before(FlushPacketsSet), + ), + ) + .add_event::(); + } +} + +/// Contains the visible equipment of a [`LivingEntity`], such as armor and held +/// items. By default this is not synced with a player's +/// [`valence_inventory::Inventory`], so the armor the player has equipped in +/// their inventory, will not be visible by other players. You would have to +/// change the equipment in this component here or attach the +/// [`EquipmentInventorySync`] component to the player entity to sync the +/// equipment with the inventory. +#[derive(Debug, Default, Clone, Component)] +pub struct Equipment { + equipment: [ItemStack; Self::SLOT_COUNT], + /// Contains a set bit for each modified slot in `slots`. + #[doc(hidden)] + pub(crate) changed: u8, +} + +impl Equipment { + pub const SLOT_COUNT: usize = 6; + + pub const MAIN_HAND_IDX: u8 = 0; + pub const OFF_HAND_IDX: u8 = 1; + pub const FEET_IDX: u8 = 2; + pub const LEGS_IDX: u8 = 3; + pub const CHEST_IDX: u8 = 4; + pub const HEAD_IDX: u8 = 5; + + pub fn new( + main_hand: ItemStack, + off_hand: ItemStack, + boots: ItemStack, + leggings: ItemStack, + chestplate: ItemStack, + helmet: ItemStack, + ) -> Self { + Self { + equipment: [main_hand, off_hand, boots, leggings, chestplate, helmet], + changed: 0, + } + } + + pub fn slot(&self, idx: u8) -> &ItemStack { + &self.equipment[idx as usize] + } + + pub fn set_slot(&mut self, idx: u8, item: ItemStack) { + assert!( + idx < Self::SLOT_COUNT as u8, + "slot index of {idx} out of bounds" + ); + if self.equipment[idx as usize] != item { + self.equipment[idx as usize] = item; + self.changed |= 1 << idx; + } + } + + pub fn main_hand(&self) -> &ItemStack { + self.slot(Self::MAIN_HAND_IDX) + } + + pub fn off_hand(&self) -> &ItemStack { + self.slot(Self::OFF_HAND_IDX) + } + + pub fn feet(&self) -> &ItemStack { + self.slot(Self::FEET_IDX) + } + + pub fn legs(&self) -> &ItemStack { + self.slot(Self::LEGS_IDX) + } + + pub fn chest(&self) -> &ItemStack { + self.slot(Self::CHEST_IDX) + } + + pub fn head(&self) -> &ItemStack { + self.slot(Self::HEAD_IDX) + } + + pub fn set_main_hand(&mut self, item: ItemStack) { + self.set_slot(Self::MAIN_HAND_IDX, item); + } + + pub fn set_off_hand(&mut self, item: ItemStack) { + self.set_slot(Self::OFF_HAND_IDX, item); + } + + pub fn set_feet(&mut self, item: ItemStack) { + self.set_slot(Self::FEET_IDX, item); + } + + pub fn set_legs(&mut self, item: ItemStack) { + self.set_slot(Self::LEGS_IDX, item); + } + + pub fn set_chest(&mut self, item: ItemStack) { + self.set_slot(Self::CHEST_IDX, item); + } + + pub fn set_head(&mut self, item: ItemStack) { + self.set_slot(Self::HEAD_IDX, item); + } + + pub fn clear(&mut self) { + for slot in 0..Self::SLOT_COUNT as u8 { + self.set_slot(slot, ItemStack::EMPTY); + } + } + + pub fn is_default(&self) -> bool { + self.equipment.iter().all(|item| item.is_empty()) + } +} + +#[derive(Debug, Clone)] +pub struct EquipmentSlotChange { + idx: u8, + stack: ItemStack, +} + +#[derive(Debug, Clone, Event)] +pub struct EquipmentChangeEvent { + pub client: Entity, + pub changed: Vec, +} + +fn update_equipment( + mut clients: Query< + (Entity, &EntityId, &EntityLayerId, &Position, &mut Equipment), + Changed, + >, + mut event_writer: EventWriter, + mut entity_layer: Query<&mut EntityLayer>, +) { + for (entity, entity_id, entity_layer_id, position, mut equipment) in &mut clients { + let Ok(mut entity_layer) = entity_layer.get_mut(entity_layer_id.0) else { + continue; + }; + + if equipment.changed != 0 { + let mut slots_changed: Vec = + Vec::with_capacity(Equipment::SLOT_COUNT); + + for slot in 0..Equipment::SLOT_COUNT { + if equipment.changed & (1 << slot) != 0 { + slots_changed.push(EquipmentSlotChange { + idx: slot as u8, + stack: equipment.equipment[slot].clone(), + }); + } + } + + entity_layer + .view_except_writer(position.0, entity) + .write_packet(&EntityEquipmentUpdateS2c { + entity_id: entity_id.get().into(), + equipment: slots_changed + .iter() + .map(|change| EquipmentEntry { + slot: change.idx as i8, + item: change.stack.clone(), + }) + .collect(), + }); + + event_writer.send(EquipmentChangeEvent { + client: entity, + changed: slots_changed, + }); + + equipment.changed = 0; + } + } +} + +/// Gets called when the player loads an entity, for example +/// when the player gets in range of the entity. +fn on_entity_load( + mut clients: Query<&mut Client>, + entities: Query<(&EntityId, &Equipment)>, + mut events: EventReader, +) { + for event in events.read() { + let Ok(mut client) = clients.get_mut(event.client) else { + continue; + }; + + let Ok((entity_id, equipment)) = entities.get(event.entity_loaded) else { + continue; + }; + + if equipment.is_default() { + continue; + } + + let mut entries: Vec = Vec::with_capacity(Equipment::SLOT_COUNT); + for (idx, stack) in equipment.equipment.iter().enumerate() { + entries.push(EquipmentEntry { + slot: idx as i8, + item: stack.clone(), + }); + } + + client.write_packet(&EntityEquipmentUpdateS2c { + entity_id: entity_id.get().into(), + equipment: entries, + }); + } +} + +/// Add a default equipment component to all living entities when they are +/// initialized. +fn on_entity_init( + mut commands: Commands, + mut entities: Query, Without)>, +) { + for entity in &mut entities { + commands.entity(entity).insert(Equipment::default()); + } +} diff --git a/crates/valence_inventory/src/lib.rs b/crates/valence_inventory/src/lib.rs index 42a20e28c..b05d13c33 100644 --- a/crates/valence_inventory/src/lib.rs +++ b/crates/valence_inventory/src/lib.rs @@ -1436,6 +1436,9 @@ fn handle_update_selected_slot( for packet in packets.read() { if let Some(pkt) = packet.decode::() { if let Ok(mut mut_held) = clients.get_mut(packet.client) { + // We bypass the change detection here because the server listens for changes + // of `HeldItem` in order to send the update to the client. + // This is not required here because the update is coming from the client. let held = mut_held.bypass_change_detection(); if pkt.slot > 8 { // The client is trying to interact with a slot that does not exist, ignore. diff --git a/crates/valence_server/src/client.rs b/crates/valence_server/src/client.rs index e4dbf41e7..39da06e77 100644 --- a/crates/valence_server/src/client.rs +++ b/crates/valence_server/src/client.rs @@ -100,7 +100,9 @@ impl Plugin for ClientPlugin { ClearEntityChangesSet.after(UpdateClientsSet), FlushPacketsSet, ), - ); + ) + .add_event::() + .add_event::(); } } @@ -878,6 +880,26 @@ fn handle_layer_messages( ); } +/// This event will be emitted when a entity is unloaded for a client (e.g when +/// moving out of range of the entity). +#[derive(Debug, Clone, PartialEq, Event)] +pub struct UnloadEntityForClientEvent { + /// The client to unload the entity for. + pub client: Entity, + /// The entity ID of the entity that will be unloaded. + pub entity_unloaded: Entity, +} + +/// This event will be emitted when a entity is loaded for a client (e.g when +/// moving into range of the entity). +#[derive(Debug, Clone, PartialEq, Event)] +pub struct LoadEntityForClientEvent { + /// The client to load the entity for. + pub client: Entity, + /// The entity that will be loaded. + pub entity_loaded: Entity, +} + pub(crate) fn update_view_and_layers( mut clients: Query< ( @@ -904,8 +926,19 @@ pub(crate) fn update_view_and_layers( entity_layers: Query<&EntityLayer>, entity_ids: Query<&EntityId>, entity_init: Query<(EntityInitQuery, &Position)>, + + mut unload_entity_writer: EventWriter, + mut load_entity_writer: EventWriter, ) { - clients.par_iter_mut().for_each( + // Wrap the events in this, so we only need one channel. + enum ChannelEvent { + UnloadEntity(UnloadEntityForClientEvent), + LoadEntity(LoadEntityForClientEvent), + } + + let (tx, rx) = std::sync::mpsc::channel(); + + (clients).par_iter_mut().for_each( |( self_entity, mut client, @@ -962,6 +995,14 @@ pub(crate) fn update_view_and_layers( for entity in layer.entities_at(pos) { if self_entity != entity { if let Ok(id) = entity_ids.get(entity) { + tx.send(ChannelEvent::UnloadEntity( + UnloadEntityForClientEvent { + client: self_entity, + entity_unloaded: entity, + }, + )) + .unwrap(); + remove_buf.push(id.get()); } } @@ -979,6 +1020,14 @@ pub(crate) fn update_view_and_layers( for entity in layer.entities_at(pos) { if self_entity != entity { if let Ok((init, pos)) = entity_init.get(entity) { + tx.send(ChannelEvent::LoadEntity( + LoadEntityForClientEvent { + client: self_entity, + entity_loaded: entity, + }, + )) + .unwrap(); + init.write_init_packets(pos.get(), &mut *client); } } @@ -999,6 +1048,14 @@ pub(crate) fn update_view_and_layers( for entity in layer.entities_at(pos) { if self_entity != entity { if let Ok(id) = entity_ids.get(entity) { + tx.send(ChannelEvent::UnloadEntity( + UnloadEntityForClientEvent { + client: self_entity, + entity_unloaded: entity, + }, + )) + .unwrap(); + remove_buf.push(id.get()); } } @@ -1019,6 +1076,14 @@ pub(crate) fn update_view_and_layers( for entity in layer.entities_at(pos) { if self_entity != entity { if let Ok((init, pos)) = entity_init.get(entity) { + tx.send(ChannelEvent::LoadEntity( + LoadEntityForClientEvent { + client: self_entity, + entity_loaded: entity, + }, + )) + .unwrap(); + init.write_init_packets(pos.get(), &mut *client); } } @@ -1061,6 +1126,14 @@ pub(crate) fn update_view_and_layers( for entity in layer.entities_at(pos) { if self_entity != entity { if let Ok(id) = entity_ids.get(entity) { + tx.send(ChannelEvent::UnloadEntity( + UnloadEntityForClientEvent { + client: self_entity, + entity_unloaded: entity, + }, + )) + .unwrap(); + remove_buf.push(id.get()); } } @@ -1076,6 +1149,14 @@ pub(crate) fn update_view_and_layers( for entity in layer.entities_at(pos) { if self_entity != entity { if let Ok((init, pos)) = entity_init.get(entity) { + tx.send(ChannelEvent::LoadEntity( + LoadEntityForClientEvent { + client: self_entity, + entity_loaded: entity, + }, + )) + .unwrap(); + init.write_init_packets(pos.get(), &mut *client); } } @@ -1097,6 +1178,18 @@ pub(crate) fn update_view_and_layers( } }, ); + + // Send the events. + for event in rx.try_iter() { + match event { + ChannelEvent::UnloadEntity(event) => { + unload_entity_writer.send(event); + } + ChannelEvent::LoadEntity(event) => { + load_entity_writer.send(event); + } + }; + } } pub(crate) fn update_game_mode(mut clients: Query<(&mut Client, &GameMode), Changed>) { diff --git a/examples/equipment.rs b/examples/equipment.rs new file mode 100644 index 000000000..4450c2052 --- /dev/null +++ b/examples/equipment.rs @@ -0,0 +1,140 @@ +#![allow(clippy::type_complexity)] + +const SPAWN_Y: i32 = 64; + +use rand::Rng; +use valence::entity::armor_stand::ArmorStandEntityBundle; +use valence::entity::zombie::ZombieEntityBundle; +use valence::prelude::*; +use valence_equipment::EquipmentInventorySync; + +pub fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .add_systems(Update, (despawn_disconnected_clients,)) + .add_systems( + Update, + ( + init_clients, + despawn_disconnected_clients, + randomize_equipment, + ), + ) + .run(); +} + +fn setup( + mut commands: Commands, + server: Res, + dimensions: Res, + biomes: Res, +) { + let mut layer = LayerBundle::new(ident!("overworld"), &dimensions, &biomes, &server); + + for z in -5..5 { + for x in -5..5 { + layer.chunk.insert_chunk([x, z], UnloadedChunk::new()); + } + } + + for z in -25..25 { + for x in -25..25 { + layer + .chunk + .set_block([x, SPAWN_Y, z], BlockState::GRASS_BLOCK); + } + } + + let layer_id = commands.spawn(layer).id(); + + commands.spawn(ZombieEntityBundle { + position: Position::new(DVec3::new(0.0, f64::from(SPAWN_Y) + 1.0, 0.0)), + layer: EntityLayerId(layer_id), + ..Default::default() + }); + + commands.spawn(ArmorStandEntityBundle { + position: Position::new(DVec3::new(1.0, f64::from(SPAWN_Y) + 1.0, 0.0)), + layer: EntityLayerId(layer_id), + ..Default::default() + }); +} + +fn init_clients( + mut commands: Commands, + mut clients: Query< + ( + Entity, + &mut Position, + &mut EntityLayerId, + &mut VisibleChunkLayer, + &mut VisibleEntityLayers, + &mut GameMode, + ), + Added, + >, + layers: Query, With)>, +) { + for ( + player, + mut pos, + mut layer_id, + mut visible_chunk_layer, + mut visible_entity_layers, + mut game_mode, + ) in &mut clients + { + let layer = layers.single(); + + pos.0 = [0.0, f64::from(SPAWN_Y) + 1.0, 0.0].into(); + layer_id.0 = layer; + visible_chunk_layer.0 = layer; + visible_entity_layers.0.insert(layer); + *game_mode = GameMode::Survival; + + commands.entity(player).insert(EquipmentInventorySync); + } +} + +fn randomize_equipment(mut query: Query<&mut Equipment>, server: Res) { + let ticks = server.current_tick() as u32; + // every second + if ticks % server.tick_rate() != 0 { + return; + } + + for mut equipment in &mut query { + equipment.clear(); + + let (slot, item_stack) = match rand::thread_rng().gen_range(0..=5) { + 0 => ( + Equipment::MAIN_HAND_IDX, + ItemStack::new(ItemKind::DiamondSword, 1, None), + ), + 1 => ( + Equipment::OFF_HAND_IDX, + ItemStack::new(ItemKind::Shield, 1, None), + ), + 2 => ( + Equipment::FEET_IDX, + ItemStack::new(ItemKind::DiamondBoots, 1, None), + ), + 3 => ( + Equipment::LEGS_IDX, + ItemStack::new(ItemKind::DiamondLeggings, 1, None), + ), + 4 => ( + Equipment::CHEST_IDX, + ItemStack::new(ItemKind::DiamondChestplate, 1, None), + ), + 5 => ( + Equipment::HEAD_IDX, + ItemStack::new(ItemKind::DiamondHelmet, 1, None), + ), + _ => unreachable!(), + }; + + equipment.set_slot(slot, item_stack); + } +} diff --git a/src/lib.rs b/src/lib.rs index d8049a5cf..a8e7d12df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,6 +47,8 @@ pub use valence_boss_bar as boss_bar; pub use valence_command as command; #[cfg(feature = "command")] pub use valence_command_macros as command_macros; +#[cfg(feature = "equipment")] +pub use valence_equipment as equipment; #[cfg(feature = "inventory")] pub use valence_inventory as inventory; pub use valence_lang as lang; @@ -108,6 +110,8 @@ pub mod prelude { event::AdvancementTabChangeEvent, Advancement, AdvancementBundle, AdvancementClientUpdate, AdvancementCriteria, AdvancementDisplay, AdvancementFrameType, AdvancementRequirements, }; + #[cfg(feature = "equipment")] + pub use valence_equipment::Equipment; #[cfg(feature = "inventory")] pub use valence_inventory::{ CursorItem, Inventory, InventoryKind, InventoryWindow, InventoryWindowMut, OpenInventory, @@ -214,6 +218,11 @@ impl PluginGroup for DefaultPlugins { group = group.add(valence_player_list::PlayerListPlugin) } + #[cfg(feature = "equipment")] + { + group = group.add(valence_equipment::EquipmentPlugin) + } + #[cfg(feature = "inventory")] { group = group.add(valence_inventory::InventoryPlugin) diff --git a/src/tests.rs b/src/tests.rs index e82971210..f1a916a05 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,5 +1,6 @@ mod boss_bar; mod client; +mod equipment; mod example; mod hunger; mod inventory; diff --git a/src/tests/equipment.rs b/src/tests/equipment.rs new file mode 100644 index 000000000..7460b78f2 --- /dev/null +++ b/src/tests/equipment.rs @@ -0,0 +1,543 @@ +use valence_equipment::{Equipment, EquipmentInventorySync}; +use valence_inventory::player_inventory::PlayerInventory; +use valence_inventory::{ClickMode, ClientInventoryState, Inventory, SlotChange}; +use valence_server::entity::armor_stand::ArmorStandEntityBundle; +use valence_server::entity::item::ItemEntityBundle; +use valence_server::entity::zombie::ZombieEntityBundle; +use valence_server::entity::{EntityLayerId, Position}; +use valence_server::math::DVec3; +use valence_server::protocol::packets::play::{ + ClickSlotC2s, EntityEquipmentUpdateS2c, UpdateSelectedSlotC2s, +}; +use valence_server::{ItemKind, ItemStack}; + +use crate::testing::ScenarioSingleClient; + +#[test] +fn test_only_send_update_to_other_players() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + let mut player_equipment = app + .world_mut() + .get_mut::(client) + .expect("could not get player equipment"); + + player_equipment.set_chest(ItemStack::new(ItemKind::DiamondChestplate, 1, None)); + + app.update(); + + // Make assertions + let sent_packets = helper.collect_received(); + + // We only have one player, so we should not have sent any packets. + sent_packets.assert_count::(0); +} + +#[test] +fn test_multiple_entities() { + let ScenarioSingleClient { + mut app, + mut helper, + layer, + .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + let zombie_bundle = ZombieEntityBundle { + layer: EntityLayerId(layer), + ..Default::default() + }; + + let zombie = app.world_mut().spawn(zombie_bundle).id(); + + app.update(); + + let mut equipment = app + .world_mut() + .get_mut::(zombie) + .expect("could not get entity equipment"); + + equipment.set_chest(ItemStack::new(ItemKind::DiamondChestplate, 1, None)); + equipment.set_head(ItemStack::new(ItemKind::DiamondHelmet, 1, None)); + + app.update(); + + // Make assertions + let sent_packets = helper.collect_received(); + sent_packets.assert_count::(1); + + helper.clear_received(); + + let mut equipment = app + .world_mut() + .get_mut::(zombie) + .expect("could not get entity equipment"); + + // Set the zombie's equipment to the same items + equipment.set_chest(ItemStack::new(ItemKind::DiamondChestplate, 1, None)); + equipment.set_head(ItemStack::new(ItemKind::DiamondHelmet, 1, None)); + + app.update(); + + // Make assertions + let sent_packets = helper.collect_received(); + sent_packets.assert_count::(0); +} + +#[test] +fn test_update_on_load_entity() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + layer, + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + let zombie_bundle = ZombieEntityBundle { + layer: EntityLayerId(layer), + position: Position::new(DVec3::new(1000.0, 0.0, 1000.0)), + ..Default::default() + }; + + let zombie = app + .world_mut() + .spawn(zombie_bundle) + .insert(Equipment::default()) + .id(); + + app.update(); + + let mut equipment = app + .world_mut() + .get_mut::(zombie) + .expect("could not get entity equipment"); + + equipment.set_chest(ItemStack::new(ItemKind::DiamondChestplate, 1, None)); + equipment.set_head(ItemStack::new(ItemKind::DiamondHelmet, 1, None)); + + app.update(); + + // Make assertions + let sent_packets = helper.collect_received(); + // The zombie is not in range of the player + sent_packets.assert_count::(0); + + // Move the player to the zombie + let mut player_pos = app + .world_mut() + .get_mut::(client) + .expect("could not get player position"); + + player_pos.0 = DVec3::new(1000.0, 0.0, 1000.0); + + // 1 tick for the tp, 1 tick for loading the entity (i think) + app.update(); + app.update(); + + // Make assertions + let sent_packets = helper.collect_received(); + // Once the player is in range, we send the equipment update + sent_packets.assert_count::(1); +} + +#[test] +fn test_skip_update_for_empty_equipment() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + layer, + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + let zombie_bundle = ZombieEntityBundle { + layer: EntityLayerId(layer), + position: Position::new(DVec3::new(1000.0, 0.0, 1000.0)), + ..Default::default() + }; + + app.world_mut() + .spawn(zombie_bundle) + .insert(Equipment::default()); + + app.update(); + + // Make assertions + let sent_packets = helper.collect_received(); + // The zombie is not in range of the player + sent_packets.assert_count::(0); + + // Move the player to the zombie + let mut player_pos = app + .world_mut() + .get_mut::(client) + .expect("could not get player position"); + + player_pos.0 = DVec3::new(1000.0, 0.0, 1000.0); + + // 1 tick for the tp, 1 tick for loading the entity (i think) + app.update(); + app.update(); + + // Make assertions + let sent_packets = helper.collect_received(); + // We skip the packet, because the equipment is empty + sent_packets.assert_count::(0); +} + +#[test] +fn test_ensure_living_entities_only() { + let ScenarioSingleClient { + mut app, + mut helper, + layer, + .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + let zombie_bundle = ZombieEntityBundle { + layer: EntityLayerId(layer), + ..Default::default() + }; + + let armor_stand_bundle = ArmorStandEntityBundle { + layer: EntityLayerId(layer), + ..Default::default() + }; + + let item_bundle = ItemEntityBundle { + layer: EntityLayerId(layer), + ..Default::default() + }; + + let zombie = app.world_mut().spawn(zombie_bundle).id(); + + let armor_stand = app.world_mut().spawn(armor_stand_bundle).id(); + + let item = app.world_mut().spawn(item_bundle).id(); + + app.update(); + + let zombie_equipment = app.world_mut().get_mut::(zombie); + assert!(zombie_equipment.is_some()); + + let armor_stand_equipment = app.world_mut().get_mut::(armor_stand); + assert!(armor_stand_equipment.is_some()); + + let item_equipment = app.world_mut().get_mut::(item); + assert!(item_equipment.is_none()); +} + +#[test] +fn test_inventory_sync_from_equipment() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + app.world_mut() + .entity_mut(client) + .insert(EquipmentInventorySync); + + let mut player_equipment = app + .world_mut() + .get_mut::(client) + .expect("could not get player equipment"); + + player_equipment.set_chest(ItemStack::new(ItemKind::DiamondChestplate, 1, None)); + + app.update(); + + let player_inventory = app + .world() + .get::(client) + .expect("could not get player equipment"); + + let player_equipment = app + .world() + .get::(client) + .expect("could not get player equipment"); + + // The inventory should have been updated + // after the equipment change + assert_eq!( + *player_inventory.slot(PlayerInventory::SLOT_CHEST), + ItemStack::new(ItemKind::DiamondChestplate, 1, None) + ); + + assert_eq!( + *player_equipment.chest(), + ItemStack::new(ItemKind::DiamondChestplate, 1, None) + ); +} + +#[test] +fn test_equipment_sync_from_inventory() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + app.world_mut() + .entity_mut(client) + .insert(EquipmentInventorySync); + + let mut player_inventory = app + .world_mut() + .get_mut::(client) + .expect("could not get player equipment"); + + player_inventory.set_slot( + PlayerInventory::SLOT_CHEST, + ItemStack::new(ItemKind::DiamondChestplate, 1, None), + ); + + app.update(); + + let player_inventory = app + .world() + .get::(client) + .expect("could not get player equipment"); + + let player_equipment = app + .world() + .get::(client) + .expect("could not get player equipment"); + + // The equipment should have been updated + // after the inventory change + assert_eq!( + *player_inventory.slot(PlayerInventory::SLOT_CHEST), + ItemStack::new(ItemKind::DiamondChestplate, 1, None) + ); + + assert_eq!( + *player_equipment.chest(), + ItemStack::new(ItemKind::DiamondChestplate, 1, None) + ); +} + +#[test] +fn test_equipment_priority_over_inventory() { + let ScenarioSingleClient { + mut app, client, .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + + app.world_mut() + .entity_mut(client) + .insert(EquipmentInventorySync); + + let mut player_inventory = app + .world_mut() + .get_mut::(client) + .expect("could not get player equipment"); + + // Set the slot in the inventory as well as in the equipment in the same tick + + player_inventory.set_slot( + PlayerInventory::SLOT_CHEST, + ItemStack::new(ItemKind::DiamondChestplate, 1, None), + ); + + let mut player_equipment = app + .world_mut() + .get_mut::(client) + .expect("could not get player equipment"); + + player_equipment.set_chest(ItemStack::new(ItemKind::GoldenChestplate, 1, None)); + + app.update(); + + // The equipment change should have priority, the inventory change is ignored + + let player_inventory = app + .world() + .get::(client) + .expect("could not get player equipment"); + + let player_equipment = app + .world() + .get::(client) + .expect("could not get player equipment"); + + assert_eq!( + *player_inventory.slot(PlayerInventory::SLOT_CHEST), + ItemStack::new(ItemKind::GoldenChestplate, 1, None) + ); + + assert_eq!( + *player_equipment.chest(), + ItemStack::new(ItemKind::GoldenChestplate, 1, None) + ); +} + +#[test] +fn test_equipment_change_from_player() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + app.world_mut() + .entity_mut(client) + .insert(EquipmentInventorySync); + + let mut player_inventory = app + .world_mut() + .get_mut::(client) + .expect("could not get player equipment"); + + player_inventory.set_slot(36, ItemStack::new(ItemKind::DiamondChestplate, 1, None)); + app.update(); + helper.clear_received(); + + let state_id = app + .world() + .get::(client) + .expect("could not get player equipment") + .state_id(); + + app.update(); + + helper.send(&ClickSlotC2s { + window_id: 0, + button: 0, + mode: ClickMode::Hotbar, + state_id: state_id.0.into(), + slot_idx: 36, + slot_changes: vec![ + SlotChange { + idx: 36, + stack: ItemStack::EMPTY, + }, + SlotChange { + idx: PlayerInventory::SLOT_CHEST as i16, + stack: ItemStack::new(ItemKind::DiamondChestplate, 1, None), + }, + ] + .into(), + carried_item: ItemStack::EMPTY, + }); + + app.update(); + app.update(); + + let player_inventory = app + .world() + .get::(client) + .expect("could not get player equipment"); + + let player_equipment = app + .world() + .get::(client) + .expect("could not get player equipment"); + + assert_eq!( + player_inventory.slot(PlayerInventory::SLOT_CHEST), + &ItemStack::new(ItemKind::DiamondChestplate, 1, None) + ); + + assert_eq!(player_inventory.slot(36), &ItemStack::EMPTY); + + assert_eq!( + player_equipment.chest(), + &ItemStack::new(ItemKind::DiamondChestplate, 1, None) + ); +} + +#[test] +fn test_held_item_change_from_client() { + let ScenarioSingleClient { + mut app, + client, + mut helper, + .. + } = ScenarioSingleClient::new(); + + // Process a tick to get past the "on join" logic. + app.update(); + helper.clear_received(); + + app.world_mut() + .entity_mut(client) + .insert(EquipmentInventorySync); + + let mut player_inventory = app + .world_mut() + .get_mut::(client) + .expect("could not get player equipment"); + + player_inventory.set_slot(36, ItemStack::new(ItemKind::DiamondSword, 1, None)); + player_inventory.set_slot(37, ItemStack::new(ItemKind::IronSword, 1, None)); + + app.update(); + + let player_equipment = app + .world() + .get::(client) + .expect("could not get player equipment"); + + assert_eq!( + player_equipment.main_hand(), + &ItemStack::new(ItemKind::DiamondSword, 1, None) + ); + + // Change the held item from the client + helper.send(&UpdateSelectedSlotC2s { slot: 1 }); + + app.update(); // handle change slot + app.update(); // handle change equipment + + let player_equipment = app + .world() + .get::(client) + .expect("could not get player equipment"); + + assert_eq!( + player_equipment.main_hand(), + &ItemStack::new(ItemKind::IronSword, 1, None) + ); +}