From 4574e18d499da7dda5d8a40fda785818a9e44bea Mon Sep 17 00:00:00 2001 From: Carson McManus Date: Mon, 19 Sep 2022 14:29:41 -0400 Subject: [PATCH] inventory client events (#66) * finish packet definition for ClickContainer * add CloseScreen client event * add DropItem client event * add SetSlotCreative client event * implement ClientEvent::CloseScreen * ClientEvent::DropItem * implement ClientEvent::SetSlotCreative * cargo fmt * add inventory_piano example to demo inventory slot click events * lints * implement ClickContainer event * inventory_piano: deduplicate note playing logic * add DropItemStack client event * implement ClientEvent::DropItemStack * adjust logging * tweak inventory_piano example, send text to chat instead of stdout * fix lint * move Slot outside of protocol module * avoid cloning slot in ClickContainer packet handler * fix inventory_piano example --- examples/inventory_piano.rs | 249 ++++++++++++++++++++++++++++++++++++ src/client.rs | 67 +++++++++- src/client/event.rs | 51 ++++++++ src/lib.rs | 1 + src/protocol.rs | 2 - src/protocol/packets.rs | 3 +- src/protocol/packets/c2s.rs | 3 + src/{protocol => }/slot.rs | 2 + 8 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 examples/inventory_piano.rs rename src/{protocol => }/slot.rs (99%) diff --git a/examples/inventory_piano.rs b/examples/inventory_piano.rs new file mode 100644 index 0000000..f28ea39 --- /dev/null +++ b/examples/inventory_piano.rs @@ -0,0 +1,249 @@ +use std::net::SocketAddr; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use log::LevelFilter; +use num::Integer; +use valence::block::BlockState; +use valence::chunk::{Chunk, UnloadedChunk}; +use valence::client::{handle_event_default, Client, ClientEvent, GameMode}; +use valence::config::{Config, ServerListPing}; +use valence::dimension::{Dimension, DimensionId}; +use valence::entity::{Entity, EntityId, EntityKind}; +use valence::player_list::PlayerListId; +use valence::protocol::packets::c2s::play::ClickContainerMode; +use valence::protocol::packets::s2c::play::SoundCategory; +use valence::server::{Server, SharedServer, ShutdownResult}; +use valence::slot::SlotId; +use valence::text::{Color, TextFormat}; +use valence::{async_trait, ident}; + +pub fn main() -> ShutdownResult { + env_logger::Builder::new() + .filter_module("valence", LevelFilter::Trace) + .parse_default_env() + .init(); + + valence::start_server( + Game { + player_count: AtomicUsize::new(0), + }, + ServerState { player_list: None }, + ) +} + +struct Game { + player_count: AtomicUsize, +} + +struct ServerState { + player_list: Option, +} + +#[derive(Default)] +struct ClientState { + entity_id: EntityId, +} + +const MAX_PLAYERS: usize = 10; + +const SIZE_X: usize = 100; +const SIZE_Z: usize = 100; + +const SLOT_MIN: SlotId = 36; +const SLOT_MAX: SlotId = 43; +const PITCH_MIN: f32 = 0.5; +const PITCH_MAX: f32 = 1.0; + +#[async_trait] +impl Config for Game { + type ServerState = ServerState; + type ClientState = ClientState; + type EntityState = (); + type WorldState = (); + type ChunkState = (); + type PlayerListState = (); + + fn max_connections(&self) -> usize { + // We want status pings to be successful even if the server is full. + MAX_PLAYERS + 64 + } + + fn dimensions(&self) -> Vec { + vec![Dimension { + fixed_time: Some(6000), + ..Dimension::default() + }] + } + + async fn server_list_ping( + &self, + _server: &SharedServer, + _remote_addr: SocketAddr, + _protocol_version: i32, + ) -> ServerListPing { + ServerListPing::Respond { + online_players: self.player_count.load(Ordering::SeqCst) as i32, + max_players: MAX_PLAYERS as i32, + player_sample: Default::default(), + description: "Hello Valence!".color(Color::AQUA), + favicon_png: Some(include_bytes!("../assets/logo-64x64.png").as_slice().into()), + } + } + + fn init(&self, server: &mut Server) { + let world = server.worlds.insert(DimensionId::default(), ()).1; + server.state.player_list = Some(server.player_lists.insert(()).0); + + // initialize chunks + for chunk_z in -2..Integer::div_ceil(&(SIZE_Z as i32), &16) + 2 { + for chunk_x in -2..Integer::div_ceil(&(SIZE_X as i32), &16) + 2 { + world.chunks.insert( + [chunk_x as i32, chunk_z as i32], + UnloadedChunk::default(), + (), + ); + } + } + + // initialize blocks in the chunks + for chunk_x in 0..Integer::div_ceil(&SIZE_X, &16) { + for chunk_z in 0..Integer::div_ceil(&SIZE_Z, &16) { + let chunk = world + .chunks + .get_mut((chunk_x as i32, chunk_z as i32)) + .unwrap(); + for x in 0..16 { + for z in 0..16 { + let cell_x = chunk_x * 16 + x; + let cell_z = chunk_z * 16 + z; + + if cell_x < SIZE_X && cell_z < SIZE_Z { + chunk.set_block_state(x, 63, z, BlockState::GRASS_BLOCK); + } + } + } + } + } + } + + fn update(&self, server: &mut Server) { + let (world_id, _) = server.worlds.iter_mut().next().unwrap(); + + let spawn_pos = [SIZE_X as f64 / 2.0, 1.0, SIZE_Z as f64 / 2.0]; + + server.clients.retain(|_, client| { + if client.created_this_tick() { + if self + .player_count + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| { + (count < MAX_PLAYERS).then_some(count + 1) + }) + .is_err() + { + client.disconnect("The server is full!".color(Color::RED)); + return false; + } + + match server + .entities + .insert_with_uuid(EntityKind::Player, client.uuid(), ()) + { + Some((id, _)) => client.state.entity_id = id, + None => { + client.disconnect("Conflicting UUID"); + return false; + } + } + + client.spawn(world_id); + client.set_flat(true); + client.teleport(spawn_pos, 0.0, 0.0); + client.set_player_list(server.state.player_list.clone()); + + if let Some(id) = &server.state.player_list { + server.player_lists.get_mut(id).insert( + client.uuid(), + client.username(), + client.textures().cloned(), + client.game_mode(), + 0, + None, + ); + } + + client.set_game_mode(GameMode::Creative); + client.send_message( + "Welcome to Valence! Open your inventory, and click on your hotbar to play \ + the piano." + .italic(), + ); + client.send_message( + "Click the rightmost hotbar slot to toggle between creative and survival." + .italic(), + ); + } + + if client.is_disconnected() { + self.player_count.fetch_sub(1, Ordering::SeqCst); + server.entities.remove(client.state.entity_id); + if let Some(id) = &server.state.player_list { + server.player_lists.get_mut(id).remove(client.uuid()); + } + return false; + } + + let player = server.entities.get_mut(client.state.entity_id).unwrap(); + + if client.position().y <= -20.0 { + client.teleport(spawn_pos, client.yaw(), client.pitch()); + } + + while let Some(event) = handle_event_default(client, player) { + match event { + ClientEvent::CloseScreen { .. } => { + client.send_message("Done already?"); + } + ClientEvent::SetSlotCreative { slot_id, .. } => { + client.send_message(format!("{:#?}", event)); + // If the user does a double click, 3 notes will be played. + // This is not possible to fix :( + play_note(client, player, slot_id); + } + ClientEvent::ClickContainer { slot_id, mode, .. } => { + client.send_message(format!("{:#?}", event)); + if mode != ClickContainerMode::Click { + // Prevent notes from being played twice if the user clicks quickly + continue; + } + play_note(client, player, slot_id); + } + _ => {} + } + } + + true + }); + } +} + +fn play_note(client: &mut Client, player: &mut Entity, clicked_slot: SlotId) { + if (SLOT_MIN..=SLOT_MAX).contains(&clicked_slot) { + let pitch = (clicked_slot - SLOT_MIN) as f32 * (PITCH_MAX - PITCH_MIN) + / (SLOT_MAX - SLOT_MIN) as f32 + + PITCH_MIN; + client.send_message(format!("playing note with pitch: {}", pitch)); + client.play_sound( + ident!("block.note_block.harp"), + SoundCategory::Block, + player.position(), + 10.0, + pitch, + ); + } else if clicked_slot == 44 { + client.set_game_mode(match client.game_mode() { + GameMode::Survival => GameMode::Creative, + GameMode::Creative => GameMode::Survival, + _ => GameMode::Creative, + }); + } +} diff --git a/src/client.rs b/src/client.rs index 77d42f1..0433565 100644 --- a/src/client.rs +++ b/src/client.rs @@ -40,6 +40,7 @@ use crate::protocol::packets::s2c::play::{ use crate::protocol::{BoundedInt, BoundedString, ByteAngle, NbtBridge, RawBytes, VarInt}; use crate::server::{C2sPacketChannels, NewClientData, S2cPlayMessage, SharedServer}; use crate::slab_versioned::{Key, VersionedSlab}; +use crate::slot::Slot; use crate::text::Text; use crate::util::{chunks_in_view_distance, is_chunk_in_view_distance}; use crate::world::{WorldId, Worlds}; @@ -232,6 +233,9 @@ pub struct Client { /// The data for the client's own player entity. player_data: Player, entity_events: Vec, + /// The item currently being held by the client's cursor in an inventory + /// screen. Does not work for creative mode. + cursor_held_item: Slot, } #[bitfield(u16)] @@ -301,6 +305,7 @@ impl Client { .with_created_this_tick(true), player_data: Player::new(), entity_events: Vec::new(), + cursor_held_item: Slot::Empty, } } @@ -769,8 +774,39 @@ impl Client { } C2sPlayPacket::CommandSuggestionsRequest(_) => {} C2sPlayPacket::ClickContainerButton(_) => {} - C2sPlayPacket::ClickContainer(_) => {} - C2sPlayPacket::CloseContainerC2s(_) => {} + C2sPlayPacket::ClickContainer(p) => { + if p.slot_idx == -999 { + // client is trying to drop the currently held stack + let held = std::mem::replace(&mut self.cursor_held_item, Slot::Empty); + match held { + Slot::Empty => {} + Slot::Present { + item_id, + item_count, + nbt, + } => self.events.push_back(ClientEvent::DropItemStack { + item_id, + item_count, + nbt, + }), + } + } else { + self.cursor_held_item = p.carried_item.clone(); + self.events.push_back(ClientEvent::ClickContainer { + window_id: p.window_id, + state_id: p.state_id, + slot_id: p.slot_idx, + mode: p.mode, + slot_changes: p.slots, + carried_item: p.carried_item, + }); + } + } + C2sPlayPacket::CloseContainerC2s(c) => { + self.events.push_back(ClientEvent::CloseScreen { + window_id: c.window_id, + }) + } C2sPlayPacket::PluginMessageC2s(_) => {} C2sPlayPacket::EditBook(_) => {} C2sPlayPacket::QueryEntityTag(_) => {} @@ -895,7 +931,7 @@ impl Client { face: p.face, }, play::DiggingStatus::DropItemStack => return, - play::DiggingStatus::DropItem => return, + play::DiggingStatus::DropItem => ClientEvent::DropItem, play::DiggingStatus::ShootArrowOrFinishEating => return, play::DiggingStatus::SwapItemInHand => return, }); @@ -929,7 +965,30 @@ impl Client { C2sPlayPacket::SetHeldItemS2c(_) => {} C2sPlayPacket::ProgramCommandBlock(_) => {} C2sPlayPacket::ProgramCommandBlockMinecart(_) => {} - C2sPlayPacket::SetCreativeModeSlot(_) => {} + C2sPlayPacket::SetCreativeModeSlot(e) => { + if e.slot == -1 { + // The client is trying to drop a stack of items + match e.clicked_item { + Slot::Empty => log::warn!( + "Invalid packet, creative client tried to drop a stack of nothing." + ), + Slot::Present { + item_id, + item_count, + nbt, + } => self.events.push_back(ClientEvent::DropItemStack { + item_id, + item_count, + nbt, + }), + } + } else { + self.events.push_back(ClientEvent::SetSlotCreative { + slot_id: e.slot, + slot: e.clicked_item, + }) + } + } C2sPlayPacket::ProgramJigsawBlock(_) => {} C2sPlayPacket::ProgramStructureBlock(_) => {} C2sPlayPacket::UpdateSign(_) => {} diff --git a/src/client/event.rs b/src/client/event.rs index 15421f8..370f037 100644 --- a/src/client/event.rs +++ b/src/client/event.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use serde_nbt::Compound; use vek::Vec3; use super::Client; @@ -7,11 +8,13 @@ use crate::block_pos::BlockPos; use crate::config::Config; use crate::entity::types::Pose; use crate::entity::{Entity, EntityEvent, EntityId, TrackedData}; +use crate::protocol::packets::c2s::play::ClickContainerMode; pub use crate::protocol::packets::c2s::play::{ BlockFace, ChatMode, DisplayedSkinParts, Hand, MainHand, ResourcePackC2s as ResourcePackStatus, }; pub use crate::protocol::packets::s2c::play::GameMode; use crate::protocol::VarInt; +use crate::slot::{Slot, SlotId}; /// Represents an action performed by a client. /// @@ -129,6 +132,49 @@ pub enum ClientEvent { sequence: VarInt, }, ResourcePackStatusChanged(ResourcePackStatus), + /// The client closed a screen. This occurs when the client closes their + /// inventory, closes a chest inventory, etc. + CloseScreen { + window_id: u8, + }, + /// The client is attempting to drop 1 of the currently held item. + DropItem, + /// The client is attempting to drop a stack of items. + /// + /// If the client is in creative mode, the items come from the void, so it + /// is safe to trust the contents of this event. Otherwise, you may need to + /// do some validation to make sure items are actually coming from the + /// user's inventory. + DropItemStack { + // TODO: maybe we could add `from_slot_id` to make validation easier + item_id: VarInt, + item_count: u8, + nbt: Option, + }, + /// The client is in creative mode, and is trying to set it's inventory slot + /// to a value. + SetSlotCreative { + /// The slot number that the client is trying to set. + slot_id: SlotId, + /// The contents of the slot. + slot: Slot, + }, + /// The client is in survival mode, and is trying to modify an inventory. + ClickContainer { + window_id: u8, + state_id: VarInt, + /// The slot that was clicked + slot_id: SlotId, + /// The type of click that the user performed + mode: ClickContainerMode, + /// A list of slot ids and what their contents should be set to. + /// + /// It's not safe to blindly trust the contents of this. Servers need to + /// validate it if they want to prevent item duping. + slot_changes: Vec<(SlotId, Slot)>, + /// The item that is now being carried by the user's cursor + carried_item: Slot, + }, } #[derive(Clone, PartialEq, Debug)] @@ -287,6 +333,11 @@ pub fn handle_event_default( ClientEvent::Digging { .. } => {} ClientEvent::InteractWithBlock { .. } => {} ClientEvent::ResourcePackStatusChanged(_) => {} + ClientEvent::CloseScreen { .. } => {} + ClientEvent::DropItem => {} + ClientEvent::DropItemStack { .. } => {} + ClientEvent::SetSlotCreative { .. } => {} + ClientEvent::ClickContainer { .. } => {} } entity.set_world(client.world()); diff --git a/src/lib.rs b/src/lib.rs index 0c1aae3..10f3fac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -109,6 +109,7 @@ pub mod server; mod slab; mod slab_rc; mod slab_versioned; +pub mod slot; pub mod spatial_index; pub mod text; pub mod util; diff --git a/src/protocol.rs b/src/protocol.rs index 4748035..a2fe5be 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -12,7 +12,6 @@ pub use byte_angle::ByteAngle; use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; use serde::de::DeserializeOwned; use serde::Serialize; -pub use slot::Slot; use uuid::Uuid; pub use var_int::VarInt; pub use var_long::VarLong; @@ -24,7 +23,6 @@ use crate::nbt; mod byte_angle; pub mod codec; pub mod packets; -mod slot; mod var_int; mod var_long; diff --git a/src/protocol/packets.rs b/src/protocol/packets.rs index 74109d8..5860cad 100644 --- a/src/protocol/packets.rs +++ b/src/protocol/packets.rs @@ -20,9 +20,10 @@ use crate::block_pos::BlockPos; use crate::ident::Ident; use crate::nbt::Compound; use crate::protocol::{ - BoundedArray, BoundedInt, BoundedString, ByteAngle, Decode, Encode, NbtBridge, RawBytes, Slot, + BoundedArray, BoundedInt, BoundedString, ByteAngle, Decode, Encode, NbtBridge, RawBytes, VarInt, VarLong, }; +use crate::slot::Slot; use crate::text::Text; /// Provides the name of a packet for debugging purposes. diff --git a/src/protocol/packets/c2s.rs b/src/protocol/packets/c2s.rs index 8a004fe..6ab34a5 100644 --- a/src/protocol/packets/c2s.rs +++ b/src/protocol/packets/c2s.rs @@ -255,10 +255,13 @@ pub mod play { slot_idx: i16, button: i8, mode: ClickContainerMode, + slots: Vec<(i16, Slot)>, + carried_item: Slot, } } def_enum! { + #[derive(Copy, PartialEq, Eq)] ClickContainerMode: VarInt { Click = 0, ShiftClick = 1, diff --git a/src/protocol/slot.rs b/src/slot.rs similarity index 99% rename from src/protocol/slot.rs rename to src/slot.rs index 6ddb7d4..49efbe6 100644 --- a/src/protocol/slot.rs +++ b/src/slot.rs @@ -5,6 +5,8 @@ use byteorder::ReadBytesExt; use crate::nbt::Compound; use crate::protocol::{Decode, Encode, VarInt}; +pub type SlotId = i16; + /// Represents a slot in an inventory. #[derive(Clone, Default, Debug)] pub enum Slot {