diff --git a/crates/connector/src/game/greceiver.rs b/crates/connector/src/game/greceiver.rs index ba9b148e..36dd5741 100644 --- a/crates/connector/src/game/greceiver.rs +++ b/crates/connector/src/game/greceiver.rs @@ -99,6 +99,12 @@ impl GameProcessor { ToGame::Leave => { self.process_leave(message.meta).await; } + ToGame::Start => { + self.process_start().await; + } + ToGame::Initialized => { + self.process_initialized(message.meta).await; + } } if self.state.is_empty().await { @@ -199,6 +205,15 @@ impl GameProcessor { self.send(&FromGame::JoinError(JoinError::GameFull), meta.source) .await; } + JoinErrorInner::GameNotOpened => { + warn!( + "Player {:?} could not join game on port {} because the game is no longer opened.", + meta.source, self.port + ); + + self.send(&FromGame::JoinError(JoinError::GameNotOpened), meta.source) + .await; + } } } } @@ -233,6 +248,17 @@ impl GameProcessor { self.send_all(&FromGame::PeerLeft(id), None).await; } + async fn process_start(&mut self) { + self.state.start().await; + self.send_all(&FromGame::Starting, None).await; + } + + async fn process_initialized(&mut self, meta: MessageMeta) { + if self.state.mark_initialized(meta.source).await { + self.send_all(&FromGame::Started, None).await; + } + } + /// Send a reliable message to all players of the game. /// /// # Arguments diff --git a/crates/connector/src/game/state.rs b/crates/connector/src/game/state.rs index 495e0b57..5f8076af 100644 --- a/crates/connector/src/game/state.rs +++ b/crates/connector/src/game/state.rs @@ -38,6 +38,17 @@ impl GameState { self.inner.write().await.remove(addr) } + /// If the game is in state `Open`, change it state to `Starting`. + pub(super) async fn start(&mut self) { + self.inner.write().await.start() + } + + /// Marks a player as initialized. Returns true if the game was just + /// started. + pub(super) async fn mark_initialized(&mut self, addr: SocketAddr) -> bool { + self.inner.write().await.mark_initialized(addr) + } + /// Constructs and returns package targets which includes all or all but /// one players connected to the game. It returns None if there is no /// matching target. @@ -52,6 +63,7 @@ impl GameState { struct GameStateInner { available_ids: AvailableIds, + state: GameStateX, players: AHashMap, } @@ -59,6 +71,7 @@ impl GameStateInner { fn new(max_players: u8) -> Self { Self { available_ids: AvailableIds::new(max_players), + state: GameStateX::Open, players: AHashMap::new(), } } @@ -72,11 +85,15 @@ impl GameStateInner { } fn add(&mut self, addr: SocketAddr) -> Result { + if self.state != GameStateX::Open { + return Err(JoinError::GameNotOpened); + } + match self.players.entry(addr) { Entry::Occupied(_) => Err(JoinError::AlreadyJoined), Entry::Vacant(vacant) => match self.available_ids.lease() { Some(id) => { - vacant.insert(Player { id }); + vacant.insert(Player::new(id)); Ok(id) } None => Err(JoinError::GameFull), @@ -94,6 +111,27 @@ impl GameStateInner { } } + fn start(&mut self) { + if self.state == GameStateX::Open { + self.state = GameStateX::Starting; + } + } + + fn mark_initialized(&mut self, addr: SocketAddr) -> bool { + let prev = self.state; + + if matches!(self.state, GameStateX::Starting) { + if let Some(player) = self.players.get_mut(&addr) { + player.initialized = true; + } + if self.players.values().all(|p| p.initialized) { + self.state = GameStateX::Started; + } + } + + self.state == GameStateX::Started && self.state != prev + } + fn targets(&self, exclude: Option) -> Option> { let len = if exclude.map_or(false, |e| self.players.contains_key(&e)) { self.players.len() - 1 @@ -153,16 +191,36 @@ impl AvailableIds { } } -#[derive(Debug, Error)] +#[derive(Debug, Error, PartialEq)] pub(super) enum JoinError { #[error("The player has already joined the game.")] AlreadyJoined, #[error("The game is full.")] GameFull, + #[error("The game is no longer opened.")] + GameNotOpened, } struct Player { id: u8, + initialized: bool, +} + +impl Player { + fn new(id: u8) -> Self { + Self { + id, + initialized: false, + } + } +} + +// TODO better name +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum GameStateX { + Open, + Starting, + Started, } #[cfg(test)] @@ -221,6 +279,25 @@ mod tests { })); } + #[test] + fn test_transitions() { + let client_a: SocketAddr = "127.0.0.1:8081".parse().unwrap(); + let client_b: SocketAddr = "127.0.0.1:8082".parse().unwrap(); + let client_c: SocketAddr = "127.0.0.1:8083".parse().unwrap(); + + let mut state = GameStateInner::new(3); + + state.add(client_a).unwrap(); + state.add(client_b).unwrap(); + + state.start(); + + assert_eq!(state.add(client_c), Err(JoinError::GameNotOpened)); + + assert!(!state.mark_initialized(client_b)); + assert!(state.mark_initialized(client_a)); + } + #[test] fn test_targets() { let mut state = GameStateInner::new(8); diff --git a/crates/map/src/hash.rs b/crates/map/src/hash.rs index 5ca6f88d..50c3292f 100644 --- a/crates/map/src/hash.rs +++ b/crates/map/src/hash.rs @@ -20,7 +20,7 @@ impl MapHash { } /// Constructs the map hash from a hexadecimal string. - pub(crate) fn from_hex(hex: &str) -> Result { + pub fn from_hex(hex: &str) -> Result { if hex.len() != 64 { return Err(HexError::InvalidLenError); } diff --git a/crates/menu/src/multiplayer/joined.rs b/crates/menu/src/multiplayer/joined.rs index d8424064..fefe2472 100644 --- a/crates/menu/src/multiplayer/joined.rs +++ b/crates/menu/src/multiplayer/joined.rs @@ -1,6 +1,13 @@ use bevy::prelude::*; -use de_core::state::AppState; -use de_multiplayer::ShutdownMultiplayerEvent; +use de_core::{ + assets::asset_path, + gconfig::{GameConfig, LocalPlayers}, + player::Player, + state::AppState, +}; +use de_lobby_model::GameMap; +use de_map::hash::MapHash; +use de_multiplayer::{GameStartingEvent, ShutdownMultiplayerEvent}; use super::MultiplayerState; @@ -8,7 +15,13 @@ pub(crate) struct JoinedGamePlugin; impl Plugin for JoinedGamePlugin { fn build(&self, app: &mut App) { - app.add_systems(OnExit(MultiplayerState::GameJoined), cleanup); + app.add_systems(OnExit(MultiplayerState::GameJoined), cleanup) + .add_systems( + Update, + handle_starting + .run_if(in_state(MultiplayerState::GameJoined)) + .run_if(on_event::()), + ); } } @@ -17,3 +30,23 @@ fn cleanup(state: Res>, mut shutdown: EventWriter>) { + // TODO use real conf + let map = GameMap::new("a".to_owned(), "b".to_owned()); + let Ok(hash) = MapHash::from_hex(map.hash()) else { + // TODO move to a different state + // TODO show a toast + return; + }; + let map_path = hash.construct_path(asset_path("maps")); + + commands.insert_resource(GameConfig::new( + map_path, + // TODO use real game conf + Player::Player4, + // TODO use real player + LocalPlayers::new(Player::Player1), + )); + next_state.set(AppState::InGame); +} diff --git a/crates/multiplayer/src/game.rs b/crates/multiplayer/src/game.rs index 6a320127..35c2708b 100644 --- a/crates/multiplayer/src/game.rs +++ b/crates/multiplayer/src/game.rs @@ -20,6 +20,7 @@ impl Plugin for GamePlugin { fn build(&self, app: &mut App) { app.add_event::() .add_event::() + .add_event::() .add_systems(OnEnter(NetState::Connected), (setup, open_or_join)) .add_systems(OnEnter(NetState::None), cleanup) .add_systems( @@ -57,6 +58,10 @@ impl GameJoinedEvent { } } +/// Joined game starting has just initiated. +#[derive(Event)] +pub struct GameStartingEvent; + #[derive(Resource)] pub(crate) struct Players { local: Option, @@ -133,6 +138,7 @@ fn process_from_game( mut fatals: EventWriter, state: Res>, mut joined_events: EventWriter, + mut starting_events: EventWriter, mut next_state: ResMut>, ) { for event in inputs.iter() { @@ -162,6 +168,11 @@ fn process_from_game( JoinError::GameFull => { fatals.send(FatalErrorEvent::new("Game is full, cannot join.")); } + JoinError::GameNotOpened => { + fatals.send(FatalErrorEvent::new( + "Game is no longer opened, cannot join.", + )); + } JoinError::AlreadyJoined => { fatals.send(FatalErrorEvent::new( "Already joined the game, cannot re-join.", @@ -184,6 +195,15 @@ fn process_from_game( FromGame::PeerLeft(id) => { info!("Peer {id} left."); } + FromGame::Starting => { + info!("Multiplayer game is starting."); + starting_events.send(GameStartingEvent); + } + FromGame::Started => { + info!("Multiplayer game is fully started."); + + // TODO + } } } } diff --git a/crates/multiplayer/src/lib.rs b/crates/multiplayer/src/lib.rs index f8604f49..45aa9952 100644 --- a/crates/multiplayer/src/lib.rs +++ b/crates/multiplayer/src/lib.rs @@ -15,7 +15,7 @@ use stats::StatsPlugin; pub use crate::{ config::{ConnectionType, NetGameConf}, - game::{GameJoinedEvent, GameOpenedEvent}, + game::{GameJoinedEvent, GameOpenedEvent, GameStartingEvent}, lifecycle::{MultiplayerShuttingDownEvent, ShutdownMultiplayerEvent, StartMultiplayerEvent}, netstate::NetState, }; diff --git a/crates/net/src/messages.rs b/crates/net/src/messages.rs index b5766e14..48ade0ca 100644 --- a/crates/net/src/messages.rs +++ b/crates/net/src/messages.rs @@ -43,6 +43,11 @@ pub enum ToGame { /// /// The game is automatically closed once all players disconnect. Leave, + /// This initiates game starting. + Start, + /// The game switches from starting state to started state once this + /// message is received from all players. + Initialized, } /// Message to be sent from a game server to a player/client (inside of a @@ -73,11 +78,18 @@ pub enum FromGame { /// Informs the player that another player with the given ID just /// disconnected from the same game. PeerLeft(u8), + /// Informs the client that the game is starting. The game is no longer + /// available for joining. The client should start game initialization. + Starting, + /// Informs the client that the game just started, id est it they can play. + Started, } #[derive(Encode, Decode)] pub enum JoinError { GameFull, + /// The game is no longer opened. + GameNotOpened, /// The player has already joined the game. AlreadyJoined, /// The player already participates on a different game.