diff --git a/examples/death.rs b/examples/death.rs new file mode 100644 index 0000000..52ec0c5 --- /dev/null +++ b/examples/death.rs @@ -0,0 +1,355 @@ +use std::net::SocketAddr; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use log::LevelFilter; +use valence::async_trait; +use valence::block::{BlockPos, BlockState}; +use valence::chunk::UnloadedChunk; +use valence::client::{handle_event_default, ClientEvent}; +use valence::config::{Config, ServerListPing}; +use valence::dimension::Dimension; +use valence::entity::{EntityId, EntityKind}; +use valence::player_list::PlayerListId; +use valence::server::{Server, SharedServer, ShutdownResult}; +use valence::text::{Color, TextFormat}; +use valence::world::WorldId; +use vek::Vec3; + +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::default(), + ) +} + +struct Game { + player_count: AtomicUsize, +} + +#[derive(Default)] +struct ClientState { + entity_id: EntityId, + // World and position to respawn at + respawn_location: (WorldId, Vec3), + // Anticheat measure + can_respawn: bool, +} + +struct WorldState { + player_list: PlayerListId, +} + +#[derive(Default)] +struct ServerState { + first_world: WorldId, + second_world: WorldId, + third_world: WorldId, +} + +const MAX_PLAYERS: usize = 10; + +const FLOOR_Y: i32 = 64; +const PLATFORM_X: i32 = 20; +const PLATFORM_Z: i32 = 20; +const LEFT_DEATH_LINE: i32 = 16; +const RIGHT_DEATH_LINE: i32 = 4; + +const FIRST_WORLD_SPAWN_BLOCK: BlockPos = BlockPos::new(10, FLOOR_Y, 10); +const SECOND_WORLD_SPAWN_BLOCK: BlockPos = BlockPos::new(5, FLOOR_Y, 5); +const THIRD_WORLD_SPAWN_BLOCK: BlockPos = BlockPos::new(5, FLOOR_Y, 5); + +#[derive(Clone, Copy, PartialEq, Eq)] +enum WhichWorld { + First, + Second, + Third, +} + +// Returns position of player standing on `pos` block +fn block_pos_to_vec(pos: BlockPos) -> Vec3 { + Vec3::new( + (pos.x as f64) + 0.5, + (pos.y as f64) + 1.0, + (pos.z as f64) + 0.5, + ) +} + +#[async_trait] +impl Config for Game { + type ServerState = ServerState; + type ClientState = ClientState; + type EntityState = (); + type WorldState = WorldState; + type ChunkState = (); + type PlayerListState = (); + + fn online_mode(&self) -> bool { + false + } + + 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() + }, + Dimension { + fixed_time: Some(19000), + ..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, + description: "Hello Valence!".color(Color::AQUA), + favicon_png: Some(include_bytes!("../assets/logo-64x64.png").as_slice().into()), + player_sample: Default::default(), + } + } + + fn init(&self, server: &mut Server) { + // We created server with meaningless default state. + // Let's create three worlds and create new ServerState. + server.state = ServerState { + first_world: create_world(server, FIRST_WORLD_SPAWN_BLOCK, WhichWorld::First), + second_world: create_world(server, SECOND_WORLD_SPAWN_BLOCK, WhichWorld::Second), + third_world: create_world(server, THIRD_WORLD_SPAWN_BLOCK, WhichWorld::Third), + }; + } + + fn update(&self, server: &mut Server) { + 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; + } + } + + let first_world_id = server.state.first_world; + let first_world = server.worlds.get(first_world_id).unwrap(); + + client.state.respawn_location = ( + server.state.first_world, + block_pos_to_vec(FIRST_WORLD_SPAWN_BLOCK), + ); + + // `set_spawn_position` is used for compass _only_ + client.set_spawn_position(FIRST_WORLD_SPAWN_BLOCK, 0.0); + + client.set_flat(true); + client.spawn(first_world_id); + client.teleport(client.state.respawn_location.1, 0.0, 0.0); + + client.set_player_list(first_world.state.player_list.clone()); + + server + .player_lists + .get_mut(&first_world.state.player_list) + .insert( + client.uuid(), + client.username(), + client.textures().cloned(), + client.game_mode(), + 0, + None, + ); + + client.set_respawn_screen(true); + + client.send_message("Welcome to the death example!".italic()); + client.send_message("Step over the left line to die. :)"); + client.send_message("Step over the right line to die and respawn in second world."); + client.send_message("Jumping down kills you and spawns you in another dimension."); + client.send_message("Sneaking triggers game credits after which you respawn."); + } + + // TODO after inventory support is added, show interaction with compass. + + if client.is_disconnected() { + self.player_count.fetch_sub(1, Ordering::SeqCst); + server.entities.remove(client.state.entity_id); + + if let Some(list) = client.player_list() { + server.player_lists.get_mut(list).remove(client.uuid()); + } + + return false; + } + + // Handling respawn locations + if !client.state.can_respawn { + if client.position().y < 0.0 { + client.state.can_respawn = true; + client.kill(None, "You fell"); + // You could have also killed the player with `Client::set_health_and_food`, + // however you cannot send a message to the death screen + // that way + if client.world() == server.state.third_world { + // Falling in third world gets you back to the first world + client.state.respawn_location = ( + server.state.first_world, + block_pos_to_vec(FIRST_WORLD_SPAWN_BLOCK), + ); + client.set_spawn_position(FIRST_WORLD_SPAWN_BLOCK, 0.0); + } else { + // falling in first and second world will cause player to spawn in third + // world + client.state.respawn_location = ( + server.state.third_world, + block_pos_to_vec(THIRD_WORLD_SPAWN_BLOCK), + ); + // This is for compass to point at + client.set_spawn_position(THIRD_WORLD_SPAWN_BLOCK, 0.0); + } + } + + // Death lanes in the first world + if client.world() == server.state.first_world { + let death_msg = "You shouldn't cross suspicious lines"; + + if client.position().x >= LEFT_DEATH_LINE as f64 { + // Client went to the left, he dies + client.state.can_respawn = true; + client.kill(None, death_msg); + } + + if client.position().x <= RIGHT_DEATH_LINE as f64 { + // Client went to the right, he dies and spawns in world2 + client.state.can_respawn = true; + client.kill(None, death_msg); + client.state.respawn_location = ( + server.state.second_world, + block_pos_to_vec(SECOND_WORLD_SPAWN_BLOCK), + ); + client.set_spawn_position(SECOND_WORLD_SPAWN_BLOCK, 0.0); + } + } + } + + let player = server.entities.get_mut(client.state.entity_id).unwrap(); + + while let Some(event) = handle_event_default(client, player) { + match event { + ClientEvent::RespawnRequest => { + if !client.state.can_respawn { + client.disconnect("Unexpected RespawnRequest"); + return false; + } + // Let's respawn our player. `spawn` will load the world, but we are + // responsible for teleporting the player. + + // You can store respawn however you want, for example in `Client`'s state. + let spawn = client.state.respawn_location; + client.spawn(spawn.0); + client.teleport(spawn.1, 0.0, 0.0); + client.state.can_respawn = false; + } + ClientEvent::StartSneaking => { + // Roll the credits, respawn after + client.state.can_respawn = true; + client.win_game(true); + } + _ => {} + } + } + + true + }); + } +} + +// Boilerplate for creating world +fn create_world(server: &mut Server, spawn_pos: BlockPos, world_type: WhichWorld) -> WorldId { + let dimension = match world_type { + WhichWorld::First => server.shared.dimensions().next().unwrap(), + WhichWorld::Second => server.shared.dimensions().next().unwrap(), + WhichWorld::Third => server.shared.dimensions().nth(1).unwrap(), + }; + + let player_list = server.player_lists.insert(()).0; + let (world_id, world) = server + .worlds + .insert(dimension.0, WorldState { player_list }); + + // Create chunks + for chunk_z in -3..3 { + for chunk_x in -3..3 { + world.chunks.insert( + [chunk_x as i32, chunk_z as i32], + UnloadedChunk::default(), + (), + ); + } + } + + // Create platform + let platform_block = match world_type { + WhichWorld::First => BlockState::END_STONE, + WhichWorld::Second => BlockState::AMETHYST_BLOCK, + WhichWorld::Third => BlockState::BLACKSTONE, + }; + + for z in 0..PLATFORM_Z { + for x in 0..PLATFORM_X { + world + .chunks + .set_block_state([x, FLOOR_Y, z], platform_block); + } + } + + // Set death lines + if world_type == WhichWorld::First { + for z in 0..PLATFORM_Z { + world + .chunks + .set_block_state([LEFT_DEATH_LINE, FLOOR_Y, z], BlockState::GOLD_BLOCK); + world + .chunks + .set_block_state([RIGHT_DEATH_LINE, FLOOR_Y, z], BlockState::DIAMOND_BLOCK); + } + } + + // Set spawn block + world + .chunks + .set_block_state(spawn_pos, BlockState::REDSTONE_BLOCK); + + world_id +} diff --git a/src/client.rs b/src/client.rs index 6ef5f38..f6928e5 100644 --- a/src/client.rs +++ b/src/client.rs @@ -23,13 +23,15 @@ use crate::entity::{ use crate::ident::Ident; use crate::player_list::{PlayerListId, PlayerLists}; use crate::player_textures::SignedPlayerTextures; -use crate::protocol::packets::c2s::play::{self, C2sPlayPacket, InteractKind, PlayerCommandId}; +use crate::protocol::packets::c2s::play::{ + self, C2sPlayPacket, ClientCommand, InteractKind, PlayerCommandId, +}; pub use crate::protocol::packets::s2c::play::SetTitleAnimationTimes; use crate::protocol::packets::s2c::play::{ - AcknowledgeBlockChange, ClearTitles, CustomSoundEffect, DisconnectPlay, EntityAnimationS2c, - EntityAttributesProperty, EntityEvent, GameEvent, GameStateChangeReason, KeepAliveS2c, - LoginPlay, PlayerPositionLookFlags, RemoveEntities, ResourcePackS2c, Respawn, S2cPlayPacket, - SetActionBarText, SetCenterChunk, SetDefaultSpawnPosition, SetEntityMetadata, + AcknowledgeBlockChange, ClearTitles, CombatDeath, CustomSoundEffect, DisconnectPlay, + EntityAnimationS2c, EntityAttributesProperty, EntityEvent, GameEvent, GameStateChangeReason, + KeepAliveS2c, LoginPlay, PlayerPositionLookFlags, RemoveEntities, ResourcePackS2c, Respawn, + S2cPlayPacket, SetActionBarText, SetCenterChunk, SetDefaultSpawnPosition, SetEntityMetadata, SetEntityVelocity, SetExperience, SetHeadRotation, SetHealth, SetRenderDistance, SetSubtitleText, SetTitleText, SoundCategory, SynchronizePlayerPosition, SystemChatMessage, TeleportEntity, UnloadChunk, UpdateAttributes, UpdateEntityPosition, @@ -187,6 +189,8 @@ pub struct Client { uuid: Uuid, username: String, textures: Option, + /// World client is currently in. Default value is **invalid** and must + /// be set by calling [`Client::spawn`]. world: WorldId, player_list: Option, old_player_list: Option, @@ -616,6 +620,44 @@ impl Client { }) } + /// Kills the client and shows `message` on the death screen. If an entity + /// killed the player, pass its ID into the function. + pub fn kill(&mut self, killer: Option, message: impl Into) { + let entity_id = match killer { + Some(k) => k.to_network_id(), + None => -1, + }; + self.send_packet(CombatDeath { + player_id: VarInt(0), + entity_id, + message: message.into(), + }); + } + + /// Respawns client. Optionally can roll the credits before respawning. + pub fn win_game(&mut self, show_credits: bool) { + let value = match show_credits { + true => 1.0, + false => 0.0, + }; + self.send_packet(GameEvent { + reason: GameStateChangeReason::WinGame, + value, + }); + } + + /// Sets whether respawn screen should be displayed after client's death. + pub fn set_respawn_screen(&mut self, enable: bool) { + let value = match enable { + true => 0.0, + false => 1.0, + }; + self.send_packet(GameEvent { + reason: GameStateChangeReason::EnableRespawnScreen, + value, + }); + } + /// Gets whether or not the client is connected to the server. /// /// A disconnected client object will never become reconnected. It is your @@ -806,7 +848,12 @@ impl Client { timestamp: Duration::from_millis(p.timestamp), }), C2sPlayPacket::ChatPreviewC2s(_) => {} - C2sPlayPacket::ClientCommand(_) => {} + C2sPlayPacket::ClientCommand(p) => match p { + ClientCommand::PerformRespawn => { + self.events.push_back(ClientEvent::RespawnRequest); + } + ClientCommand::RequestStatus => (), + }, C2sPlayPacket::ClientInformation(p) => { self.events.push_back(ClientEvent::SettingsChanged { locale: p.locale.0, diff --git a/src/client/event.rs b/src/client/event.rs index 625f16e..8a67d90 100644 --- a/src/client/event.rs +++ b/src/client/event.rs @@ -172,6 +172,7 @@ pub enum ClientEvent { /// The item that is now being carried by the user's cursor carried_item: Slot, }, + RespawnRequest, } #[derive(Clone, PartialEq, Debug)] @@ -335,6 +336,7 @@ pub fn handle_event_default( ClientEvent::DropItemStack { .. } => {} ClientEvent::SetSlotCreative { .. } => {} ClientEvent::ClickContainer { .. } => {} + ClientEvent::RespawnRequest => {} } entity.set_world(client.world()); diff --git a/src/protocol/packets/s2c.rs b/src/protocol/packets/s2c.rs index b5edad6..1a186e0 100644 --- a/src/protocol/packets/s2c.rs +++ b/src/protocol/packets/s2c.rs @@ -469,6 +469,15 @@ pub mod play { } } + def_struct! { + CombatDeath { + player_id: VarInt, + /// Killer's entity ID, -1 if no killer + entity_id: i32, + message: Text + } + } + def_enum! { PlayerInfo: VarInt { AddPlayer: Vec = 0, @@ -752,6 +761,7 @@ pub mod play { UpdateEntityRotation = 42, OpenScreen = 45, PlayerChatMessage = 51, + CombatDeath = 54, PlayerInfo = 55, SynchronizePlayerPosition = 57, RemoveEntities = 59,