Live location tracking and playback for the game "manhunt"

Better state sync, transport error handling

bwc9876.dev 2b2a6622 134abbba

verified
+183 -263
+1
TODO.md
··· 20 - [x] Signaling: All of it 21 - [x] Backend : Better transport error handling 22 - [ ] Backend : Abstract lobby? Separate crate? 23 - [x] Backend : Add checks for when the `powerup_locations` field is an empty array in settings 24 - [ ] Backend : More tests
··· 20 - [x] Signaling: All of it 21 - [x] Backend : Better transport error handling 22 - [ ] Backend : Abstract lobby? Separate crate? 23 + - [x] Transport : Handle transport cancellation better 24 - [x] Backend : Add checks for when the `powerup_locations` field is an empty array in settings 25 - [ ] Backend : More tests
+13 -8
backend/src/game/mod.rs
··· 117 } 118 } 119 120 - async fn consume_event(&self, state: &mut GameState, event: GameEvent) -> Result { 121 if !state.game_ended() { 122 state.event_history.push((Utc::now(), event.clone())); 123 } ··· 126 GameEvent::Ping(player_ping) => state.add_ping(player_ping), 127 GameEvent::ForcePing(target, display) => { 128 if target != state.id { 129 - return Ok(()); 130 } 131 132 let ping = if let Some(display) = display { ··· 149 state.remove_player(id); 150 } 151 GameEvent::TransportDisconnect => { 152 - bail!("Transport disconnected"); 153 } 154 GameEvent::TransportError(err) => { 155 bail!("Transport error: {err}"); ··· 161 162 self.state_update_sender.send_update(); 163 164 - Ok(()) 165 } 166 167 /// Perform a tick for a specific moment in time ··· 253 } 254 255 /// Main loop of the game, handles ticking and receiving messages from [Transport]. 256 - pub async fn main_loop(&self) -> Result<GameHistory> { 257 let mut interval = tokio::time::interval(self.interval); 258 259 interval.set_missed_tick_behavior(MissedTickBehavior::Delay); ··· 265 events = self.transport.receive_messages() => { 266 let mut state = self.state.write().await; 267 for event in events { 268 - if let Err(why) = self.consume_event(&mut state, event).await { 269 - break 'game Err(why); 270 } 271 } 272 } ··· 277 278 if should_break { 279 let history = state.as_game_history(); 280 - break Ok(history); 281 } 282 } 283 }
··· 117 } 118 } 119 120 + async fn consume_event(&self, state: &mut GameState, event: GameEvent) -> Result<bool> { 121 if !state.game_ended() { 122 state.event_history.push((Utc::now(), event.clone())); 123 } ··· 126 GameEvent::Ping(player_ping) => state.add_ping(player_ping), 127 GameEvent::ForcePing(target, display) => { 128 if target != state.id { 129 + return Ok(false); 130 } 131 132 let ping = if let Some(display) = display { ··· 149 state.remove_player(id); 150 } 151 GameEvent::TransportDisconnect => { 152 + return Ok(true); 153 } 154 GameEvent::TransportError(err) => { 155 bail!("Transport error: {err}"); ··· 161 162 self.state_update_sender.send_update(); 163 164 + Ok(false) 165 } 166 167 /// Perform a tick for a specific moment in time ··· 253 } 254 255 /// Main loop of the game, handles ticking and receiving messages from [Transport]. 256 + pub async fn main_loop(&self) -> Result<Option<GameHistory>> { 257 let mut interval = tokio::time::interval(self.interval); 258 259 interval.set_missed_tick_behavior(MissedTickBehavior::Delay); ··· 265 events = self.transport.receive_messages() => { 266 let mut state = self.state.write().await; 267 for event in events { 268 + match self.consume_event(&mut state, event).await { 269 + Ok(should_break) => { 270 + if should_break { 271 + break 'game Ok(None); 272 + } 273 + } 274 + Err(why) => { break 'game Err(why); } 275 } 276 } 277 } ··· 282 283 if should_break { 284 let history = state.as_game_history(); 285 + break Ok(Some(history)); 286 } 287 } 288 }
+18 -8
backend/src/lib.rs
··· 17 use serde::{Deserialize, Serialize}; 18 use tauri::{AppHandle, Manager, State}; 19 use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; 20 - use tauri_specta::{collect_commands, collect_events, Event}; 21 use tokio::sync::RwLock; 22 use transport::MatchboxTransport; 23 use uuid::Uuid; ··· 104 let state_handle = app.state::<AppStateHandle>(); 105 let mut state = state_handle.write().await; 106 match res { 107 - Ok(history) => { 108 let history = AppGameHistory::new(history, profiles); 109 if let Err(why) = history.save_history(&app2) { 110 error!("Failed to save game history: {why:?}"); ··· 115 } 116 state.quit_to_menu(app2); 117 } 118 Err(why) => { 119 error!("Game Error: {why:?}"); 120 - app2.dialog().message("There was a connection error in the game, you have been disconnected").kind(MessageDialogKind::Error).show(|_| {}); 121 state.quit_to_menu(app2); 122 } 123 } ··· 225 let state_handle = app2.state::<AppStateHandle>(); 226 let mut state = state_handle.write().await; 227 match res { 228 - Ok((my_id, start)) => { 229 info!("Starting game as {my_id}"); 230 state.start_game(app_game, my_id, start).await; 231 } 232 Err(why) => { 233 - error!("Lobby Error: {why:?}"); 234 app_game 235 .dialog() 236 - .message("Error joining the lobby") 237 .kind(MessageDialogKind::Error) 238 .show(|_| {}); 239 state.quit_to_menu(app_game); ··· 470 #[specta::specta] 471 /// (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the 472 /// player has none. Returns the updated game state 473 - async fn use_powerup(state: State<'_, AppStateHandle>) -> Result { 474 let game = state.read().await.get_game()?; 475 game.use_powerup().await; 476 Ok(()) ··· 488 489 pub fn mk_specta() -> tauri_specta::Builder { 490 tauri_specta::Builder::<tauri::Wry>::new() 491 .commands(collect_commands![ 492 start_lobby, 493 get_profile, ··· 500 host_start_game, 501 mark_caught, 502 grab_powerup, 503 - use_powerup, 504 check_room_code, 505 get_profiles, 506 replay_game,
··· 17 use serde::{Deserialize, Serialize}; 18 use tauri::{AppHandle, Manager, State}; 19 use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; 20 + use tauri_specta::{collect_commands, collect_events, ErrorHandlingMode, Event}; 21 use tokio::sync::RwLock; 22 use transport::MatchboxTransport; 23 use uuid::Uuid; ··· 104 let state_handle = app.state::<AppStateHandle>(); 105 let mut state = state_handle.write().await; 106 match res { 107 + Ok(Some(history)) => { 108 let history = AppGameHistory::new(history, profiles); 109 if let Err(why) = history.save_history(&app2) { 110 error!("Failed to save game history: {why:?}"); ··· 115 } 116 state.quit_to_menu(app2); 117 } 118 + Ok(None) => { 119 + info!("User quit game"); 120 + } 121 Err(why) => { 122 error!("Game Error: {why:?}"); 123 + app2.dialog() 124 + .message(format!("Connection Error: {why}")) 125 + .kind(MessageDialogKind::Error) 126 + .show(|_| {}); 127 state.quit_to_menu(app2); 128 } 129 } ··· 231 let state_handle = app2.state::<AppStateHandle>(); 232 let mut state = state_handle.write().await; 233 match res { 234 + Ok(Some((my_id, start))) => { 235 info!("Starting game as {my_id}"); 236 state.start_game(app_game, my_id, start).await; 237 + } 238 + Ok(None) => { 239 + info!("User quit lobby"); 240 } 241 Err(why) => { 242 + error!("Lobby Error: {why}"); 243 app_game 244 .dialog() 245 + .message(format!("Error joining the lobby: {why}")) 246 .kind(MessageDialogKind::Error) 247 .show(|_| {}); 248 state.quit_to_menu(app_game); ··· 479 #[specta::specta] 480 /// (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the 481 /// player has none. Returns the updated game state 482 + async fn activate_powerup(state: State<'_, AppStateHandle>) -> Result { 483 let game = state.read().await.get_game()?; 484 game.use_powerup().await; 485 Ok(()) ··· 497 498 pub fn mk_specta() -> tauri_specta::Builder { 499 tauri_specta::Builder::<tauri::Wry>::new() 500 + .error_handling(ErrorHandlingMode::Throw) 501 .commands(collect_commands![ 502 start_lobby, 503 get_profile, ··· 510 host_start_game, 511 mark_caught, 512 grab_powerup, 513 + activate_powerup, 514 check_room_code, 515 get_profiles, 516 replay_game,
+3 -5
backend/src/lobby.rs
··· 162 self.transport.cancel(); 163 } 164 165 - pub async fn open(&self) -> Result<(Uuid, StartGameInfo)> { 166 let transport_inner = self.transport.clone(); 167 tokio::spawn(async move { transport_inner.transport_loop().await }); 168 ··· 181 state.profiles.insert(id, self.self_profile.clone()); 182 } 183 TransportMessage::Disconnected => { 184 - break 'lobby Err(anyhow!( 185 - "Transport disconnected unexpectedly before lobby could start game" 186 - )); 187 } 188 TransportMessage::Error(why) => { 189 break 'lobby Err(anyhow!("Transport error: {why}")); ··· 207 .await 208 .self_id 209 .expect("Error getting self ID"); 210 - break 'lobby Ok((id, start_game_info)); 211 } 212 LobbyMessage::PlayerSwitch(seeker) => { 213 let mut state = self.state.lock().await;
··· 162 self.transport.cancel(); 163 } 164 165 + pub async fn open(&self) -> Result<Option<(Uuid, StartGameInfo)>> { 166 let transport_inner = self.transport.clone(); 167 tokio::spawn(async move { transport_inner.transport_loop().await }); 168 ··· 181 state.profiles.insert(id, self.self_profile.clone()); 182 } 183 TransportMessage::Disconnected => { 184 + break 'lobby Ok(None); 185 } 186 TransportMessage::Error(why) => { 187 break 'lobby Err(anyhow!("Transport error: {why}")); ··· 205 .await 206 .self_id 207 .expect("Error getting self ID"); 208 + break 'lobby Ok(Some((id, start_game_info))); 209 } 210 LobbyMessage::PlayerSwitch(seeker) => { 211 let mut state = self.state.lock().await;
+33 -15
backend/src/transport.rs
··· 6 use anyhow::Context; 7 use futures::FutureExt; 8 use log::error; 9 - use matchbox_socket::{PeerId, PeerState, WebRtcSocket}; 10 use serde::{Deserialize, Serialize}; 11 use tokio::sync::{Mutex, RwLock}; 12 use tokio_util::sync::CancellationToken; ··· 206 pub async fn transport_loop(&self) { 207 let (mut socket, loop_fut) = WebRtcSocket::new_reliable(&self.ws_url); 208 209 - let loop_fut = loop_fut.fuse(); 210 tokio::pin!(loop_fut); 211 212 let mut all_peers = HashSet::<PeerId>::with_capacity(20); ··· 310 let mut buffer = Vec::with_capacity(30); 311 312 tokio::select! { 313 - 314 _ = self.cancel_token.cancelled() => { 315 - socket.close(); 316 } 317 318 - _ = timer.tick() => { 319 - // Transport Tick 320 } 321 322 _ = outgoing_rx.recv_many(&mut buffer, 30) => { 323 - 324 self.handle_send(&mut socket, &all_peers, &mut buffer).await; 325 } 326 - 327 - res = &mut loop_fut => { 328 - // Break on disconnect 329 - if let Err(why) = res { 330 - self.push_incoming(my_id.unwrap_or_default(), TransportMessage::Error(why.to_string())).await; 331 - } 332 - break; 333 - } 334 } 335 } 336 } 337 } 338
··· 6 use anyhow::Context; 7 use futures::FutureExt; 8 use log::error; 9 + use matchbox_socket::{Error as SocketError, PeerId, PeerState, WebRtcSocket}; 10 use serde::{Deserialize, Serialize}; 11 use tokio::sync::{Mutex, RwLock}; 12 use tokio_util::sync::CancellationToken; ··· 206 pub async fn transport_loop(&self) { 207 let (mut socket, loop_fut) = WebRtcSocket::new_reliable(&self.ws_url); 208 209 + let loop_fut = async { 210 + let msg = match loop_fut.await { 211 + Ok(_) => TransportMessage::Disconnected, 212 + Err(e) => { 213 + let msg = match e { 214 + SocketError::ConnectionFailed(e) => { 215 + format!("Failed to connect to server: {e}") 216 + } 217 + SocketError::Disconnected(e) => { 218 + format!("Disconnected from server, network error or kick: {e}") 219 + } 220 + }; 221 + TransportMessage::Error(msg) 222 + } 223 + }; 224 + self.push_incoming(self.my_id.read().await.unwrap_or_default(), msg) 225 + .await; 226 + } 227 + .fuse(); 228 + 229 tokio::pin!(loop_fut); 230 231 let mut all_peers = HashSet::<PeerId>::with_capacity(20); ··· 329 let mut buffer = Vec::with_capacity(30); 330 331 tokio::select! { 332 _ = self.cancel_token.cancelled() => { 333 + // Break if cancelled externally 334 + break; 335 } 336 337 + _ = &mut loop_fut => { 338 + // Break if disconnected 339 + break; 340 } 341 342 + // Rerun every tick 343 + _ = timer.tick() => {} 344 + 345 _ = outgoing_rx.recv_many(&mut buffer, 30) => { 346 + // Handle sending new messages 347 self.handle_send(&mut socket, &all_peers, &mut buffer).await; 348 } 349 } 350 } 351 + 352 + drop(socket); 353 + loop_fut.await 354 } 355 } 356
+40 -146
frontend/src/bindings.ts
··· 9 * (Screen: Menu) Start/Join a new lobby, set `join_code` to `null` to be host, 10 * set it to a join code to be a client. This triggers a screen change to [AppScreen::Lobby] 11 */ 12 - async startLobby( 13 - joinCode: string | null, 14 - settings: GameSettings 15 - ): Promise<Result<null, string>> { 16 - try { 17 - return { 18 - status: "ok", 19 - data: await TAURI_INVOKE("start_lobby", { joinCode, settings }) 20 - }; 21 - } catch (e) { 22 - if (e instanceof Error) throw e; 23 - else return { status: "error", error: e as any }; 24 - } 25 }, 26 /** 27 * (Screen: Menu) Get the user's player profile 28 */ 29 - async getProfile(): Promise<Result<PlayerProfile, string>> { 30 - try { 31 - return { status: "ok", data: await TAURI_INVOKE("get_profile") }; 32 - } catch (e) { 33 - if (e instanceof Error) throw e; 34 - else return { status: "error", error: e as any }; 35 - } 36 }, 37 /** 38 * Quit a running game or leave a lobby 39 */ 40 - async quitToMenu(): Promise<Result<null, string>> { 41 - try { 42 - return { status: "ok", data: await TAURI_INVOKE("quit_to_menu") }; 43 - } catch (e) { 44 - if (e instanceof Error) throw e; 45 - else return { status: "error", error: e as any }; 46 - } 47 }, 48 /** 49 * Get the screen the app should currently be on, returns [AppScreen] 50 */ 51 - async getCurrentScreen(): Promise<Result<AppScreen, string>> { 52 - try { 53 - return { status: "ok", data: await TAURI_INVOKE("get_current_screen") }; 54 - } catch (e) { 55 - if (e instanceof Error) throw e; 56 - else return { status: "error", error: e as any }; 57 - } 58 }, 59 /** 60 * (Screen: Menu) Update the player's profile and persist it 61 */ 62 - async updateProfile(newProfile: PlayerProfile): Promise<Result<null, string>> { 63 - try { 64 - return { status: "ok", data: await TAURI_INVOKE("update_profile", { newProfile }) }; 65 - } catch (e) { 66 - if (e instanceof Error) throw e; 67 - else return { status: "error", error: e as any }; 68 - } 69 }, 70 /** 71 * (Screen: Lobby) Get the current state of the lobby, call after receiving an update event 72 */ 73 - async getLobbyState(): Promise<Result<LobbyState, string>> { 74 - try { 75 - return { status: "ok", data: await TAURI_INVOKE("get_lobby_state") }; 76 - } catch (e) { 77 - if (e instanceof Error) throw e; 78 - else return { status: "error", error: e as any }; 79 - } 80 }, 81 /** 82 * (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the 83 * new lobby state 84 */ 85 - async hostUpdateSettings(settings: GameSettings): Promise<Result<null, string>> { 86 - try { 87 - return { status: "ok", data: await TAURI_INVOKE("host_update_settings", { settings }) }; 88 - } catch (e) { 89 - if (e instanceof Error) throw e; 90 - else return { status: "error", error: e as any }; 91 - } 92 }, 93 /** 94 * (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState] 95 */ 96 - async switchTeams(seeker: boolean): Promise<Result<null, string>> { 97 - try { 98 - return { status: "ok", data: await TAURI_INVOKE("switch_teams", { seeker }) }; 99 - } catch (e) { 100 - if (e instanceof Error) throw e; 101 - else return { status: "error", error: e as any }; 102 - } 103 }, 104 /** 105 * (Screen: Lobby) HOST ONLY: Start the game, stops anyone else from joining and switched screen 106 * to AppScreen::Game. 107 */ 108 - async hostStartGame(): Promise<Result<null, string>> { 109 - try { 110 - return { status: "ok", data: await TAURI_INVOKE("host_start_game") }; 111 - } catch (e) { 112 - if (e instanceof Error) throw e; 113 - else return { status: "error", error: e as any }; 114 - } 115 }, 116 /** 117 * (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state 118 */ 119 - async markCaught(): Promise<Result<null, string>> { 120 - try { 121 - return { status: "ok", data: await TAURI_INVOKE("mark_caught") }; 122 - } catch (e) { 123 - if (e instanceof Error) throw e; 124 - else return { status: "error", error: e as any }; 125 - } 126 }, 127 /** 128 * (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of 129 * the powerup. Returns the new game state after rolling for the powerup 130 */ 131 - async grabPowerup(): Promise<Result<null, string>> { 132 - try { 133 - return { status: "ok", data: await TAURI_INVOKE("grab_powerup") }; 134 - } catch (e) { 135 - if (e instanceof Error) throw e; 136 - else return { status: "error", error: e as any }; 137 - } 138 }, 139 /** 140 * (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the 141 * player has none. Returns the updated game state 142 */ 143 - async usePowerup(): Promise<Result<null, string>> { 144 - try { 145 - return { status: "ok", data: await TAURI_INVOKE("use_powerup") }; 146 - } catch (e) { 147 - if (e instanceof Error) throw e; 148 - else return { status: "error", error: e as any }; 149 - } 150 }, 151 /** 152 * (Screen: Menu) Check if a room code is valid to join, use this before starting a game 153 * for faster error checking. 154 */ 155 - async checkRoomCode(code: string): Promise<Result<boolean, string>> { 156 - try { 157 - return { status: "ok", data: await TAURI_INVOKE("check_room_code", { code }) }; 158 - } catch (e) { 159 - if (e instanceof Error) throw e; 160 - else return { status: "error", error: e as any }; 161 - } 162 }, 163 /** 164 * (Screen: Game) Get all player profiles with display names and profile pictures for this game. 165 * This value will never change and is fairly expensive to clone, so please minimize calls to 166 * this command. 167 */ 168 - async getProfiles(): Promise<Result<Partial<{ [key in string]: PlayerProfile }>, string>> { 169 - try { 170 - return { status: "ok", data: await TAURI_INVOKE("get_profiles") }; 171 - } catch (e) { 172 - if (e instanceof Error) throw e; 173 - else return { status: "error", error: e as any }; 174 - } 175 }, 176 /** 177 * (Screen: Menu) Go to the game replay screen to replay the game history specified by id 178 */ 179 - async replayGame(id: string): Promise<Result<null, string>> { 180 - try { 181 - return { status: "ok", data: await TAURI_INVOKE("replay_game", { id }) }; 182 - } catch (e) { 183 - if (e instanceof Error) throw e; 184 - else return { status: "error", error: e as any }; 185 - } 186 }, 187 /** 188 * (Screen: Menu) Get a list of all previously played games, returns of list of DateTimes that represent when 189 * each game started, use this as a key 190 */ 191 - async listGameHistories(): Promise<Result<string[], string>> { 192 - try { 193 - return { status: "ok", data: await TAURI_INVOKE("list_game_histories") }; 194 - } catch (e) { 195 - if (e instanceof Error) throw e; 196 - else return { status: "error", error: e as any }; 197 - } 198 }, 199 /** 200 * (Screen: Replay) Get the game history that's currently being replayed. Try to limit calls to 201 * this 202 */ 203 - async getCurrentReplayHistory(): Promise<Result<AppGameHistory, string>> { 204 - try { 205 - return { status: "ok", data: await TAURI_INVOKE("get_current_replay_history") }; 206 - } catch (e) { 207 - if (e instanceof Error) throw e; 208 - else return { status: "error", error: e as any }; 209 - } 210 }, 211 /** 212 * (Screen: Game) Get the current settings for this game. 213 */ 214 - async getGameSettings(): Promise<Result<GameSettings, string>> { 215 - try { 216 - return { status: "ok", data: await TAURI_INVOKE("get_game_settings") }; 217 - } catch (e) { 218 - if (e instanceof Error) throw e; 219 - else return { status: "error", error: e as any }; 220 - } 221 }, 222 /** 223 * (Screen: Game) Get the current state of the game. 224 */ 225 - async getGameState(): Promise<Result<GameUiState, string>> { 226 - try { 227 - return { status: "ok", data: await TAURI_INVOKE("get_game_state") }; 228 - } catch (e) { 229 - if (e instanceof Error) throw e; 230 - else return { status: "error", error: e as any }; 231 - } 232 }, 233 /** 234 * (Screen: Setup) Complete user setup and go to the menu screen 235 */ 236 - async completeSetup(profile: PlayerProfile): Promise<Result<null, string>> { 237 - try { 238 - return { status: "ok", data: await TAURI_INVOKE("complete_setup", { profile }) }; 239 - } catch (e) { 240 - if (e instanceof Error) throw e; 241 - else return { status: "error", error: e as any }; 242 - } 243 } 244 }; 245
··· 9 * (Screen: Menu) Start/Join a new lobby, set `join_code` to `null` to be host, 10 * set it to a join code to be a client. This triggers a screen change to [AppScreen::Lobby] 11 */ 12 + async startLobby(joinCode: string | null, settings: GameSettings): Promise<null> { 13 + return await TAURI_INVOKE("start_lobby", { joinCode, settings }); 14 }, 15 /** 16 * (Screen: Menu) Get the user's player profile 17 */ 18 + async getProfile(): Promise<PlayerProfile> { 19 + return await TAURI_INVOKE("get_profile"); 20 }, 21 /** 22 * Quit a running game or leave a lobby 23 */ 24 + async quitToMenu(): Promise<null> { 25 + return await TAURI_INVOKE("quit_to_menu"); 26 }, 27 /** 28 * Get the screen the app should currently be on, returns [AppScreen] 29 */ 30 + async getCurrentScreen(): Promise<AppScreen> { 31 + return await TAURI_INVOKE("get_current_screen"); 32 }, 33 /** 34 * (Screen: Menu) Update the player's profile and persist it 35 */ 36 + async updateProfile(newProfile: PlayerProfile): Promise<null> { 37 + return await TAURI_INVOKE("update_profile", { newProfile }); 38 }, 39 /** 40 * (Screen: Lobby) Get the current state of the lobby, call after receiving an update event 41 */ 42 + async getLobbyState(): Promise<LobbyState> { 43 + return await TAURI_INVOKE("get_lobby_state"); 44 }, 45 /** 46 * (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the 47 * new lobby state 48 */ 49 + async hostUpdateSettings(settings: GameSettings): Promise<null> { 50 + return await TAURI_INVOKE("host_update_settings", { settings }); 51 }, 52 /** 53 * (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState] 54 */ 55 + async switchTeams(seeker: boolean): Promise<null> { 56 + return await TAURI_INVOKE("switch_teams", { seeker }); 57 }, 58 /** 59 * (Screen: Lobby) HOST ONLY: Start the game, stops anyone else from joining and switched screen 60 * to AppScreen::Game. 61 */ 62 + async hostStartGame(): Promise<null> { 63 + return await TAURI_INVOKE("host_start_game"); 64 }, 65 /** 66 * (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state 67 */ 68 + async markCaught(): Promise<null> { 69 + return await TAURI_INVOKE("mark_caught"); 70 }, 71 /** 72 * (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of 73 * the powerup. Returns the new game state after rolling for the powerup 74 */ 75 + async grabPowerup(): Promise<null> { 76 + return await TAURI_INVOKE("grab_powerup"); 77 }, 78 /** 79 * (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the 80 * player has none. Returns the updated game state 81 */ 82 + async activatePowerup(): Promise<null> { 83 + return await TAURI_INVOKE("activate_powerup"); 84 }, 85 /** 86 * (Screen: Menu) Check if a room code is valid to join, use this before starting a game 87 * for faster error checking. 88 */ 89 + async checkRoomCode(code: string): Promise<boolean> { 90 + return await TAURI_INVOKE("check_room_code", { code }); 91 }, 92 /** 93 * (Screen: Game) Get all player profiles with display names and profile pictures for this game. 94 * This value will never change and is fairly expensive to clone, so please minimize calls to 95 * this command. 96 */ 97 + async getProfiles(): Promise<Partial<{ [key in string]: PlayerProfile }>> { 98 + return await TAURI_INVOKE("get_profiles"); 99 }, 100 /** 101 * (Screen: Menu) Go to the game replay screen to replay the game history specified by id 102 */ 103 + async replayGame(id: string): Promise<null> { 104 + return await TAURI_INVOKE("replay_game", { id }); 105 }, 106 /** 107 * (Screen: Menu) Get a list of all previously played games, returns of list of DateTimes that represent when 108 * each game started, use this as a key 109 */ 110 + async listGameHistories(): Promise<string[]> { 111 + return await TAURI_INVOKE("list_game_histories"); 112 }, 113 /** 114 * (Screen: Replay) Get the game history that's currently being replayed. Try to limit calls to 115 * this 116 */ 117 + async getCurrentReplayHistory(): Promise<AppGameHistory> { 118 + return await TAURI_INVOKE("get_current_replay_history"); 119 }, 120 /** 121 * (Screen: Game) Get the current settings for this game. 122 */ 123 + async getGameSettings(): Promise<GameSettings> { 124 + return await TAURI_INVOKE("get_game_settings"); 125 }, 126 /** 127 * (Screen: Game) Get the current state of the game. 128 */ 129 + async getGameState(): Promise<GameUiState> { 130 + return await TAURI_INVOKE("get_game_state"); 131 }, 132 /** 133 * (Screen: Setup) Complete user setup and go to the menu screen 134 */ 135 + async completeSetup(profile: PlayerProfile): Promise<null> { 136 + return await TAURI_INVOKE("complete_setup", { profile }); 137 } 138 }; 139
+3 -6
frontend/src/components/App.tsx
··· 1 import React from "react"; 2 import useSWR from "swr"; 3 import { AppScreen, commands } from "@/bindings"; 4 - import { useTauriEvent } from "@/lib/hooks"; 5 - import { unwrapResult } from "@/lib/result"; 6 import SetupScreen from "./SetupScreen"; 7 import MenuScreen from "./MenuScreen"; 8 import LobbyScreen from "./LobbyScreen"; ··· 26 export default function App() { 27 const { data: screen, mutate } = useSWR( 28 "fetch-screen", 29 - async () => { 30 - return unwrapResult(await commands.getCurrentScreen()); 31 - }, 32 - { suspense: true, dedupingInterval: 100 } 33 ); 34 35 useTauriEvent("changeScreen", (newScreen) => {
··· 1 import React from "react"; 2 import useSWR from "swr"; 3 import { AppScreen, commands } from "@/bindings"; 4 + import { useTauriEvent, sharedSwrConfig } from "@/lib/hooks"; 5 import SetupScreen from "./SetupScreen"; 6 import MenuScreen from "./MenuScreen"; 7 import LobbyScreen from "./LobbyScreen"; ··· 25 export default function App() { 26 const { data: screen, mutate } = useSWR( 27 "fetch-screen", 28 + commands.getCurrentScreen, 29 + sharedSwrConfig 30 ); 31 32 useTauriEvent("changeScreen", (newScreen) => {
+14 -18
frontend/src/components/GameScreen.tsx
··· 1 import React from "react"; 2 import { commands } from "@/bindings"; 3 - import { useTauriEvent } from "@/lib/hooks"; 4 import useSWR from "swr"; 5 - import { unwrapResult } from "@/lib/result"; 6 7 export default function GameScreen() { 8 - const profiles = unwrapResult(React.use(commands.getProfiles())); 9 const { data: gameState, mutate } = useSWR( 10 "fetch-game-state", 11 - async () => { 12 - return unwrapResult(await commands.getGameState()); 13 - }, 14 - { 15 - suspense: true, 16 - dedupingInterval: 100 17 - } 18 ); 19 20 useTauriEvent("gameStateUpdate", () => { ··· 25 26 const markCaught = async () => { 27 if (!isSeeker) { 28 - unwrapResult(await commands.markCaught()); 29 } 30 }; 31 32 const grabPowerup = async () => { 33 if (gameState.available_powerup !== null) { 34 - unwrapResult(await commands.grabPowerup()); 35 } 36 }; 37 38 - const usePowerup = async () => { 39 if (gameState.held_powerup !== null && gameState.held_powerup !== "PingSeeker") { 40 - unwrapResult(await commands.usePowerup()); 41 } 42 }; 43 44 const quitToMenu = async () => { 45 - unwrapResult(await commands.quitToMenu()); 46 }; 47 48 if (gameState.game_ended) { ··· 56 {Object.keys(gameState.caught_state) 57 .filter((k) => !gameState.caught_state[k]) 58 .map((key) => ( 59 - <li key={key}>{profiles[key]?.display_name ?? key}</li> 60 ))} 61 {!isSeeker && <button onClick={markCaught}>I got caught!</button>} 62 <h2>Pings</h2> ··· 67 .filter(([key, v]) => key && v !== undefined) 68 .map(([k, v]) => ( 69 <li key={k}> 70 - {profiles[v!.display_player]?.display_name ?? v!.display_player} 71 : {v && JSON.stringify(v.loc)} 72 </li> 73 ))} ··· 90 Held Powerup: {gameState.held_powerup} 91 {(gameState.held_powerup === "PingSeeker" && ( 92 <small>(Will be used next ping)</small> 93 - )) || <button onClick={usePowerup}>Use</button>} 94 </p> 95 )} 96 <h2>Quit</h2>
··· 1 import React from "react"; 2 import { commands } from "@/bindings"; 3 + import { sharedSwrConfig, useTauriEvent } from "@/lib/hooks"; 4 import useSWR from "swr"; 5 6 export default function GameScreen() { 7 + const { data: profiles } = useSWR("game-get-profiles", commands.getProfiles); 8 + 9 const { data: gameState, mutate } = useSWR( 10 "fetch-game-state", 11 + commands.getGameState, 12 + sharedSwrConfig 13 ); 14 15 useTauriEvent("gameStateUpdate", () => { ··· 20 21 const markCaught = async () => { 22 if (!isSeeker) { 23 + await commands.markCaught(); 24 } 25 }; 26 27 const grabPowerup = async () => { 28 if (gameState.available_powerup !== null) { 29 + await commands.grabPowerup(); 30 } 31 }; 32 33 + const activatePowerup = async () => { 34 if (gameState.held_powerup !== null && gameState.held_powerup !== "PingSeeker") { 35 + await commands.activatePowerup(); 36 } 37 }; 38 39 const quitToMenu = async () => { 40 + await commands.quitToMenu(); 41 }; 42 43 if (gameState.game_ended) { ··· 51 {Object.keys(gameState.caught_state) 52 .filter((k) => !gameState.caught_state[k]) 53 .map((key) => ( 54 + <li key={key}>{profiles?.[key]?.display_name ?? key}</li> 55 ))} 56 {!isSeeker && <button onClick={markCaught}>I got caught!</button>} 57 <h2>Pings</h2> ··· 62 .filter(([key, v]) => key && v !== undefined) 63 .map(([k, v]) => ( 64 <li key={k}> 65 + {profiles?.[v!.display_player]?.display_name ?? 66 + v!.display_player} 67 : {v && JSON.stringify(v.loc)} 68 </li> 69 ))} ··· 86 Held Powerup: {gameState.held_powerup} 87 {(gameState.held_powerup === "PingSeeker" && ( 88 <small>(Will be used next ping)</small> 89 + )) || <button onClick={activatePowerup}>Use</button>} 90 </p> 91 )} 92 <h2>Quit</h2>
+6 -12
frontend/src/components/LobbyScreen.tsx
··· 1 import React from "react"; 2 import { commands } from "@/bindings"; 3 - import { useTauriEvent } from "@/lib/hooks"; 4 import useSWR from "swr"; 5 - import { unwrapResult } from "@/lib/result"; 6 7 export default function LobbyScreen() { 8 const { data: lobbyState, mutate } = useSWR( 9 "fetch-lobby-state", 10 - async () => { 11 - return unwrapResult(await commands.getLobbyState()); 12 - }, 13 - { 14 - suspense: true, 15 - dedupingInterval: 100 16 - } 17 ); 18 19 useTauriEvent("lobbyStateUpdate", () => { ··· 21 }); 22 23 const setSeeker = async (seeker: boolean) => { 24 - unwrapResult(await commands.switchTeams(seeker)); 25 }; 26 27 const startGame = async () => { 28 - unwrapResult(await commands.hostStartGame()); 29 }; 30 31 const quit = async () => { 32 - unwrapResult(await commands.quitToMenu()); 33 }; 34 35 if (lobbyState.self_id === null) {
··· 1 import React from "react"; 2 import { commands } from "@/bindings"; 3 + import { sharedSwrConfig, useTauriEvent } from "@/lib/hooks"; 4 import useSWR from "swr"; 5 6 export default function LobbyScreen() { 7 const { data: lobbyState, mutate } = useSWR( 8 "fetch-lobby-state", 9 + commands.getLobbyState, 10 + sharedSwrConfig 11 ); 12 13 useTauriEvent("lobbyStateUpdate", () => { ··· 15 }); 16 17 const setSeeker = async (seeker: boolean) => { 18 + await commands.switchTeams(seeker); 19 }; 20 21 const startGame = async () => { 22 + await commands.hostStartGame(); 23 }; 24 25 const quit = async () => { 26 + await commands.quitToMenu(); 27 }; 28 29 if (lobbyState.self_id === null) {
+23 -29
frontend/src/components/MenuScreen.tsx
··· 1 import { commands, GameSettings } from "@/bindings"; 2 - import { unwrapResult } from "@/lib/result"; 3 import React from "react"; 4 5 // Temp settings for now. 6 const settings: GameSettings = { ··· 20 ] 21 }; 22 23 - function MainMenu({ 24 - profilePromise, 25 - historyPromise 26 - }: { 27 - profilePromise: ReturnType<typeof commands.getProfile>; 28 - historyPromise: ReturnType<typeof commands.listGameHistories>; 29 - }) { 30 - const initialProfile = unwrapResult(React.use(profilePromise)); 31 - const gameHistory = unwrapResult(React.use(historyPromise)); 32 - const [profile, setProfile] = React.useState(initialProfile); 33 - const [newName, setName] = React.useState(initialProfile.display_name); 34 const [roomCode, setRoomCode] = React.useState(""); 35 36 const onStartGame = async (code: string | null) => { 37 if (code) { 38 try { 39 - const validCode = unwrapResult(await commands.checkRoomCode(code)); 40 if (!validCode) { 41 window.alert("Invalid Join Code"); 42 return; ··· 50 }; 51 52 const onSaveProfile = async () => { 53 - unwrapResult(await commands.updateProfile({ ...profile, display_name: newName })); 54 - setProfile((p) => { 55 - return { ...p, display_name: newName }; 56 - }); 57 }; 58 59 return ( ··· 77 </div> 78 <hr /> 79 <h3>Edit Profile</h3> 80 - <input value={newName} onChange={(e) => setName(e.target.value)} /> 81 <button onClick={onSaveProfile}>Save</button> 82 <hr /> 83 <h3>Previous Games</h3> ··· 89 </> 90 ); 91 } 92 - 93 - export default function MenuScreen() { 94 - const profilePromise = commands.getProfile(); 95 - const previousGamesPromise = commands.listGameHistories(); 96 - 97 - return ( 98 - <React.Suspense fallback={<p>Loading profile</p>}> 99 - <MainMenu profilePromise={profilePromise} historyPromise={previousGamesPromise} /> 100 - </React.Suspense> 101 - ); 102 - }
··· 1 import { commands, GameSettings } from "@/bindings"; 2 + import { sharedSwrConfig } from "@/lib/hooks"; 3 import React from "react"; 4 + import useSWR from "swr"; 5 6 // Temp settings for now. 7 const settings: GameSettings = { ··· 21 ] 22 }; 23 24 + export default function MenuScreen() { 25 const [roomCode, setRoomCode] = React.useState(""); 26 + const [newName, setName] = React.useState(""); 27 + 28 + const { data: profile, mutate: setProfile } = useSWR( 29 + "fetch-profile", 30 + commands.getProfile, 31 + sharedSwrConfig 32 + ); 33 + const { data: gameHistory } = useSWR( 34 + "list-game-history", 35 + commands.listGameHistories, 36 + sharedSwrConfig 37 + ); 38 39 const onStartGame = async (code: string | null) => { 40 if (code) { 41 try { 42 + const validCode = await commands.checkRoomCode(code); 43 if (!validCode) { 44 window.alert("Invalid Join Code"); 45 return; ··· 53 }; 54 55 const onSaveProfile = async () => { 56 + await commands.updateProfile({ ...profile, display_name: newName }); 57 + setProfile({ ...profile, display_name: newName }); 58 }; 59 60 return ( ··· 78 </div> 79 <hr /> 80 <h3>Edit Profile</h3> 81 + <input 82 + placeholder={profile.display_name} 83 + value={newName} 84 + onChange={(e) => setName(e.target.value)} 85 + /> 86 <button onClick={onSaveProfile}>Save</button> 87 <hr /> 88 <h3>Previous Games</h3> ··· 94 </> 95 ); 96 }
+1 -2
frontend/src/components/SetupScreen.tsx
··· 1 import React from "react"; 2 import { commands, PlayerProfile } from "@/bindings"; 3 - import { unwrapResult } from "@/lib/result"; 4 5 export default function SetupScreen() { 6 const [displayName, setName] = React.useState("User"); 7 8 const onSave = async () => { 9 const profile = { display_name: displayName, pfp_base64: null } as PlayerProfile; 10 - unwrapResult(await commands.completeSetup(profile)); 11 }; 12 13 return (
··· 1 import React from "react"; 2 import { commands, PlayerProfile } from "@/bindings"; 3 4 export default function SetupScreen() { 5 const [displayName, setName] = React.useState("User"); 6 7 const onSave = async () => { 8 const profile = { display_name: displayName, pfp_base64: null } as PlayerProfile; 9 + await commands.completeSetup(profile); 10 }; 11 12 return (
+8 -3
frontend/src/lib/hooks.ts
··· 1 import { useEffect } from "react"; 2 import { events } from "@/bindings"; 3 4 type ExtractCallback<E extends keyof typeof events> = ( 5 payload: Parameters<Parameters<(typeof events)[E]["listen"]>[0]>[0]["payload"] 6 ) => void; 7 8 /** 9 * Convenience hook that does useEffect for a Tauri event and handles unsubscribing on unmount 10 */ 11 - export const useTauriEvent = <E extends keyof typeof events>( 12 tauriEvent: E, 13 cb: ExtractCallback<E> 14 - ) => { 15 useEffect(() => { 16 const unlisten = events[tauriEvent].listen((e) => { 17 cb(e.payload); ··· 21 unlisten.then((f) => f()); 22 }; 23 }, [tauriEvent, cb]); 24 - };
··· 1 import { useEffect } from "react"; 2 import { events } from "@/bindings"; 3 + import { SWRConfiguration } from "swr"; 4 5 type ExtractCallback<E extends keyof typeof events> = ( 6 payload: Parameters<Parameters<(typeof events)[E]["listen"]>[0]>[0]["payload"] 7 ) => void; 8 9 + type SWRConfigTyp = { suspense: true } & SWRConfiguration; 10 + 11 + export const sharedSwrConfig: SWRConfigTyp = { suspense: true, dedupingInterval: 100 }; 12 + 13 /** 14 * Convenience hook that does useEffect for a Tauri event and handles unsubscribing on unmount 15 */ 16 + export function useTauriEvent<E extends keyof typeof events>( 17 tauriEvent: E, 18 cb: ExtractCallback<E> 19 + ) { 20 useEffect(() => { 21 const unlisten = events[tauriEvent].listen((e) => { 22 cb(e.payload); ··· 26 unlisten.then((f) => f()); 27 }; 28 }, [tauriEvent, cb]); 29 + }
-10
frontend/src/lib/result.ts
··· 1 - import { Result } from "@/bindings.ts"; 2 - 3 - export const unwrapResult = <T, E>(res: Result<T, E>): T => { 4 - switch (res.status) { 5 - case "ok": 6 - return res.data; 7 - case "error": 8 - throw res.error; 9 - } 10 - };
···
+19
frontend/src/main.tsx
··· 3 4 const App = React.lazy(() => import("@/components/App")); 5 6 ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 7 <React.StrictMode> 8 <React.Suspense>
··· 3 4 const App = React.lazy(() => import("@/components/App")); 5 6 + import { warn, debug, trace, info, error } from "@tauri-apps/plugin-log"; 7 + 8 + function forwardConsole( 9 + fnName: "log" | "debug" | "info" | "warn" | "error", 10 + logger: (message: string) => Promise<void> 11 + ) { 12 + const original = console[fnName]; 13 + console[fnName] = (message) => { 14 + original(message); 15 + logger(message); 16 + }; 17 + } 18 + 19 + forwardConsole("log", trace); 20 + forwardConsole("debug", debug); 21 + forwardConsole("info", info); 22 + forwardConsole("warn", warn); 23 + forwardConsole("error", error); 24 + 25 ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 26 <React.StrictMode> 27 <React.Suspense>
+1 -1
frontend/vite.config.ts
··· 4 import react from "@vitejs/plugin-react"; 5 import path from "path"; 6 7 - const host = process.env.TAURI_DEV_HOST; 8 9 export default defineConfig(async () => ({ 10 plugins: [react()],
··· 4 import react from "@vitejs/plugin-react"; 5 import path from "path"; 6 7 + const host = process.env.HOST_OVERRIDE || process.env.TAURI_DEV_HOST; 8 9 export default defineConfig(async () => ({ 10 plugins: [react()],