Live location tracking and playback for the game "manhunt"

Rename backend to manhunt-app

bwc9876.dev f6c9fe02 1366ad4b

verified
+625 -598
+2 -2
.prettierignore
··· 1 frontend/dist 2 frontend/node_modules 3 frontend/package-lock.json 4 - backend/gen 5 - backend/target 6 result 7 .prettiercache
··· 1 frontend/dist 2 frontend/node_modules 3 frontend/package-lock.json 4 + manhunt-app/gen 5 + manhunt-app/target 6 result 7 .prettiercache
+1 -1
Cargo.toml
··· 1 [workspace] 2 - members = ["backend", "manhunt-logic", "manhunt-signaling", "manhunt-transport"] 3 resolver = "3" 4 5 [profile.release]
··· 1 [workspace] 2 + members = ["manhunt-app", "manhunt-logic", "manhunt-signaling", "manhunt-transport"] 3 resolver = "3" 4 5 [profile.release]
+2 -2
README.md
··· 64 Game and lobby logic for the app 65 - [manhunt-transport/](https://github.com/Bwc9876/manhunt-app/tree/main/manhunt-transport): 66 Transport (networking) implementation for communication between apps 67 - - [backend/](https://github.com/Bwc9876/manhunt-app/tree/main/backend): App 68 backend, Rust side of the Tauri application 69 - [frontend/](https://github.com/Bwc9876/manhunt-app/tree/main/frontend): App 70 frontend, Web side of the Tauri application ··· 83 - `just check-frontend`: Check for potential issues on the frontend 84 (only need to run if you edited the frontend) 85 86 - **Important**: When changing any type in `backend` that derives `specta::Type`, 87 you need to run `just export-types` to sync these type bindings to the frontend. 88 Otherwise the TypeScript definitions will not match the ones that the backend expects. 89
··· 64 Game and lobby logic for the app 65 - [manhunt-transport/](https://github.com/Bwc9876/manhunt-app/tree/main/manhunt-transport): 66 Transport (networking) implementation for communication between apps 67 + - [manhunt-app/](https://github.com/Bwc9876/manhunt-app/tree/main/manhunt-app): App 68 backend, Rust side of the Tauri application 69 - [frontend/](https://github.com/Bwc9876/manhunt-app/tree/main/frontend): App 70 frontend, Web side of the Tauri application ··· 83 - `just check-frontend`: Check for potential issues on the frontend 84 (only need to run if you edited the frontend) 85 86 + **Important**: When changing any type in a rust file that derives `specta::Type`, 87 you need to run `just export-types` to sync these type bindings to the frontend. 88 Otherwise the TypeScript definitions will not match the ones that the backend expects. 89
backend/.gitignore manhunt-app/.gitignore
backend/Cargo.toml manhunt-app/Cargo.toml
backend/build.rs manhunt-app/build.rs
backend/capabilities/default.json manhunt-app/capabilities/default.json
backend/gen/android/.editorconfig manhunt-app/gen/android/.editorconfig
backend/gen/android/.gitignore manhunt-app/gen/android/.gitignore
backend/gen/android/app/.gitignore manhunt-app/gen/android/app/.gitignore
backend/gen/android/app/build.gradle.kts manhunt-app/gen/android/app/build.gradle.kts
backend/gen/android/app/proguard-rules.pro manhunt-app/gen/android/app/proguard-rules.pro
backend/gen/android/app/src/main/AndroidManifest.xml manhunt-app/gen/android/app/src/main/AndroidManifest.xml
backend/gen/android/app/src/main/java/com/bwc9876/manhunt/app/MainActivity.kt manhunt-app/gen/android/app/src/main/java/com/bwc9876/manhunt/app/MainActivity.kt
backend/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml manhunt-app/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
backend/gen/android/app/src/main/res/drawable/ic_launcher_background.xml manhunt-app/gen/android/app/src/main/res/drawable/ic_launcher_background.xml
backend/gen/android/app/src/main/res/layout/activity_main.xml manhunt-app/gen/android/app/src/main/res/layout/activity_main.xml
backend/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png manhunt-app/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
backend/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png manhunt-app/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
backend/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png manhunt-app/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
backend/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png manhunt-app/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
backend/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png manhunt-app/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
backend/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png manhunt-app/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
backend/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png manhunt-app/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
backend/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png manhunt-app/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
backend/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png manhunt-app/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
backend/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png manhunt-app/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
backend/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png manhunt-app/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
backend/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png manhunt-app/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
backend/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png manhunt-app/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
backend/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png manhunt-app/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
backend/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png manhunt-app/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
backend/gen/android/app/src/main/res/values-night/themes.xml manhunt-app/gen/android/app/src/main/res/values-night/themes.xml
backend/gen/android/app/src/main/res/values/colors.xml manhunt-app/gen/android/app/src/main/res/values/colors.xml
backend/gen/android/app/src/main/res/values/strings.xml manhunt-app/gen/android/app/src/main/res/values/strings.xml
backend/gen/android/app/src/main/res/values/themes.xml manhunt-app/gen/android/app/src/main/res/values/themes.xml
backend/gen/android/app/src/main/res/xml/file_paths.xml manhunt-app/gen/android/app/src/main/res/xml/file_paths.xml
backend/gen/android/build.gradle.kts manhunt-app/gen/android/build.gradle.kts
backend/gen/android/buildSrc/build.gradle.kts manhunt-app/gen/android/buildSrc/build.gradle.kts
backend/gen/android/buildSrc/src/main/java/com/bwc9876/manhunt/app/kotlin/BuildTask.kt manhunt-app/gen/android/buildSrc/src/main/java/com/bwc9876/manhunt/app/kotlin/BuildTask.kt
backend/gen/android/buildSrc/src/main/java/com/bwc9876/manhunt/app/kotlin/RustPlugin.kt manhunt-app/gen/android/buildSrc/src/main/java/com/bwc9876/manhunt/app/kotlin/RustPlugin.kt
backend/gen/android/gradle.properties manhunt-app/gen/android/gradle.properties
backend/gen/android/gradle/wrapper/gradle-wrapper.jar manhunt-app/gen/android/gradle/wrapper/gradle-wrapper.jar
backend/gen/android/gradle/wrapper/gradle-wrapper.properties manhunt-app/gen/android/gradle/wrapper/gradle-wrapper.properties
backend/gen/android/gradlew manhunt-app/gen/android/gradlew
backend/gen/android/gradlew.bat manhunt-app/gen/android/gradlew.bat
backend/gen/android/settings.gradle manhunt-app/gen/android/settings.gradle
backend/icons/128x128.png manhunt-app/icons/128x128.png
backend/icons/128x128@2x.png manhunt-app/icons/128x128@2x.png
backend/icons/32x32.png manhunt-app/icons/32x32.png
backend/icons/Square107x107Logo.png manhunt-app/icons/Square107x107Logo.png
backend/icons/Square142x142Logo.png manhunt-app/icons/Square142x142Logo.png
backend/icons/Square150x150Logo.png manhunt-app/icons/Square150x150Logo.png
backend/icons/Square284x284Logo.png manhunt-app/icons/Square284x284Logo.png
backend/icons/Square30x30Logo.png manhunt-app/icons/Square30x30Logo.png
backend/icons/Square310x310Logo.png manhunt-app/icons/Square310x310Logo.png
backend/icons/Square44x44Logo.png manhunt-app/icons/Square44x44Logo.png
backend/icons/Square71x71Logo.png manhunt-app/icons/Square71x71Logo.png
backend/icons/Square89x89Logo.png manhunt-app/icons/Square89x89Logo.png
backend/icons/StoreLogo.png manhunt-app/icons/StoreLogo.png
backend/icons/icon.icns manhunt-app/icons/icon.icns
backend/icons/icon.ico manhunt-app/icons/icon.ico
backend/icons/icon.png manhunt-app/icons/icon.png
backend/src/export_types.rs manhunt-app/src/export_types.rs
+12 -3
backend/src/history.rs manhunt-app/src/history.rs
··· 5 use tauri_plugin_store::{Store, StoreExt}; 6 use uuid::Uuid; 7 8 - use manhunt_logic::{GameHistory, PlayerProfile}; 9 10 use crate::UtcDT; 11 ··· 15 pub struct AppGameHistory { 16 history: GameHistory, 17 profiles: HashMap<Uuid, PlayerProfile>, 18 } 19 20 impl AppGameHistory { 21 - pub fn new(history: GameHistory, profiles: HashMap<Uuid, PlayerProfile>) -> Self { 22 - Self { history, profiles } 23 } 24 25 fn get_store<R: Runtime>(app: &AppHandle<R>) -> Result<Arc<Store<R>>> {
··· 5 use tauri_plugin_store::{Store, StoreExt}; 6 use uuid::Uuid; 7 8 + use manhunt_logic::{GameHistory, GameSettings, PlayerProfile}; 9 10 use crate::UtcDT; 11 ··· 15 pub struct AppGameHistory { 16 history: GameHistory, 17 profiles: HashMap<Uuid, PlayerProfile>, 18 + settings: GameSettings, 19 } 20 21 impl AppGameHistory { 22 + pub fn new( 23 + history: GameHistory, 24 + profiles: HashMap<Uuid, PlayerProfile>, 25 + settings: GameSettings, 26 + ) -> Self { 27 + Self { 28 + history, 29 + profiles, 30 + settings, 31 + } 32 } 33 34 fn get_store<R: Runtime>(app: &AppHandle<R>) -> Result<Arc<Store<R>>> {
-578
backend/src/lib.rs
··· 1 - mod history; 2 - mod location; 3 - mod profiles; 4 - 5 - use std::{collections::HashMap, marker::PhantomData, sync::Arc, time::Duration}; 6 - 7 - use anyhow::Context; 8 - use location::TauriLocation; 9 - use log::{LevelFilter, error, info, warn}; 10 - use manhunt_logic::{ 11 - Game as BaseGame, GameSettings, GameUiState, Lobby as BaseLobby, LobbyState, PlayerProfile, 12 - StartGameInfo, StateUpdateSender, 13 - }; 14 - use manhunt_transport::{MatchboxTransport, request_room_code, room_exists}; 15 - use serde::{Deserialize, Serialize}; 16 - use tauri::{AppHandle, Manager, State}; 17 - use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; 18 - use tauri_specta::{ErrorHandlingMode, Event, collect_commands, collect_events}; 19 - use tokio::sync::RwLock; 20 - use uuid::Uuid; 21 - 22 - type UtcDT = chrono::DateTime<chrono::Utc>; 23 - 24 - /// The state of the game has changed 25 - #[derive(Serialize, Deserialize, Clone, Default, Debug, specta::Type, tauri_specta::Event)] 26 - struct GameStateUpdate; 27 - 28 - /// The state of the lobby has changed 29 - #[derive(Serialize, Deserialize, Clone, Default, Debug, specta::Type, tauri_specta::Event)] 30 - struct LobbyStateUpdate; 31 - 32 - struct TauriStateUpdateSender<E: Clone + Default + Event + Serialize>(AppHandle, PhantomData<E>); 33 - 34 - impl<E: Serialize + Clone + Default + Event> TauriStateUpdateSender<E> { 35 - fn new(app: &AppHandle) -> Self { 36 - Self(app.clone(), PhantomData) 37 - } 38 - } 39 - 40 - impl<E: Serialize + Clone + Default + Event> StateUpdateSender for TauriStateUpdateSender<E> { 41 - fn send_update(&self) { 42 - if let Err(why) = E::default().emit(&self.0) { 43 - error!("Error sending Game state update to UI: {why:?}"); 44 - } 45 - } 46 - } 47 - 48 - type Game = BaseGame<TauriLocation, MatchboxTransport, TauriStateUpdateSender<GameStateUpdate>>; 49 - type Lobby = BaseLobby<MatchboxTransport, TauriStateUpdateSender<LobbyStateUpdate>>; 50 - 51 - enum AppState { 52 - Setup, 53 - Menu(PlayerProfile), 54 - Lobby(Arc<Lobby>), 55 - Game(Arc<Game>, HashMap<Uuid, PlayerProfile>), 56 - Replay(AppGameHistory), 57 - } 58 - 59 - #[derive(Serialize, Deserialize, specta::Type, Debug, Clone, Eq, PartialEq)] 60 - enum AppScreen { 61 - Setup, 62 - Menu, 63 - Lobby, 64 - Game, 65 - Replay, 66 - } 67 - 68 - type AppStateHandle = RwLock<AppState>; 69 - 70 - const GAME_TICK_RATE: Duration = Duration::from_secs(1); 71 - 72 - /// The app is changing screens, contains the screen it's switching to 73 - #[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] 74 - struct ChangeScreen(AppScreen); 75 - 76 - fn error_dialog(app: &AppHandle, msg: &str) { 77 - app.dialog() 78 - .message(msg) 79 - .kind(MessageDialogKind::Error) 80 - .show(|_| {}); 81 - } 82 - 83 - impl AppState { 84 - pub async fn start_game(&mut self, app: AppHandle, start: StartGameInfo) { 85 - if let AppState::Lobby(lobby) = self { 86 - let transport = lobby.clone_transport(); 87 - let profiles = lobby.clone_profiles().await; 88 - let location = TauriLocation::new(app.clone()); 89 - let state_updates = TauriStateUpdateSender::new(&app); 90 - let game = Arc::new(Game::new( 91 - GAME_TICK_RATE, 92 - start, 93 - transport, 94 - location, 95 - state_updates, 96 - )); 97 - *self = AppState::Game(game.clone(), profiles.clone()); 98 - Self::game_loop(app.clone(), game, profiles); 99 - Self::emit_screen_change(&app, AppScreen::Game); 100 - } 101 - } 102 - 103 - fn game_loop(app: AppHandle, game: Arc<Game>, profiles: HashMap<Uuid, PlayerProfile>) { 104 - tokio::spawn(async move { 105 - let res = game.main_loop().await; 106 - let state_handle = app.state::<AppStateHandle>(); 107 - let mut state = state_handle.write().await; 108 - match res { 109 - Ok(Some(history)) => { 110 - let history = AppGameHistory::new(history, profiles); 111 - if let Err(why) = history.save_history(&app) { 112 - error!("Failed to save game history: {why:?}"); 113 - error_dialog(&app, "Failed to save the history of this game"); 114 - } 115 - state.quit_to_menu(app.clone()).await; 116 - } 117 - Ok(None) => { 118 - info!("User quit game"); 119 - } 120 - Err(why) => { 121 - error!("Game Error: {why:?}"); 122 - app.dialog() 123 - .message(format!("Connection Error: {why}")) 124 - .kind(MessageDialogKind::Error) 125 - .show(|_| {}); 126 - state.quit_to_menu(app.clone()).await; 127 - } 128 - } 129 - }); 130 - } 131 - 132 - pub fn get_menu(&self) -> Result<&PlayerProfile> { 133 - match self { 134 - AppState::Menu(player_profile) => Ok(player_profile), 135 - _ => Err("Not on menu screen".to_string()), 136 - } 137 - } 138 - 139 - pub fn get_menu_mut(&mut self) -> Result<&mut PlayerProfile> { 140 - match self { 141 - AppState::Menu(player_profile) => Ok(player_profile), 142 - _ => Err("Not on menu screen".to_string()), 143 - } 144 - } 145 - 146 - pub fn get_lobby(&self) -> Result<Arc<Lobby>> { 147 - if let AppState::Lobby(lobby) = self { 148 - Ok(lobby.clone()) 149 - } else { 150 - Err("Not on lobby screen".to_string()) 151 - } 152 - } 153 - 154 - pub fn get_game(&self) -> Result<Arc<Game>> { 155 - if let AppState::Game(game, _) = self { 156 - Ok(game.clone()) 157 - } else { 158 - Err("Not on game screen".to_string()) 159 - } 160 - } 161 - 162 - pub fn get_profiles(&self) -> Result<&HashMap<Uuid, PlayerProfile>> { 163 - if let AppState::Game(_, profiles) = self { 164 - Ok(profiles) 165 - } else { 166 - Err("Not on game screen".to_string()) 167 - } 168 - } 169 - 170 - pub fn get_replay(&self) -> Result<AppGameHistory> { 171 - if let AppState::Replay(history) = self { 172 - Ok(history.clone()) 173 - } else { 174 - Err("Not on replay screen".to_string()) 175 - } 176 - } 177 - 178 - fn emit_screen_change(app: &AppHandle, screen: AppScreen) { 179 - if let Err(why) = ChangeScreen(screen).emit(app) { 180 - warn!("Error emitting screen change: {why:?}"); 181 - } 182 - } 183 - 184 - pub fn complete_setup(&mut self, app: &AppHandle, profile: PlayerProfile) -> Result { 185 - if let AppState::Setup = self { 186 - write_profile_to_store(app, profile.clone()); 187 - *self = AppState::Menu(profile); 188 - Self::emit_screen_change(app, AppScreen::Menu); 189 - Ok(()) 190 - } else { 191 - Err("Must be on the Setup screen".to_string()) 192 - } 193 - } 194 - 195 - pub fn replay_game(&mut self, app: &AppHandle, id: UtcDT) -> Result { 196 - if let AppState::Menu(_) = self { 197 - let history = AppGameHistory::get_history(app, id) 198 - .context("Failed to read history") 199 - .map_err(|e| e.to_string())?; 200 - *self = AppState::Replay(history); 201 - Self::emit_screen_change(app, AppScreen::Replay); 202 - Ok(()) 203 - } else { 204 - Err("Not on menu screen".to_string()) 205 - } 206 - } 207 - 208 - fn lobby_loop(app: AppHandle, lobby: Arc<Lobby>) { 209 - tokio::spawn(async move { 210 - let res = lobby.main_loop().await; 211 - let app_game = app.clone(); 212 - let state_handle = app.state::<AppStateHandle>(); 213 - let mut state = state_handle.write().await; 214 - match res { 215 - Ok(Some(start)) => { 216 - info!("Starting Game"); 217 - state.start_game(app_game, start).await; 218 - } 219 - Ok(None) => { 220 - info!("User quit lobby"); 221 - } 222 - Err(why) => { 223 - error!("Lobby Error: {why}"); 224 - error_dialog(&app_game, &format!("Error joining the lobby: {why}")); 225 - state.quit_to_menu(app_game).await; 226 - } 227 - } 228 - }); 229 - } 230 - 231 - pub async fn start_lobby( 232 - &mut self, 233 - join_code: Option<String>, 234 - app: AppHandle, 235 - settings: GameSettings, 236 - ) { 237 - if let AppState::Menu(profile) = self { 238 - let host = join_code.is_none(); 239 - let room_code = if let Some(code) = join_code { 240 - code.to_ascii_uppercase() 241 - } else { 242 - match request_room_code().await { 243 - Ok(code) => code, 244 - Err(why) => { 245 - error_dialog(&app, &format!("Couldn't create a lobby\n\n{why:?}")); 246 - return; 247 - } 248 - } 249 - }; 250 - let state_updates = TauriStateUpdateSender::<LobbyStateUpdate>::new(&app); 251 - let lobby = 252 - Lobby::new(&room_code, host, profile.clone(), settings, state_updates).await; 253 - match lobby { 254 - Ok(lobby) => { 255 - *self = AppState::Lobby(lobby.clone()); 256 - Self::lobby_loop(app.clone(), lobby); 257 - Self::emit_screen_change(&app, AppScreen::Lobby); 258 - } 259 - Err(why) => { 260 - error_dialog( 261 - &app, 262 - &format!("Couldn't connect you to the lobby\n\n{why:?}"), 263 - ); 264 - } 265 - } 266 - } 267 - } 268 - 269 - pub async fn quit_to_menu(&mut self, app: AppHandle) { 270 - let profile = match self { 271 - AppState::Setup => None, 272 - AppState::Menu(_) => { 273 - warn!("Already on menu!"); 274 - return; 275 - } 276 - AppState::Lobby(lobby) => { 277 - lobby.quit_lobby().await; 278 - read_profile_from_store(&app) 279 - } 280 - AppState::Game(game, _) => { 281 - game.quit_game().await; 282 - read_profile_from_store(&app) 283 - } 284 - AppState::Replay(_) => read_profile_from_store(&app), 285 - }; 286 - let screen = if let Some(profile) = profile { 287 - *self = AppState::Menu(profile); 288 - AppScreen::Menu 289 - } else { 290 - *self = AppState::Setup; 291 - AppScreen::Setup 292 - }; 293 - 294 - Self::emit_screen_change(&app, screen); 295 - } 296 - } 297 - 298 - use std::result::Result as StdResult; 299 - 300 - use crate::{ 301 - history::AppGameHistory, 302 - profiles::{read_profile_from_store, write_profile_to_store}, 303 - }; 304 - 305 - type Result<T = (), E = String> = StdResult<T, E>; 306 - 307 - // == GENERAL / FLOW COMMANDS == 308 - 309 - #[tauri::command] 310 - #[specta::specta] 311 - /// Get the screen the app should currently be on, returns [AppScreen] 312 - async fn get_current_screen(state: State<'_, AppStateHandle>) -> Result<AppScreen> { 313 - let state = state.read().await; 314 - Ok(match &*state { 315 - AppState::Setup => AppScreen::Setup, 316 - AppState::Menu(_player_profile) => AppScreen::Menu, 317 - AppState::Lobby(_lobby) => AppScreen::Lobby, 318 - AppState::Game(_game, _profiles) => AppScreen::Game, 319 - AppState::Replay(_) => AppScreen::Replay, 320 - }) 321 - } 322 - 323 - #[tauri::command] 324 - #[specta::specta] 325 - /// Quit a running game or leave a lobby 326 - async fn quit_to_menu(app: AppHandle, state: State<'_, AppStateHandle>) -> Result { 327 - let mut state = state.write().await; 328 - state.quit_to_menu(app).await; 329 - Ok(()) 330 - } 331 - 332 - // == AppState::Setup COMMANDS 333 - 334 - #[tauri::command] 335 - #[specta::specta] 336 - /// (Screen: Setup) Complete user setup and go to the menu screen 337 - async fn complete_setup( 338 - profile: PlayerProfile, 339 - app: AppHandle, 340 - state: State<'_, AppStateHandle>, 341 - ) -> Result { 342 - state.write().await.complete_setup(&app, profile) 343 - } 344 - 345 - // == AppState::Menu COMMANDS == 346 - 347 - #[tauri::command] 348 - #[specta::specta] 349 - /// (Screen: Menu) Get the user's player profile 350 - async fn get_profile(state: State<'_, AppStateHandle>) -> Result<PlayerProfile> { 351 - let state = state.read().await; 352 - let profile = state.get_menu()?; 353 - Ok(profile.clone()) 354 - } 355 - 356 - #[tauri::command] 357 - #[specta::specta] 358 - /// (Screen: Menu) Get a list of all previously played games, returns of list of DateTimes that represent when 359 - /// each game started, use this as a key 360 - fn list_game_histories(app: AppHandle) -> Result<Vec<UtcDT>> { 361 - AppGameHistory::ls_histories(&app) 362 - .map_err(|err| err.context("Failed to get game histories").to_string()) 363 - } 364 - 365 - #[tauri::command] 366 - #[specta::specta] 367 - /// (Screen: Menu) Go to the game replay screen to replay the game history specified by id 368 - async fn replay_game(id: UtcDT, app: AppHandle, state: State<'_, AppStateHandle>) -> Result { 369 - state.write().await.replay_game(&app, id) 370 - } 371 - 372 - #[tauri::command] 373 - #[specta::specta] 374 - /// (Screen: Menu) Check if a room code is valid to join, use this before starting a game 375 - /// for faster error checking. 376 - async fn check_room_code(code: &str) -> Result<bool> { 377 - room_exists(code).await.map_err(|err| err.to_string()) 378 - } 379 - 380 - #[tauri::command] 381 - #[specta::specta] 382 - /// (Screen: Menu) Update the player's profile and persist it 383 - async fn update_profile( 384 - new_profile: PlayerProfile, 385 - app: AppHandle, 386 - state: State<'_, AppStateHandle>, 387 - ) -> Result { 388 - write_profile_to_store(&app, new_profile.clone()); 389 - let mut state = state.write().await; 390 - let profile = state.get_menu_mut()?; 391 - *profile = new_profile; 392 - Ok(()) 393 - } 394 - 395 - #[tauri::command] 396 - #[specta::specta] 397 - /// (Screen: Menu) Start/Join a new lobby, set `join_code` to `null` to be host, 398 - /// set it to a join code to be a client. This triggers a screen change to [AppScreen::Lobby] 399 - async fn start_lobby( 400 - app: AppHandle, 401 - join_code: Option<String>, 402 - settings: GameSettings, 403 - state: State<'_, AppStateHandle>, 404 - ) -> Result { 405 - let mut state = state.write().await; 406 - state.start_lobby(join_code, app, settings).await; 407 - Ok(()) 408 - } 409 - 410 - // AppState::Lobby COMMANDS 411 - 412 - #[tauri::command] 413 - #[specta::specta] 414 - /// (Screen: Lobby) Get the current state of the lobby, call after receiving an update event 415 - async fn get_lobby_state(state: State<'_, AppStateHandle>) -> Result<LobbyState> { 416 - let lobby = state.read().await.get_lobby()?; 417 - Ok(lobby.clone_state().await) 418 - } 419 - 420 - #[tauri::command] 421 - #[specta::specta] 422 - /// (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState] 423 - async fn switch_teams(seeker: bool, state: State<'_, AppStateHandle>) -> Result { 424 - let lobby = state.read().await.get_lobby()?; 425 - lobby.switch_teams(seeker).await; 426 - Ok(()) 427 - } 428 - 429 - #[tauri::command] 430 - #[specta::specta] 431 - /// (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the 432 - /// new lobby state 433 - async fn host_update_settings(settings: GameSettings, state: State<'_, AppStateHandle>) -> Result { 434 - let lobby = state.read().await.get_lobby()?; 435 - lobby.update_settings(settings).await; 436 - Ok(()) 437 - } 438 - 439 - #[tauri::command] 440 - #[specta::specta] 441 - /// (Screen: Lobby) HOST ONLY: Start the game, stops anyone else from joining and switched screen 442 - /// to AppScreen::Game. 443 - async fn host_start_game(state: State<'_, AppStateHandle>) -> Result { 444 - state.read().await.get_lobby()?.start_game().await; 445 - Ok(()) 446 - } 447 - 448 - // AppScreen::Game COMMANDS 449 - 450 - #[tauri::command] 451 - #[specta::specta] 452 - /// (Screen: Game) Get all player profiles with display names and profile pictures for this game. 453 - /// This value will never change and is fairly expensive to clone, so please minimize calls to 454 - /// this command. 455 - async fn get_profiles(state: State<'_, AppStateHandle>) -> Result<HashMap<Uuid, PlayerProfile>> { 456 - state.read().await.get_profiles().cloned() 457 - } 458 - 459 - #[tauri::command] 460 - #[specta::specta] 461 - /// (Screen: Game) Get the current settings for this game. 462 - async fn get_game_settings(state: State<'_, AppStateHandle>) -> Result<GameSettings> { 463 - Ok(state.read().await.get_game()?.clone_settings().await) 464 - } 465 - 466 - #[tauri::command] 467 - #[specta::specta] 468 - /// (Screen: Game) Get the current state of the game. 469 - async fn get_game_state(state: State<'_, AppStateHandle>) -> Result<GameUiState> { 470 - Ok(state.read().await.get_game()?.get_ui_state().await) 471 - } 472 - 473 - #[tauri::command] 474 - #[specta::specta] 475 - /// (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state 476 - async fn mark_caught(state: State<'_, AppStateHandle>) -> Result { 477 - let game = state.read().await.get_game()?; 478 - game.mark_caught().await; 479 - Ok(()) 480 - } 481 - 482 - #[tauri::command] 483 - #[specta::specta] 484 - /// (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of 485 - /// the powerup. Returns the new game state after rolling for the powerup 486 - async fn grab_powerup(state: State<'_, AppStateHandle>) -> Result { 487 - let game = state.read().await.get_game()?; 488 - game.get_powerup().await; 489 - Ok(()) 490 - } 491 - 492 - #[tauri::command] 493 - #[specta::specta] 494 - /// (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the 495 - /// player has none. Returns the updated game state 496 - async fn activate_powerup(state: State<'_, AppStateHandle>) -> Result { 497 - let game = state.read().await.get_game()?; 498 - game.use_powerup().await; 499 - Ok(()) 500 - } 501 - 502 - // AppState::Replay COMMANDS 503 - 504 - #[tauri::command] 505 - #[specta::specta] 506 - /// (Screen: Replay) Get the game history that's currently being replayed. Try to limit calls to 507 - /// this 508 - async fn get_current_replay_history(state: State<'_, AppStateHandle>) -> Result<AppGameHistory> { 509 - state.read().await.get_replay() 510 - } 511 - 512 - pub fn mk_specta() -> tauri_specta::Builder { 513 - tauri_specta::Builder::<tauri::Wry>::new() 514 - .error_handling(ErrorHandlingMode::Throw) 515 - .commands(collect_commands![ 516 - start_lobby, 517 - get_profile, 518 - quit_to_menu, 519 - get_current_screen, 520 - update_profile, 521 - get_lobby_state, 522 - host_update_settings, 523 - switch_teams, 524 - host_start_game, 525 - mark_caught, 526 - grab_powerup, 527 - activate_powerup, 528 - check_room_code, 529 - get_profiles, 530 - replay_game, 531 - list_game_histories, 532 - get_current_replay_history, 533 - get_game_settings, 534 - get_game_state, 535 - complete_setup, 536 - ]) 537 - .events(collect_events![ 538 - ChangeScreen, 539 - GameStateUpdate, 540 - LobbyStateUpdate 541 - ]) 542 - } 543 - 544 - #[cfg_attr(mobile, tauri::mobile_entry_point)] 545 - pub fn run() { 546 - let state = RwLock::new(AppState::Setup); 547 - 548 - let builder = mk_specta(); 549 - 550 - tauri::Builder::default() 551 - .plugin(tauri_plugin_dialog::init()) 552 - .plugin(tauri_plugin_notification::init()) 553 - .plugin( 554 - tauri_plugin_log::Builder::new() 555 - .level(LevelFilter::Debug) 556 - .build(), 557 - ) 558 - .plugin(tauri_plugin_opener::init()) 559 - .plugin(tauri_plugin_geolocation::init()) 560 - .plugin(tauri_plugin_store::Builder::default().build()) 561 - .invoke_handler(builder.invoke_handler()) 562 - .manage(state) 563 - .setup(move |app| { 564 - builder.mount_events(app); 565 - 566 - let handle = app.handle().clone(); 567 - tauri::async_runtime::spawn(async move { 568 - if let Some(profile) = read_profile_from_store(&handle) { 569 - let state_handle = handle.state::<AppStateHandle>(); 570 - let mut state = state_handle.write().await; 571 - *state = AppState::Menu(profile); 572 - } 573 - }); 574 - Ok(()) 575 - }) 576 - .run(tauri::generate_context!()) 577 - .expect("error while running tauri application"); 578 - }
···
backend/src/location.rs manhunt-app/src/location.rs
backend/src/main.rs manhunt-app/src/main.rs
backend/src/profiles.rs manhunt-app/src/profiles.rs
backend/tauri.conf.json manhunt-app/tauri.conf.json
+1
frontend/src/bindings.ts
··· 156 export type AppGameHistory = { 157 history: GameHistory; 158 profiles: Partial<{ [key in string]: PlayerProfile }>; 159 }; 160 export type AppScreen = "Setup" | "Menu" | "Lobby" | "Game" | "Replay"; 161 /**
··· 156 export type AppGameHistory = { 157 history: GameHistory; 158 profiles: Partial<{ [key in string]: PlayerProfile }>; 159 + settings: GameSettings; 160 }; 161 export type AppScreen = "Setup" | "Menu" | "Lobby" | "Game" | "Replay"; 162 /**
+2 -3
justfile
··· 37 npm run lint 38 39 # Export types from the backend to TypeScript bindings 40 - [working-directory('backend')] 41 export-types: 42 - cargo run --bin export-types ../frontend/src/bindings.ts 43 - prettier --write ../frontend/src/bindings.ts --config ../.prettierrc.yaml 44 45 # Start the signaling server on localhost:3536 46 [working-directory('manhunt-signaling')]
··· 37 npm run lint 38 39 # Export types from the backend to TypeScript bindings 40 export-types: 41 + cargo run --bin export-types frontend/src/bindings.ts 42 + prettier --write frontend/src/bindings.ts --config .prettierrc.yaml 43 44 # Start the signaling server on localhost:3536 45 [working-directory('manhunt-signaling')]
+297
manhunt-app/src/lib.rs
···
··· 1 + mod history; 2 + mod location; 3 + mod profiles; 4 + mod state; 5 + 6 + use std::collections::HashMap; 7 + 8 + use log::LevelFilter; 9 + use manhunt_logic::{GameSettings, GameUiState, LobbyState, PlayerProfile, UtcDT}; 10 + use manhunt_transport::room_exists; 11 + use tauri::{AppHandle, Manager, State}; 12 + use tauri_specta::{ErrorHandlingMode, collect_commands, collect_events}; 13 + use tokio::sync::RwLock; 14 + use uuid::Uuid; 15 + 16 + use std::result::Result as StdResult; 17 + 18 + use crate::{ 19 + history::AppGameHistory, 20 + profiles::{read_profile_from_store, write_profile_to_store}, 21 + state::{AppScreen, AppState, AppStateHandle, ChangeScreen, GameStateUpdate, LobbyStateUpdate}, 22 + }; 23 + 24 + type Result<T = (), E = String> = StdResult<T, E>; 25 + 26 + // == GENERAL / FLOW COMMANDS == 27 + 28 + #[tauri::command] 29 + #[specta::specta] 30 + /// Get the screen the app should currently be on, returns [AppScreen] 31 + async fn get_current_screen(state: State<'_, AppStateHandle>) -> Result<AppScreen> { 32 + let state = state.read().await; 33 + Ok(match &*state { 34 + AppState::Setup => AppScreen::Setup, 35 + AppState::Menu(_player_profile) => AppScreen::Menu, 36 + AppState::Lobby(_lobby) => AppScreen::Lobby, 37 + AppState::Game(_game, _profiles) => AppScreen::Game, 38 + AppState::Replay(_) => AppScreen::Replay, 39 + }) 40 + } 41 + 42 + #[tauri::command] 43 + #[specta::specta] 44 + /// Quit a running game or leave a lobby 45 + async fn quit_to_menu(app: AppHandle, state: State<'_, AppStateHandle>) -> Result { 46 + let mut state = state.write().await; 47 + state.quit_to_menu(app).await; 48 + Ok(()) 49 + } 50 + 51 + // == AppState::Setup COMMANDS 52 + 53 + #[tauri::command] 54 + #[specta::specta] 55 + /// (Screen: Setup) Complete user setup and go to the menu screen 56 + async fn complete_setup( 57 + profile: PlayerProfile, 58 + app: AppHandle, 59 + state: State<'_, AppStateHandle>, 60 + ) -> Result { 61 + state.write().await.complete_setup(&app, profile) 62 + } 63 + 64 + // == AppState::Menu COMMANDS == 65 + 66 + #[tauri::command] 67 + #[specta::specta] 68 + /// (Screen: Menu) Get the user's player profile 69 + async fn get_profile(state: State<'_, AppStateHandle>) -> Result<PlayerProfile> { 70 + let state = state.read().await; 71 + let profile = state.get_menu()?; 72 + Ok(profile.clone()) 73 + } 74 + 75 + #[tauri::command] 76 + #[specta::specta] 77 + /// (Screen: Menu) Get a list of all previously played games, returns of list of DateTimes that represent when 78 + /// each game started, use this as a key 79 + fn list_game_histories(app: AppHandle) -> Result<Vec<UtcDT>> { 80 + AppGameHistory::ls_histories(&app) 81 + .map_err(|err| err.context("Failed to get game histories").to_string()) 82 + } 83 + 84 + #[tauri::command] 85 + #[specta::specta] 86 + /// (Screen: Menu) Go to the game replay screen to replay the game history specified by id 87 + async fn replay_game(id: UtcDT, app: AppHandle, state: State<'_, AppStateHandle>) -> Result { 88 + state.write().await.replay_game(&app, id) 89 + } 90 + 91 + #[tauri::command] 92 + #[specta::specta] 93 + /// (Screen: Menu) Check if a room code is valid to join, use this before starting a game 94 + /// for faster error checking. 95 + async fn check_room_code(code: &str) -> Result<bool> { 96 + room_exists(code).await.map_err(|err| err.to_string()) 97 + } 98 + 99 + #[tauri::command] 100 + #[specta::specta] 101 + /// (Screen: Menu) Update the player's profile and persist it 102 + async fn update_profile( 103 + new_profile: PlayerProfile, 104 + app: AppHandle, 105 + state: State<'_, AppStateHandle>, 106 + ) -> Result { 107 + write_profile_to_store(&app, new_profile.clone()); 108 + let mut state = state.write().await; 109 + let profile = state.get_menu_mut()?; 110 + *profile = new_profile; 111 + Ok(()) 112 + } 113 + 114 + #[tauri::command] 115 + #[specta::specta] 116 + /// (Screen: Menu) Start/Join a new lobby, set `join_code` to `null` to be host, 117 + /// set it to a join code to be a client. This triggers a screen change to [AppScreen::Lobby] 118 + async fn start_lobby( 119 + app: AppHandle, 120 + join_code: Option<String>, 121 + settings: GameSettings, 122 + state: State<'_, AppStateHandle>, 123 + ) -> Result { 124 + let mut state = state.write().await; 125 + state.start_lobby(join_code, app, settings).await; 126 + Ok(()) 127 + } 128 + 129 + // AppState::Lobby COMMANDS 130 + 131 + #[tauri::command] 132 + #[specta::specta] 133 + /// (Screen: Lobby) Get the current state of the lobby, call after receiving an update event 134 + async fn get_lobby_state(state: State<'_, AppStateHandle>) -> Result<LobbyState> { 135 + let lobby = state.read().await.get_lobby()?; 136 + Ok(lobby.clone_state().await) 137 + } 138 + 139 + #[tauri::command] 140 + #[specta::specta] 141 + /// (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState] 142 + async fn switch_teams(seeker: bool, state: State<'_, AppStateHandle>) -> Result { 143 + let lobby = state.read().await.get_lobby()?; 144 + lobby.switch_teams(seeker).await; 145 + Ok(()) 146 + } 147 + 148 + #[tauri::command] 149 + #[specta::specta] 150 + /// (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the 151 + /// new lobby state 152 + async fn host_update_settings(settings: GameSettings, state: State<'_, AppStateHandle>) -> Result { 153 + let lobby = state.read().await.get_lobby()?; 154 + lobby.update_settings(settings).await; 155 + Ok(()) 156 + } 157 + 158 + #[tauri::command] 159 + #[specta::specta] 160 + /// (Screen: Lobby) HOST ONLY: Start the game, stops anyone else from joining and switched screen 161 + /// to AppScreen::Game. 162 + async fn host_start_game(state: State<'_, AppStateHandle>) -> Result { 163 + state.read().await.get_lobby()?.start_game().await; 164 + Ok(()) 165 + } 166 + 167 + // AppScreen::Game COMMANDS 168 + 169 + #[tauri::command] 170 + #[specta::specta] 171 + /// (Screen: Game) Get all player profiles with display names and profile pictures for this game. 172 + /// This value will never change and is fairly expensive to clone, so please minimize calls to 173 + /// this command. 174 + async fn get_profiles(state: State<'_, AppStateHandle>) -> Result<HashMap<Uuid, PlayerProfile>> { 175 + state.read().await.get_profiles().cloned() 176 + } 177 + 178 + #[tauri::command] 179 + #[specta::specta] 180 + /// (Screen: Game) Get the current settings for this game. 181 + async fn get_game_settings(state: State<'_, AppStateHandle>) -> Result<GameSettings> { 182 + Ok(state.read().await.get_game()?.clone_settings().await) 183 + } 184 + 185 + #[tauri::command] 186 + #[specta::specta] 187 + /// (Screen: Game) Get the current state of the game. 188 + async fn get_game_state(state: State<'_, AppStateHandle>) -> Result<GameUiState> { 189 + Ok(state.read().await.get_game()?.get_ui_state().await) 190 + } 191 + 192 + #[tauri::command] 193 + #[specta::specta] 194 + /// (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state 195 + async fn mark_caught(state: State<'_, AppStateHandle>) -> Result { 196 + let game = state.read().await.get_game()?; 197 + game.mark_caught().await; 198 + Ok(()) 199 + } 200 + 201 + #[tauri::command] 202 + #[specta::specta] 203 + /// (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of 204 + /// the powerup. Returns the new game state after rolling for the powerup 205 + async fn grab_powerup(state: State<'_, AppStateHandle>) -> Result { 206 + let game = state.read().await.get_game()?; 207 + game.get_powerup().await; 208 + Ok(()) 209 + } 210 + 211 + #[tauri::command] 212 + #[specta::specta] 213 + /// (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the 214 + /// player has none. Returns the updated game state 215 + async fn activate_powerup(state: State<'_, AppStateHandle>) -> Result { 216 + let game = state.read().await.get_game()?; 217 + game.use_powerup().await; 218 + Ok(()) 219 + } 220 + 221 + // AppState::Replay COMMANDS 222 + 223 + #[tauri::command] 224 + #[specta::specta] 225 + /// (Screen: Replay) Get the game history that's currently being replayed. Try to limit calls to 226 + /// this 227 + async fn get_current_replay_history(state: State<'_, AppStateHandle>) -> Result<AppGameHistory> { 228 + state.read().await.get_replay() 229 + } 230 + 231 + pub fn mk_specta() -> tauri_specta::Builder { 232 + tauri_specta::Builder::<tauri::Wry>::new() 233 + .error_handling(ErrorHandlingMode::Throw) 234 + .commands(collect_commands![ 235 + start_lobby, 236 + get_profile, 237 + quit_to_menu, 238 + get_current_screen, 239 + update_profile, 240 + get_lobby_state, 241 + host_update_settings, 242 + switch_teams, 243 + host_start_game, 244 + mark_caught, 245 + grab_powerup, 246 + activate_powerup, 247 + check_room_code, 248 + get_profiles, 249 + replay_game, 250 + list_game_histories, 251 + get_current_replay_history, 252 + get_game_settings, 253 + get_game_state, 254 + complete_setup, 255 + ]) 256 + .events(collect_events![ 257 + ChangeScreen, 258 + GameStateUpdate, 259 + LobbyStateUpdate 260 + ]) 261 + } 262 + 263 + #[cfg_attr(mobile, tauri::mobile_entry_point)] 264 + pub fn run() { 265 + let state = RwLock::new(AppState::Setup); 266 + 267 + let builder = mk_specta(); 268 + 269 + tauri::Builder::default() 270 + .plugin(tauri_plugin_dialog::init()) 271 + .plugin(tauri_plugin_notification::init()) 272 + .plugin( 273 + tauri_plugin_log::Builder::new() 274 + .level(LevelFilter::Debug) 275 + .build(), 276 + ) 277 + .plugin(tauri_plugin_opener::init()) 278 + .plugin(tauri_plugin_geolocation::init()) 279 + .plugin(tauri_plugin_store::Builder::default().build()) 280 + .invoke_handler(builder.invoke_handler()) 281 + .manage(state) 282 + .setup(move |app| { 283 + builder.mount_events(app); 284 + 285 + let handle = app.handle().clone(); 286 + tauri::async_runtime::spawn(async move { 287 + if let Some(profile) = read_profile_from_store(&handle) { 288 + let state_handle = handle.state::<AppStateHandle>(); 289 + let mut state = state_handle.write().await; 290 + *state = AppState::Menu(profile); 291 + } 292 + }); 293 + Ok(()) 294 + }) 295 + .run(tauri::generate_context!()) 296 + .expect("error while running tauri application"); 297 + }
+300
manhunt-app/src/state.rs
···
··· 1 + use std::{collections::HashMap, marker::PhantomData, sync::Arc, time::Duration}; 2 + 3 + use anyhow::Context; 4 + use log::{error, info, warn}; 5 + use manhunt_logic::{ 6 + Game as BaseGame, GameSettings, Lobby as BaseLobby, PlayerProfile, StartGameInfo, 7 + StateUpdateSender, UtcDT, 8 + }; 9 + use manhunt_transport::{MatchboxTransport, request_room_code}; 10 + use serde::{Deserialize, Serialize}; 11 + use tauri::{AppHandle, Manager}; 12 + use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; 13 + use tauri_specta::Event; 14 + use tokio::sync::RwLock; 15 + use uuid::Uuid; 16 + 17 + use crate::{ 18 + Result, 19 + history::AppGameHistory, 20 + location::TauriLocation, 21 + profiles::{read_profile_from_store, write_profile_to_store}, 22 + }; 23 + 24 + /// The state of the game has changed 25 + #[derive(Serialize, Deserialize, Clone, Default, Debug, specta::Type, tauri_specta::Event)] 26 + pub struct GameStateUpdate; 27 + 28 + /// The state of the lobby has changed 29 + #[derive(Serialize, Deserialize, Clone, Default, Debug, specta::Type, tauri_specta::Event)] 30 + pub struct LobbyStateUpdate; 31 + 32 + pub struct TauriStateUpdateSender<E: Clone + Default + Event + Serialize>( 33 + AppHandle, 34 + PhantomData<E>, 35 + ); 36 + 37 + impl<E: Serialize + Clone + Default + Event> TauriStateUpdateSender<E> { 38 + fn new(app: &AppHandle) -> Self { 39 + Self(app.clone(), PhantomData) 40 + } 41 + } 42 + 43 + impl<E: Serialize + Clone + Default + Event> StateUpdateSender for TauriStateUpdateSender<E> { 44 + fn send_update(&self) { 45 + if let Err(why) = E::default().emit(&self.0) { 46 + error!("Error sending Game state update to UI: {why:?}"); 47 + } 48 + } 49 + } 50 + 51 + type Game = BaseGame<TauriLocation, MatchboxTransport, TauriStateUpdateSender<GameStateUpdate>>; 52 + type Lobby = BaseLobby<MatchboxTransport, TauriStateUpdateSender<LobbyStateUpdate>>; 53 + 54 + pub enum AppState { 55 + Setup, 56 + Menu(PlayerProfile), 57 + Lobby(Arc<Lobby>), 58 + Game(Arc<Game>, HashMap<Uuid, PlayerProfile>), 59 + Replay(AppGameHistory), 60 + } 61 + 62 + #[derive(Serialize, Deserialize, specta::Type, Debug, Clone, Eq, PartialEq)] 63 + pub enum AppScreen { 64 + Setup, 65 + Menu, 66 + Lobby, 67 + Game, 68 + Replay, 69 + } 70 + 71 + pub type AppStateHandle = RwLock<AppState>; 72 + 73 + const GAME_TICK_RATE: Duration = Duration::from_secs(1); 74 + 75 + /// The app is changing screens, contains the screen it's switching to 76 + #[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] 77 + pub struct ChangeScreen(AppScreen); 78 + 79 + fn error_dialog(app: &AppHandle, msg: &str) { 80 + app.dialog() 81 + .message(msg) 82 + .kind(MessageDialogKind::Error) 83 + .show(|_| {}); 84 + } 85 + 86 + impl AppState { 87 + pub async fn start_game(&mut self, app: AppHandle, start: StartGameInfo) { 88 + if let AppState::Lobby(lobby) = self { 89 + let transport = lobby.clone_transport(); 90 + let profiles = lobby.clone_profiles().await; 91 + let location = TauriLocation::new(app.clone()); 92 + let state_updates = TauriStateUpdateSender::new(&app); 93 + let game = Arc::new(Game::new( 94 + GAME_TICK_RATE, 95 + start, 96 + transport, 97 + location, 98 + state_updates, 99 + )); 100 + *self = AppState::Game(game.clone(), profiles.clone()); 101 + Self::game_loop(app.clone(), game, profiles); 102 + Self::emit_screen_change(&app, AppScreen::Game); 103 + } 104 + } 105 + 106 + fn game_loop(app: AppHandle, game: Arc<Game>, profiles: HashMap<Uuid, PlayerProfile>) { 107 + tokio::spawn(async move { 108 + let res = game.main_loop().await; 109 + let state_handle = app.state::<AppStateHandle>(); 110 + let mut state = state_handle.write().await; 111 + match res { 112 + Ok(Some(history)) => { 113 + let history = 114 + AppGameHistory::new(history, profiles, game.clone_settings().await); 115 + if let Err(why) = history.save_history(&app) { 116 + error!("Failed to save game history: {why:?}"); 117 + error_dialog(&app, "Failed to save the history of this game"); 118 + } 119 + state.quit_to_menu(app.clone()).await; 120 + } 121 + Ok(None) => { 122 + info!("User quit game"); 123 + } 124 + Err(why) => { 125 + error!("Game Error: {why:?}"); 126 + app.dialog() 127 + .message(format!("Connection Error: {why}")) 128 + .kind(MessageDialogKind::Error) 129 + .show(|_| {}); 130 + state.quit_to_menu(app.clone()).await; 131 + } 132 + } 133 + }); 134 + } 135 + 136 + pub fn get_menu(&self) -> Result<&PlayerProfile> { 137 + match self { 138 + AppState::Menu(player_profile) => Ok(player_profile), 139 + _ => Err("Not on menu screen".to_string()), 140 + } 141 + } 142 + 143 + pub fn get_menu_mut(&mut self) -> Result<&mut PlayerProfile> { 144 + match self { 145 + AppState::Menu(player_profile) => Ok(player_profile), 146 + _ => Err("Not on menu screen".to_string()), 147 + } 148 + } 149 + 150 + pub fn get_lobby(&self) -> Result<Arc<Lobby>> { 151 + if let AppState::Lobby(lobby) = self { 152 + Ok(lobby.clone()) 153 + } else { 154 + Err("Not on lobby screen".to_string()) 155 + } 156 + } 157 + 158 + pub fn get_game(&self) -> Result<Arc<Game>> { 159 + if let AppState::Game(game, _) = self { 160 + Ok(game.clone()) 161 + } else { 162 + Err("Not on game screen".to_string()) 163 + } 164 + } 165 + 166 + pub fn get_profiles(&self) -> Result<&HashMap<Uuid, PlayerProfile>> { 167 + if let AppState::Game(_, profiles) = self { 168 + Ok(profiles) 169 + } else { 170 + Err("Not on game screen".to_string()) 171 + } 172 + } 173 + 174 + pub fn get_replay(&self) -> Result<AppGameHistory> { 175 + if let AppState::Replay(history) = self { 176 + Ok(history.clone()) 177 + } else { 178 + Err("Not on replay screen".to_string()) 179 + } 180 + } 181 + 182 + fn emit_screen_change(app: &AppHandle, screen: AppScreen) { 183 + if let Err(why) = ChangeScreen(screen).emit(app) { 184 + warn!("Error emitting screen change: {why:?}"); 185 + } 186 + } 187 + 188 + pub fn complete_setup(&mut self, app: &AppHandle, profile: PlayerProfile) -> Result { 189 + if let AppState::Setup = self { 190 + write_profile_to_store(app, profile.clone()); 191 + *self = AppState::Menu(profile); 192 + Self::emit_screen_change(app, AppScreen::Menu); 193 + Ok(()) 194 + } else { 195 + Err("Must be on the Setup screen".to_string()) 196 + } 197 + } 198 + 199 + pub fn replay_game(&mut self, app: &AppHandle, id: UtcDT) -> Result { 200 + if let AppState::Menu(_) = self { 201 + let history = AppGameHistory::get_history(app, id) 202 + .context("Failed to read history") 203 + .map_err(|e| e.to_string())?; 204 + *self = AppState::Replay(history); 205 + Self::emit_screen_change(app, AppScreen::Replay); 206 + Ok(()) 207 + } else { 208 + Err("Not on menu screen".to_string()) 209 + } 210 + } 211 + 212 + fn lobby_loop(app: AppHandle, lobby: Arc<Lobby>) { 213 + tokio::spawn(async move { 214 + let res = lobby.main_loop().await; 215 + let app_game = app.clone(); 216 + let state_handle = app.state::<AppStateHandle>(); 217 + let mut state = state_handle.write().await; 218 + match res { 219 + Ok(Some(start)) => { 220 + info!("Starting Game"); 221 + state.start_game(app_game, start).await; 222 + } 223 + Ok(None) => { 224 + info!("User quit lobby"); 225 + } 226 + Err(why) => { 227 + error!("Lobby Error: {why}"); 228 + error_dialog(&app_game, &format!("Error joining the lobby: {why}")); 229 + state.quit_to_menu(app_game).await; 230 + } 231 + } 232 + }); 233 + } 234 + 235 + pub async fn start_lobby( 236 + &mut self, 237 + join_code: Option<String>, 238 + app: AppHandle, 239 + settings: GameSettings, 240 + ) { 241 + if let AppState::Menu(profile) = self { 242 + let host = join_code.is_none(); 243 + let room_code = if let Some(code) = join_code { 244 + code.to_ascii_uppercase() 245 + } else { 246 + match request_room_code().await { 247 + Ok(code) => code, 248 + Err(why) => { 249 + error_dialog(&app, &format!("Couldn't create a lobby\n\n{why:?}")); 250 + return; 251 + } 252 + } 253 + }; 254 + let state_updates = TauriStateUpdateSender::<LobbyStateUpdate>::new(&app); 255 + let lobby = 256 + Lobby::new(&room_code, host, profile.clone(), settings, state_updates).await; 257 + match lobby { 258 + Ok(lobby) => { 259 + *self = AppState::Lobby(lobby.clone()); 260 + Self::lobby_loop(app.clone(), lobby); 261 + Self::emit_screen_change(&app, AppScreen::Lobby); 262 + } 263 + Err(why) => { 264 + error_dialog( 265 + &app, 266 + &format!("Couldn't connect you to the lobby\n\n{why:?}"), 267 + ); 268 + } 269 + } 270 + } 271 + } 272 + 273 + pub async fn quit_to_menu(&mut self, app: AppHandle) { 274 + let profile = match self { 275 + AppState::Setup => None, 276 + AppState::Menu(_) => { 277 + warn!("Already on menu!"); 278 + return; 279 + } 280 + AppState::Lobby(lobby) => { 281 + lobby.quit_lobby().await; 282 + read_profile_from_store(&app) 283 + } 284 + AppState::Game(game, _) => { 285 + game.quit_game().await; 286 + read_profile_from_store(&app) 287 + } 288 + AppState::Replay(_) => read_profile_from_store(&app), 289 + }; 290 + let screen = if let Some(profile) = profile { 291 + *self = AppState::Menu(profile); 292 + AppScreen::Menu 293 + } else { 294 + *self = AppState::Setup; 295 + AppScreen::Setup 296 + }; 297 + 298 + Self::emit_screen_change(&app, screen); 299 + } 300 + }
+1 -1
manhunt-logic/src/lib.rs
··· 10 mod tests; 11 mod transport; 12 13 - pub use game::{Game, StateUpdateSender}; 14 pub use game_events::GameEvent; 15 pub use game_state::{GameHistory, GameUiState}; 16 pub use lobby::{Lobby, LobbyMessage, LobbyState, StartGameInfo};
··· 10 mod tests; 11 mod transport; 12 13 + pub use game::{Game, StateUpdateSender, UtcDT}; 14 pub use game_events::GameEvent; 15 pub use game_state::{GameHistory, GameUiState}; 16 pub use lobby::{Lobby, LobbyMessage, LobbyState, StartGameInfo};
+1 -1
nix/packages/manhunt-signaling.nix
··· 9 toSource { 10 root = ../../.; 11 fileset = unions [ 12 - ../../backend 13 ../../manhunt-logic 14 ../../manhunt-transport 15 ../../manhunt-signaling
··· 9 toSource { 10 root = ../../.; 11 fileset = unions [ 12 + ../../manhunt-app 13 ../../manhunt-logic 14 ../../manhunt-transport 15 ../../manhunt-signaling
+6 -7
nix/packages/manhunt.nix
··· 11 copyDesktopItems, 12 rustPlatform, 13 manhunt-frontend, 14 - cargo-nextest, 15 }: 16 rustPlatform.buildRustPackage { 17 pname = "manhunt"; ··· 20 toSource { 21 root = ../../.; 22 fileset = unions [ 23 - ../../backend 24 ../../manhunt-logic 25 ../../manhunt-transport 26 ../../manhunt-signaling ··· 29 ]; 30 }; 31 cargoLock.lockFile = ../../Cargo.lock; 32 - buildAndTestSubdir = "backend"; 33 buildFeatures = [ 34 "tauri/custom-protocol" 35 ]; ··· 50 ]; 51 52 postPatch = '' 53 - substituteInPlace backend/tauri.conf.json \ 54 --replace-fail '"frontendDist": "../frontend/dist"' '"frontendDist": "${manhunt-frontend}"' 55 ''; 56 ··· 59 cargoTestFlags = "-p manhunt-logic -p manhunt-transport -p manhunt-app"; 60 61 postInstall = '' 62 - install -DT backend/icons/128x128@2x.png $out/share/icons/hicolor/256x256@2/apps/manhunt.png 63 - install -DT backend/icons/128x128.png $out/share/icons/hicolor/128x128/apps/manhunt.png 64 - install -DT backend/icons/32x32.png $out/share/icons/hicolor/32x32/apps/manhunt.png 65 ''; 66 67 meta = with lib; {
··· 11 copyDesktopItems, 12 rustPlatform, 13 manhunt-frontend, 14 }: 15 rustPlatform.buildRustPackage { 16 pname = "manhunt"; ··· 19 toSource { 20 root = ../../.; 21 fileset = unions [ 22 + ../../manhunt-app 23 ../../manhunt-logic 24 ../../manhunt-transport 25 ../../manhunt-signaling ··· 28 ]; 29 }; 30 cargoLock.lockFile = ../../Cargo.lock; 31 + buildAndTestSubdir = "manhunt-app"; 32 buildFeatures = [ 33 "tauri/custom-protocol" 34 ]; ··· 49 ]; 50 51 postPatch = '' 52 + substituteInPlace manhunt-app/tauri.conf.json \ 53 --replace-fail '"frontendDist": "../frontend/dist"' '"frontendDist": "${manhunt-frontend}"' 54 ''; 55 ··· 58 cargoTestFlags = "-p manhunt-logic -p manhunt-transport -p manhunt-app"; 59 60 postInstall = '' 61 + install -DT manhunt-app/icons/128x128@2x.png $out/share/icons/hicolor/256x256@2/apps/manhunt.png 62 + install -DT manhunt-app/icons/128x128.png $out/share/icons/hicolor/128x128/apps/manhunt.png 63 + install -DT manhunt-app/icons/32x32.png $out/share/icons/hicolor/32x32/apps/manhunt.png 64 ''; 65 66 meta = with lib; {