diff --git a/Cargo.lock b/Cargo.lock index 2ce39741..e94cbe9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1312,6 +1312,16 @@ version = "0.17.0-dev" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ef8e4b7e61dfe7719bb03c884dc270cd46a82efb40f93e9933b990c5c190c59" +[[package]] +name = "bevy_mod_billboard" +version = "0.7.0" +source = "git+https://github.com/kisya-games/bevy_mod_billboard.git?branch=bevy-0.17#dfaa4d2107fc2aa439fdc548d264a450b915e5c4" +dependencies = [ + "bevy", + "bitflags 2.10.0", + "smallvec", +] + [[package]] name = "bevy_pbr" version = "0.17.3" @@ -5337,6 +5347,7 @@ dependencies = [ "bevy_flair", "bevy_fps_controller", "bevy_mesh", + "bevy_mod_billboard", "bevy_rapier3d", "bevy_renet", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 1f215a48..fb13f03f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ bevy_egui = { version = "0.38.1", features = ["immutable_ctx"]} bevy_diagnostic = "0.17.3" bevy-inspector-egui = {version = "0.35", features = ["bevy_render"]} bevy_renet = "3.0.0" +bevy_mod_billboard = "0.7.0" renet_visualizer = {version="1.1.0", features=["bevy"]} egui_plot = "0.34.0" @@ -41,6 +42,7 @@ clap = { version = "4.5.54", features = ["derive"] } renet_visualizer = {git = "https://github.com/cb341/renet.git" } bevy_renet = {git = "https://github.com/cb341/renet.git" } renet = {git = "https://github.com/cb341/renet.git" } +bevy_mod_billboard = {git = "https://github.com/kisya-games/bevy_mod_billboard.git", branch="bevy-0.17"} # TODO: Remove once https://github.com/kulkalkul/bevy_mod_billboard/pull/36 is merged [profile.dev.package."*"] opt-level = 3 diff --git a/src/client/chat/systems.rs b/src/client/chat/systems.rs index 47097b2f..b71de0c9 100644 --- a/src/client/chat/systems.rs +++ b/src/client/chat/systems.rs @@ -168,7 +168,7 @@ pub fn add_message_to_chat_container_system( Node::default(), Name::new("chat_entry"), chat_components::ChatMessageElement, - Text::new(event.0.message.clone()), + Text::new(event.0.format_string()), )); }); } @@ -277,7 +277,7 @@ mod tests { event_writer.write(SingleChatSendEvent(ChatMessage { message: "Hello World".to_string(), - client_id: 0, + sender: ChatMessageSender::Server, message_id: 1, timestamp: 0, })); @@ -292,7 +292,7 @@ mod tests { assert_eq!(message_count, 1); assert_eq!( messages.iter(app.world()).next().unwrap().0 .0, - "Hello World" + "[1970-01-01 00:00:00 UTC] SERVER: Hello World" ); } diff --git a/src/client/gui/systems.rs b/src/client/gui/systems.rs index 1fd03842..6232b42d 100644 --- a/src/client/gui/systems.rs +++ b/src/client/gui/systems.rs @@ -25,6 +25,7 @@ pub fn handle_debug_state_transition_system( ) { if key_input.just_pressed(KeyCode::Tab) { match *current_state.get() { + GameState::WaitingForServer => {} GameState::Playing => next_state.set(GameState::Debugging), GameState::Chatting => next_state.set(GameState::Debugging), GameState::Debugging => next_state.set(GameState::Playing), diff --git a/src/client/main.rs b/src/client/main.rs index 1b1c67f1..184a37dc 100644 --- a/src/client/main.rs +++ b/src/client/main.rs @@ -13,6 +13,7 @@ mod states; mod terrain; use bevy_flair::FlairPlugin; +use clap::Parser; use scene::setup_scene; #[cfg(feature = "wireframe")] @@ -32,7 +33,17 @@ mod wireframe_config { } } +#[derive(Debug, Parser)] +#[command(version)] +#[command(long_about = None)] +struct Cli { + #[command(flatten)] + networking_args: networking_commands::NetworkingArgs, +} + fn main() { + let cli = Cli::parse(); + let window_plugin = WindowPlugin { primary_window: Some(Window { resolution: WindowResolution::new(1920, 1080).with_scale_factor_override(2.0), @@ -47,6 +58,17 @@ fn main() { .set(ImagePlugin::default_nearest()); let mut app = App::new(); + + match networking::NetworkingPlugin::from_args(cli.networking_args) { + Ok(plugin) => { + app.add_plugins(plugin); + } + Err(err) => { + eprintln!("Error: {}", err); + return; + } + } + app.add_plugins(( default_plugins, FlairPlugin, @@ -56,7 +78,6 @@ fn main() { EntityCountDiagnosticsPlugin::default(), SystemInformationDiagnosticsPlugin, gui::GuiPlugin, - networking::NetworkingPlugin, terrain::TerrainPlugin, collider::ColliderPlugin, player::PlayerPlugin, @@ -64,7 +85,7 @@ fn main() { #[cfg(feature = "chat")] chat::ChatPlugin, )); - app.insert_state(GameState::Playing); + app.insert_state(GameState::WaitingForServer); #[cfg(feature = "wireframe")] app.insert_resource(wireframe_config::wireframe_config()); diff --git a/src/client/networking/commands.rs b/src/client/networking/commands.rs new file mode 100644 index 00000000..62914195 --- /dev/null +++ b/src/client/networking/commands.rs @@ -0,0 +1,16 @@ +use clap::*; + +use crate::prelude::NetworkingPlugin; + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +pub struct NetworkingArgs { + #[arg(short, long)] + username: String, +} + +impl NetworkingPlugin { + pub fn from_args(args: NetworkingArgs) -> Result { + NetworkingPlugin::new(args.username) + } +} diff --git a/src/client/networking/mod.rs b/src/client/networking/mod.rs index 8fe37d70..fff1f7ad 100644 --- a/src/client/networking/mod.rs +++ b/src/client/networking/mod.rs @@ -1,16 +1,30 @@ +pub mod commands; pub mod systems; use crate::connection_config; use bevy_renet::{ - netcode::{ClientAuthentication, NetcodeClientPlugin, NetcodeClientTransport}, + netcode::{ + ClientAuthentication, NetcodeClientPlugin, NetcodeClientTransport, NetcodeTransportError, + }, RenetClientPlugin, }; use crate::prelude::*; -const SERVER_ADDR: &str = "127.0.0.1:5000"; +const DEFAULT_SERVER_ADDR: &str = "127.0.0.1:5000"; + +pub struct NetworkingPlugin { + username: Username, +} + +impl NetworkingPlugin { + pub fn new(username: String) -> Result { + Ok(Self { + username: Username::new(&username)?, + }) + } +} -pub struct NetworkingPlugin; impl Plugin for NetworkingPlugin { fn build(&self, app: &mut App) { app.add_plugins((RenetClientPlugin, NetcodeClientPlugin)); @@ -18,11 +32,12 @@ impl Plugin for NetworkingPlugin { let client = RenetClient::new(connection_config()); app.insert_resource(client); - let client_id = rand::random::(); let authentication = ClientAuthentication::Unsecure { - server_addr: SERVER_ADDR.parse().unwrap(), - client_id, - user_data: None, + server_addr: DEFAULT_SERVER_ADDR + .parse() + .expect("Hardcoded server address should be valid"), + client_id: rand::random::(), + user_data: Some(self.username.to_netcode_user_data()), protocol_id: 0, }; let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); @@ -32,6 +47,21 @@ impl Plugin for NetworkingPlugin { let transport = NetcodeClientTransport::new(current_time, authentication, socket).unwrap(); app.insert_resource(transport); + app.add_systems(Last, networking_systems::exit_on_last_window_closed_system); app.add_systems(Update, networking_systems::receive_message_system); + + fn exit_on_transport_error( + mut renet_error: MessageReader, + mut exit_events: MessageWriter, + ) { + if !renet_error.is_empty() { + exit_events.write(AppExit::error()); + } + for error in renet_error.read() { + eprintln!("{}", error); + } + } + + app.add_systems(Update, exit_on_transport_error); } } diff --git a/src/client/networking/systems.rs b/src/client/networking/systems.rs index b91c8889..5ad0c00f 100644 --- a/src/client/networking/systems.rs +++ b/src/client/networking/systems.rs @@ -1,7 +1,20 @@ use crate::prelude::*; +pub fn exit_on_last_window_closed_system( + close_events: MessageReader, + windows: Query<(), With>, + mut client: ResMut, + mut exit: MessageWriter, +) { + if !close_events.is_empty() && windows.iter().count() <= 1 { + client.disconnect(); + exit.write(AppExit::Success); + } +} + #[allow(clippy::too_many_arguments)] pub fn receive_message_system( + mut commands: Commands, mut client: ResMut, mut player_spawn_events: ResMut>, mut player_despawn_events: ResMut>, @@ -15,20 +28,29 @@ pub fn receive_message_system( Messages, >, mut spawn_area_loaded: ResMut, + mut exit_events: MessageWriter, + mut next_state: ResMut>, ) { while let Some(message) = client.receive_message(DefaultChannel::ReliableOrdered) { match bincode::deserialize(&message) { Ok(message) => match message { - NetworkingMessage::PlayerJoin(event) => { + NetworkingMessage::PlayerReject(reject_reason) => { + eprintln!("Server connection rejected: {reject_reason}"); + exit_events.write(AppExit::error()); + } + NetworkingMessage::PlayerAccept(player_state) => { + commands.insert_resource(player_resources::LocalPlayerSpawnState(player_state)); + next_state.set(GameState::Playing); + } + NetworkingMessage::PlayerJoin(username) => { player_spawn_events.write(remote_player_events::RemotePlayerSpawnedEvent { - client_id: event, + username, position: Vec3::ZERO, }); } - NetworkingMessage::PlayerLeave(event) => { - player_despawn_events.write(remote_player_events::RemotePlayerDespawnedEvent { - client_id: event, - }); + NetworkingMessage::PlayerLeave(username) => { + player_despawn_events + .write(remote_player_events::RemotePlayerDespawnedEvent { username }); } NetworkingMessage::BlockUpdate { position, block } => { debug!("Client received block update message: {:?}", position); diff --git a/src/client/player/resources.rs b/src/client/player/resources.rs index 4e30458f..861d7d8b 100644 --- a/src/client/player/resources.rs +++ b/src/client/player/resources.rs @@ -48,3 +48,6 @@ impl LastPlayerPosition { Self(Vec3::ZERO) } } + +#[derive(Resource)] +pub struct LocalPlayerSpawnState(pub PlayerState); diff --git a/src/client/player/systems/controller.rs b/src/client/player/systems/controller.rs index 35ea22a1..ac3ce9a2 100644 --- a/src/client/player/systems/controller.rs +++ b/src/client/player/systems/controller.rs @@ -1,14 +1,5 @@ use crate::prelude::*; -#[cfg(feature = "skip_terrain")] -const SPAWN_POINT: Vec3 = Vec3::new(0.0, 1.0, 0.0); - -#[cfg(all(not(feature = "skip_terrain"), not(feature = "lock_player")))] -const SPAWN_POINT: Vec3 = Vec3::new(0.0, 43.0, 0.0); // TODO: determine terrain height at 0,0 - -#[cfg(all(not(feature = "skip_terrain"), feature = "lock_player"))] -const SPAWN_POINT: Vec3 = Vec3::new(128.0, 96.0, -128.0); - pub fn setup_player_camera(mut commands: Commands) { commands.spawn(( Name::new("Player cam?"), @@ -38,8 +29,9 @@ pub fn setup_controller_on_area_ready_system( mut commands: Commands, mut player_spawned: ResMut, mut render_player: Query<&mut RenderPlayer>, + spawn_state: Res, ) { - info!("Setting up controller"); + info!("Setting up controller at {:?}", spawn_state.0.position); let logical_entity = commands .spawn(( @@ -62,14 +54,17 @@ pub fn setup_controller_on_area_ready_system( LockedAxes::ROTATION_LOCKED, AdditionalMassProperties::Mass(1.0), GravityScale(0.0), - Ccd { enabled: true }, // Prevent clipping when going fast - Transform::from_translation(SPAWN_POINT), + Ccd { enabled: true }, + Transform::from_translation(spawn_state.0.position), LogicalPlayer, #[cfg(not(feature = "lock_player"))] - FpsControllerInput { - pitch: -TAU / 20.0, - yaw: TAU * 5.0 / 12.0, - ..default() + { + let (yaw, pitch, _roll) = spawn_state.0.rotation.to_euler(EulerRot::YXZ); + FpsControllerInput { + pitch, + yaw, + ..default() + } }, #[cfg(feature = "lock_player")] FpsControllerInput { diff --git a/src/client/prelude.rs b/src/client/prelude.rs index b947c587..00d6f172 100644 --- a/src/client/prelude.rs +++ b/src/client/prelude.rs @@ -44,6 +44,7 @@ pub use crate::collider::components as collider_components; pub use crate::collider::events as collider_events; pub use crate::collider::systems as collider_systems; +pub use crate::networking::commands as networking_commands; pub use crate::networking::systems as networking_systems; pub use crate::networking::NetworkingPlugin; diff --git a/src/client/remote_player/components.rs b/src/client/remote_player/components.rs index d5f015e0..f5ea0049 100644 --- a/src/client/remote_player/components.rs +++ b/src/client/remote_player/components.rs @@ -2,7 +2,7 @@ use crate::prelude::*; #[derive(Component)] pub struct RemotePlayer { - pub client_id: ClientId, + pub username: Username, } #[derive(Default, Reflect, GizmoConfigGroup)] diff --git a/src/client/remote_player/events.rs b/src/client/remote_player/events.rs index 1052c103..d2cf455f 100644 --- a/src/client/remote_player/events.rs +++ b/src/client/remote_player/events.rs @@ -2,16 +2,16 @@ use crate::prelude::*; #[derive(Message)] pub struct RemotePlayerSpawnedEvent { - pub client_id: ClientId, + pub username: Username, pub position: Vec3, } #[derive(Message)] pub struct RemotePlayerDespawnedEvent { - pub client_id: ClientId, + pub username: Username, } #[derive(Message)] pub struct RemotePlayerSyncEvent { - pub players: HashMap, + pub players: HashMap, } diff --git a/src/client/remote_player/mod.rs b/src/client/remote_player/mod.rs index b190d7a4..5f0f6172 100644 --- a/src/client/remote_player/mod.rs +++ b/src/client/remote_player/mod.rs @@ -1,3 +1,5 @@ +use bevy_mod_billboard::prelude::BillboardPlugin; + pub mod components; pub mod events; pub mod systems; @@ -8,6 +10,7 @@ pub struct RemotePlayerPlugin; impl Plugin for RemotePlayerPlugin { fn build(&self, app: &mut App) { + app.add_plugins(BillboardPlugin); app.add_message::(); app.init_gizmo_group::(); app.add_message::(); diff --git a/src/client/remote_player/systems.rs b/src/client/remote_player/systems.rs index 661f50a7..75668953 100644 --- a/src/client/remote_player/systems.rs +++ b/src/client/remote_player/systems.rs @@ -1,24 +1,45 @@ use crate::prelude::*; +use bevy_mod_billboard::prelude::*; pub fn spawn_remote_player_system( mut commands: Commands, mut spawn_events: MessageReader, mut meshes: ResMut>, mut materials: ResMut>, + asset_server: Res, ) { + let terminus_handle = asset_server.load("fonts/Terminus500.ttf"); + for event in spawn_events.read() { - let client_id = event.client_id; + let username = event.username; let material = materials.add(StandardMaterial { base_color: Color::srgb(0.8, 0.7, 0.6), ..default() }); - commands.spawn(( - bevy::prelude::Mesh3d(meshes.add(Cuboid::new(0.5, 0.5, 0.5))), - MeshMaterial3d(material), - remote_player_components::RemotePlayer { client_id }, - )); + commands + .spawn(( + Node::default(), + bevy::prelude::Mesh3d(meshes.add(Cuboid::new(0.5, 0.5, 0.5))), + MeshMaterial3d(material), + remote_player_components::RemotePlayer { username }, + )) + .with_children(|parent| { + parent + .spawn(( + Node::default(), + BillboardText::default(), + TextLayout::new_with_justify(Justify::Center), + Transform::from_scale(Vec3::splat(0.0085)), + )) + .with_child(( + Node::default(), + TextSpan::new(format!("{username}\n\n\n")), + TextFont::from(terminus_handle.clone()).with_font_size(60.0), + TextColor::from(Color::WHITE), + )); + }); } } @@ -29,7 +50,7 @@ pub fn despawn_remote_player_system( ) { for event in despawn_events.read() { for (entity, remote_player) in query.iter() { - if remote_player.client_id == event.client_id { + if remote_player.username == event.username { commands.entity(entity).despawn(); } } @@ -44,19 +65,19 @@ pub fn update_remote_player_system( let latest_event = sync_events.read().last(); if let Some(event) = latest_event { - for (client_id, player_state) in event.players.iter() { + for (username, player_state) in event.players.iter() { let mut player_exists = false; for (remote_player, mut transform) in query.iter_mut() { - if remote_player.client_id == *client_id { + if remote_player.username == *username { player_exists = true; - transform.translation = player_state.position + Vec3::new(0.0, 1.55, 0.0); + transform.translation = player_state.position + Vec3::new(0.0, 0.55, 0.0); transform.rotation = player_state.rotation; } } if !player_exists { spawn_events.write(remote_player_events::RemotePlayerSpawnedEvent { - client_id: *client_id, + username: *username, position: player_state.position, }); } diff --git a/src/client/states.rs b/src/client/states.rs index a3f2c4ff..ffb0abcc 100644 --- a/src/client/states.rs +++ b/src/client/states.rs @@ -2,6 +2,7 @@ use bevy::prelude::States; #[derive(States, Debug, Clone, PartialEq, Eq, Hash)] pub enum GameState { + WaitingForServer, Chatting, Debugging, Playing, diff --git a/src/client/terrain/mod.rs b/src/client/terrain/mod.rs index ac87634c..3e3f674c 100644 --- a/src/client/terrain/mod.rs +++ b/src/client/terrain/mod.rs @@ -28,8 +28,15 @@ impl Plugin for TerrainPlugin { #[cfg(not(feature = "skip_terrain"))] { app.insert_resource(terrain_resources::SpawnAreaLoaded(false)); - app.add_systems(Startup, terrain_systems::prepare_spawn_area_system); - app.add_systems(Startup, terrain_systems::generate_world_system); + + app.add_systems( + OnExit(GameState::WaitingForServer), + terrain_systems::prepare_spawn_area_system, + ); + app.add_systems( + OnExit(GameState::WaitingForServer), + terrain_systems::generate_world_system, + ); app.add_systems( Update, terrain_systems::handle_chunk_mesh_update_events_system, diff --git a/src/server/chat/events.rs b/src/server/chat/events.rs index 2d7553d8..8de8ee4d 100644 --- a/src/server/chat/events.rs +++ b/src/server/chat/events.rs @@ -2,7 +2,7 @@ use crate::prelude::*; #[derive(Message)] pub struct PlayerChatMessageSendEvent { - pub client_id: ClientId, + pub sender: ChatMessageSender, pub message: String, } diff --git a/src/server/chat/systems.rs b/src/server/chat/systems.rs index 4d8ceb29..3774ad2c 100644 --- a/src/server/chat/systems.rs +++ b/src/server/chat/systems.rs @@ -9,14 +9,14 @@ pub fn sync_single_player_chat_messages_system( ) { for event in player_send_messages.read() { let message = event.message.clone(); - let client_id = event.client_id; + let sender = event.sender.clone(); - info!("Broadcasting message from sender {}", client_id); + info!("Broadcasting message from sender {sender}"); let message_count = chat_messages.messages.len(); let message_id = message_count; let chat_message = ChatMessage { - client_id, + sender, message_id, message, timestamp: get_current_time_in_ms(), diff --git a/src/server/networking/mod.rs b/src/server/networking/mod.rs index 4ad5561a..68e727c0 100644 --- a/src/server/networking/mod.rs +++ b/src/server/networking/mod.rs @@ -1,10 +1,12 @@ +pub mod resources; pub mod systems; use crate::connection_config; +use crate::networking::resources::{ActiveConnections, PendingDisconnects}; use crate::prelude::*; -const SERVER_ADDR: &str = "127.0.0.1:5000"; +const DEFAULT_SERVER_ADDR: &str = "127.0.0.1:5000"; pub struct NetworkingPlugin; @@ -35,7 +37,25 @@ impl Plugin for NetworkingPlugin { app.insert_resource(server); app.add_plugins(NetcodeServerPlugin); - let server_addr = SERVER_ADDR.parse().unwrap(); + app.insert_resource(Self::build_transport_resource()); + app.insert_resource(ClientUsernames::default()); + app.insert_resource(ActiveConnections::default()); + app.insert_resource(PendingDisconnects::default()); + app.add_systems(Update, networking_systems::receive_message_system); + app.add_systems(Update, networking_systems::handle_events_system); + app.add_systems( + Last, + ( + networking_systems::process_pending_disconnects_system, + networking_systems::disconnect_all_clients_on_exit_system, + ), + ); + } +} + +impl NetworkingPlugin { + fn build_transport_resource() -> NetcodeServerTransport { + let server_addr = DEFAULT_SERVER_ADDR.parse().unwrap(); let socket = UdpSocket::bind(server_addr).unwrap(); let server_config = ServerConfig { current_time: SystemTime::now() @@ -46,10 +66,7 @@ impl Plugin for NetworkingPlugin { public_addresses: vec![server_addr], authentication: ServerAuthentication::Unsecure, }; - let transport = NetcodeServerTransport::new(server_config, socket).unwrap(); - app.insert_resource(transport); - - app.add_systems(Update, networking_systems::receive_message_system); - app.add_systems(Update, networking_systems::handle_events_system); + NetcodeServerTransport::new(server_config, socket) + .expect("Serverconfig and socket should be valid") } } diff --git a/src/server/networking/resources.rs b/src/server/networking/resources.rs new file mode 100644 index 00000000..87e5af45 --- /dev/null +++ b/src/server/networking/resources.rs @@ -0,0 +1,42 @@ +use crate::prelude::*; + +use std::collections::HashSet; + +use renet::ClientId; + +#[derive(Default, Resource)] +pub struct ActiveConnections { + accepted_clients: HashSet, +} + +impl ActiveConnections { + pub fn accept(&mut self, client_id: ClientId) { + self.accepted_clients.insert(client_id); + } + + pub fn reject(&mut self, client_id: &ClientId) { + self.accepted_clients.remove(client_id); + } + + pub fn is_accepted(&self, client_id: &ClientId) -> bool { + self.accepted_clients.contains(client_id) + } +} + +#[derive(Default, Resource)] +pub struct PendingDisconnects { + pending: Vec, + ready: Vec, +} + +impl PendingDisconnects { + pub fn queue(&mut self, client_id: ClientId) { + self.pending.push(client_id); + } + + pub fn drain_ready(&mut self) -> Vec { + let to_disconnect = std::mem::take(&mut self.ready); + self.ready = std::mem::take(&mut self.pending); + to_disconnect + } +} diff --git a/src/server/networking/systems.rs b/src/server/networking/systems.rs index ed2a2777..7de9ce32 100644 --- a/src/server/networking/systems.rs +++ b/src/server/networking/systems.rs @@ -1,16 +1,41 @@ -use crate::prelude::*; +use crate::{ + networking::resources::{ActiveConnections, PendingDisconnects}, + prelude::*, +}; +use bevy::prelude::*; + +pub fn disconnect_all_clients_on_exit_system( + mut server: ResMut, + mut exit_events: MessageReader, +) { + if exit_events.read().len() > 0 { + server.disconnect_all(); + } +} + +#[allow(clippy::too_many_arguments)] pub fn receive_message_system( mut server: ResMut, mut player_states: ResMut, mut past_block_updates: ResMut, mut chunk_manager: ResMut, + client_usernames: Res, mut request_queue: ResMut, + accepted_clients: Res, #[cfg(feature = "chat")] mut chat_message_events: MessageWriter< chat_events::PlayerChatMessageSendEvent, >, ) { for client_id in server.clients_id() { + if !accepted_clients.is_accepted(&client_id) { + continue; + } + + let username = client_usernames + .username_for_client_id(&client_id) + .cloned() + .expect("All clients should be associated with a username"); while let Some(message) = server.receive_message(client_id, DefaultChannel::ReliableOrdered) { let message = bincode::deserialize(&message).unwrap(); @@ -36,8 +61,10 @@ pub fn receive_message_system( #[cfg(feature = "chat")] NetworkingMessage::ChatMessageSend(message) => { info!("Received chat message from {}", client_id); - chat_message_events - .write(chat_events::PlayerChatMessageSendEvent { client_id, message }); + chat_message_events.write(chat_events::PlayerChatMessageSendEvent { + sender: ChatMessageSender::Player(username), + message, + }); } _ => { warn!("Received unknown message type. (ReliabelOrdered)"); @@ -57,7 +84,10 @@ pub fn receive_message_system( "Received player update from client {} {}", client_id, player.position ); - player_states.players.insert(client_id, player); + let username = client_usernames + .username_for_client_id(&client_id) + .expect("All clients should have associated username"); + player_states.players.insert(*username, player); } NetworkingMessage::ChunkBatchRequest(positions) => { info!( @@ -75,30 +105,59 @@ pub fn receive_message_system( } } +#[allow(clippy::too_many_arguments)] pub fn handle_events_system( mut server: ResMut, mut server_events: MessageReader, mut player_states: ResMut, past_block_updates: Res, mut request_queue: ResMut, + mut client_usernames: ResMut, + mut active_connections: ResMut, + mut pending_disconnects: ResMut, #[cfg(feature = "chat")] mut chat_message_events: MessageWriter< chat_events::PlayerChatMessageSendEvent, >, #[cfg(feature = "chat")] mut chat_sync_events: MessageWriter< chat_events::SyncPlayerChatMessagesEvent, >, + transport: Res, ) { for event in server_events.read() { match event { ServerEvent::ClientConnected { client_id } => { - println!("Client {client_id} connected"); - player_states.players.insert( + let user_data = transport.user_data(*client_id).unwrap(); + let username = Username::from_user_data(&user_data); + + if let Some(existing_client_id) = client_usernames.get_client_id(&username) { + if active_connections.is_accepted(existing_client_id) { + server.send_message( + *client_id, + DefaultChannel::ReliableOrdered, + bincode::serialize(&NetworkingMessage::PlayerReject(String::from( + "Another Client is already connected with that Username.", + ))) + .expect("Message should always be sendable"), + ); + active_connections.reject(client_id); + pending_disconnects.queue(*client_id); + println!("Client {client_id} with Username '{username}' rejected"); + continue; + } + } + + active_connections.accept(*client_id); + + let player_state = player_states.players.entry(username).or_default(); + + client_usernames.insert(*client_id, username); + server.send_message( *client_id, - PlayerState { - position: Vec3::ZERO, - rotation: Quat::IDENTITY, - }, + DefaultChannel::ReliableOrdered, + bincode::serialize(&NetworkingMessage::PlayerAccept(*player_state)) + .expect("Message should always be sendable"), ); + println!("{username} connected"); #[cfg(feature = "chat")] chat_sync_events.write(chat_events::SyncPlayerChatMessagesEvent { @@ -107,12 +166,11 @@ pub fn handle_events_system( #[cfg(feature = "chat")] chat_message_events.write(chat_events::PlayerChatMessageSendEvent { - client_id: SERVER_MESSAGE_ID, - message: format!("Player {} joined the game", *client_id), + sender: ChatMessageSender::Server, + message: format!("{username} joined the game"), }); - let message = - bincode::serialize(&NetworkingMessage::PlayerJoin(*client_id)).unwrap(); + let message = bincode::serialize(&NetworkingMessage::PlayerJoin(username)).unwrap(); server.broadcast_message_except( *client_id, DefaultChannel::ReliableOrdered, @@ -128,26 +186,45 @@ pub fn handle_events_system( server.send_message(*client_id, DefaultChannel::ReliableOrdered, message); } } - ServerEvent::ClientDisconnected { client_id, reason } => { - println!("Client {client_id} disconnected: {reason}"); - player_states.players.remove(client_id); - + ServerEvent::ClientDisconnected { client_id, .. } => { request_queue.remove(client_id); + if active_connections.is_accepted(client_id) { + active_connections.reject(client_id); - #[cfg(feature = "chat")] - chat_message_events.write(chat_events::PlayerChatMessageSendEvent { - client_id: SERVER_MESSAGE_ID, - message: format!("Player {} left the game", client_id), - }); + let username = client_usernames + .username_for_client_id(client_id) + .cloned() + .expect("All clients should have an associated username"); + + println!("Player {username} disconnected"); + + #[cfg(feature = "chat")] + chat_message_events.write(chat_events::PlayerChatMessageSendEvent { + sender: ChatMessageSender::Server, + message: format!("{username} left the game"), + }); - let message = - bincode::serialize(&NetworkingMessage::PlayerLeave(*client_id)).unwrap(); - server.broadcast_message(DefaultChannel::ReliableOrdered, message); + if let Some(username) = client_usernames.username_for_client_id(client_id) { + let message = + bincode::serialize(&NetworkingMessage::PlayerLeave(*username)) + .expect("Messages should be serializable"); + server.broadcast_message(DefaultChannel::ReliableOrdered, message); + } + } } } } } +pub fn process_pending_disconnects_system( + mut server: ResMut, + mut pending_disconnects: ResMut, +) { + for client_id in pending_disconnects.drain_ready() { + server.disconnect(client_id); + } +} + use bevy::ecs::message::MessageReader; #[cfg(feature = "chat")] use bevy::ecs::message::MessageWriter; diff --git a/src/server/player/resources.rs b/src/server/player/resources.rs index d184c56c..7af70fc0 100644 --- a/src/server/player/resources.rs +++ b/src/server/player/resources.rs @@ -2,7 +2,7 @@ use crate::prelude::*; #[derive(Resource)] pub struct PlayerStates { - pub players: HashMap, + pub players: HashMap, } impl PlayerStates { diff --git a/src/server/player/systems.rs b/src/server/player/systems.rs index f25bd0ce..ac1323c1 100644 --- a/src/server/player/systems.rs +++ b/src/server/player/systems.rs @@ -1,12 +1,28 @@ +use crate::networking::resources::ActiveConnections; use crate::prelude::*; pub fn broadcast_player_attributes_system( mut server: ResMut, + usernames: Res, player_states: Res, + active_connections: Res, ) { for client_id in server.clients_id() { - let mut other_player_states = player_states.players.clone(); - other_player_states.remove(&client_id); + let other_player_states: HashMap = player_states + .players + .iter() + .filter(|(username, _)| { + usernames + .get_client_id(username) + .is_some_and(|client_id| active_connections.is_accepted(client_id)) + }) + .filter(|(username, _)| { + usernames + .username_for_client_id(&client_id) + .is_none_or(|own_username| own_username != *username) + }) + .map(|(u, s)| (*u, *s)) + .collect(); server.send_message( client_id, diff --git a/src/server/prelude.rs b/src/server/prelude.rs index dcb21d1f..dad65071 100644 --- a/src/server/prelude.rs +++ b/src/server/prelude.rs @@ -35,6 +35,7 @@ pub use noise::Perlin; pub use rsmc as lib; // my crates +pub use crate::networking::resources as networking_resources; pub use crate::networking::systems as networking_systems; pub use crate::player::resources as player_resources; diff --git a/src/shared/networking.rs b/src/shared/networking.rs index 197af056..395fe4d8 100644 --- a/src/shared/networking.rs +++ b/src/shared/networking.rs @@ -1,6 +1,13 @@ -use std::time::Duration; +use std::{ + fmt::{Debug, Display}, + time::Duration, +}; -use bevy::math::{IVec3, Quat, Vec3}; +use bevy::{ + ecs::resource::Resource, + math::{IVec3, Quat, Vec3}, +}; +use bevy_renet::netcode::NETCODE_USER_DATA_BYTES; use chrono::DateTime; use renet::{ChannelConfig, ClientId, ConnectionConfig, SendType}; use serde::{Deserialize, Serialize}; @@ -8,17 +15,175 @@ use std::collections::HashMap; use super::{BlockId, Chunk}; -pub const SERVER_MESSAGE_ID: ClientId = 0; +pub const SERVER_USERNAME: &str = "SERVER"; +pub const MAX_USERNAME_LENGTH_BYTES: usize = 50; -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Resource, Default)] +pub struct ClientUsernames { + client_to_username: HashMap, + username_to_client: HashMap, +} + +impl ClientUsernames { + pub fn insert(&mut self, client_id: ClientId, username: Username) { + self.client_to_username.insert(client_id, username); + self.username_to_client.insert(username, client_id); + } + + pub fn get_client_id(&self, username: &Username) -> Option<&ClientId> { + self.username_to_client.get(username) + } + + pub fn username_for_client_id(&self, client_id: &ClientId) -> Option<&Username> { + self.client_to_username.get(client_id) + } +} + +const USERNAME_BUFFER_SIZE: usize = MAX_USERNAME_LENGTH_BYTES + 1; + +#[derive(PartialEq, Eq, Hash, Clone, Copy)] +pub struct Username([u8; USERNAME_BUFFER_SIZE]); + +impl Username { + pub fn new(s: &str) -> Result { + let len = s.len(); + if len > MAX_USERNAME_LENGTH_BYTES { + return Err(format!( + "Username too long: {len} bytes (max {MAX_USERNAME_LENGTH_BYTES})", + )); + } + if s.to_lowercase() == SERVER_USERNAME.to_lowercase() { + return Err(format!( + "Username '{s}' is too similar to '{SERVER_USERNAME}" + )); + } + let mut buf = [0u8; USERNAME_BUFFER_SIZE]; + buf[0] = len as u8; + buf[1..=len].copy_from_slice(s.as_bytes()); + Ok(Self(buf)) + } + + pub fn as_str(&self) -> &str { + let len = self.0[0] as usize; + std::str::from_utf8(&self.0[1..=len]).unwrap_or("invalid") + } + + pub fn is_server(&self) -> bool { + self.as_str() == SERVER_USERNAME + } + + pub fn to_netcode_user_data(&self) -> [u8; NETCODE_USER_DATA_BYTES] { + let mut user_data = [0u8; NETCODE_USER_DATA_BYTES]; + let len = self.0[0] as usize; + user_data[..=len].copy_from_slice(&self.0[..=len]); + user_data + } + + pub fn from_user_data(user_data: &[u8; NETCODE_USER_DATA_BYTES]) -> Self { + let mut len = user_data[0] as usize; + len = len.min(MAX_USERNAME_LENGTH_BYTES); + let mut buf = [0u8; USERNAME_BUFFER_SIZE]; + buf[..=len].copy_from_slice(&user_data[..=len]); + Self(buf) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_username_acts_like_a_string() { + let username = Username::new("Steve").unwrap(); + assert_eq!(format!("{}", username), "Steve"); + } + + #[test] + fn test_bad_usernames_are_rejected() { + let bad_username = Username::new( + "MyUserNameIsProbablyWayyToLongAndCouldCauseTroubleInChatMessagesAndOtherPlaces", + ); + assert!(bad_username.is_err()); + assert!(bad_username.err().unwrap().contains("too long")); + + let bad_username = Username::new("SErVER"); + assert!(bad_username.is_err()); + assert!(bad_username.err().unwrap().contains("SERVER")); + } +} + +impl Display for Username { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl Debug for Username { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Username({:?})", self.as_str()) + } +} + +impl From<&str> for Username { + fn from(value: &str) -> Self { + Self::new(value).unwrap() + } +} + +impl From for Username { + fn from(value: String) -> Self { + Self::from(value.as_str()) + } +} + +impl Serialize for Username { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for Username { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::new(&s).map_err(serde::de::Error::custom) + } +} + +pub const DEFAULT_SPAWN_POINT: Vec3 = Vec3::new(0.0, 43.0, 0.0); // TODO: determine spawn point from terain + +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] pub struct PlayerState { pub position: Vec3, pub rotation: Quat, } +impl Default for PlayerState { + fn default() -> Self { + Self { + position: DEFAULT_SPAWN_POINT, + rotation: Quat::IDENTITY, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum ChatMessageSender { + Player(Username), + Server, +} + +impl Display for ChatMessageSender { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ChatMessageSender::Player(username) => write!(f, "{username}"), + ChatMessageSender::Server => write!(f, "SERVER"), + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ChatMessage { - pub client_id: ClientId, + pub sender: ChatMessageSender, pub message_id: usize, pub timestamp: i64, pub message: String, @@ -29,21 +194,23 @@ impl ChatMessage { let dt = DateTime::from_timestamp_millis(self.timestamp).expect("invalid timestamp"); let timestamp_string = dt.to_string(); - let client_name = match self.client_id { - SERVER_MESSAGE_ID => "SERVER".to_string(), - _ => self.client_id.to_string(), - }; + let username = &self.sender; + let message = &self.message; - format!("[{}] {}: {}", timestamp_string, client_name, self.message) + format!("[{timestamp_string}] {username}: {message}") } } +type RejectReason = String; + #[derive(Serialize, Deserialize, Debug)] pub enum NetworkingMessage { - PlayerJoin(ClientId), - PlayerLeave(ClientId), + PlayerAccept(PlayerState), + PlayerReject(RejectReason), + PlayerJoin(Username), + PlayerLeave(Username), PlayerUpdate(PlayerState), - PlayerSync(HashMap), + PlayerSync(HashMap), ChunkBatchRequest(Vec), ChunkBatchResponse(Vec), ChatMessageSend(String),