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 19841bfa0..5918ce0b2 100644 --- a/crates/valence_inventory/src/lib.rs +++ b/crates/valence_inventory/src/lib.rs @@ -1408,6 +1408,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) + ); +}