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 1 frontend/dist 2 2 frontend/node_modules 3 3 frontend/package-lock.json 4 - backend/gen 5 - backend/target 4 + manhunt-app/gen 5 + manhunt-app/target 6 6 result 7 7 .prettiercache
+1 -1
Cargo.toml
··· 1 1 [workspace] 2 - members = ["backend", "manhunt-logic", "manhunt-signaling", "manhunt-transport"] 2 + members = ["manhunt-app", "manhunt-logic", "manhunt-signaling", "manhunt-transport"] 3 3 resolver = "3" 4 4 5 5 [profile.release]
+2 -2
README.md
··· 64 64 Game and lobby logic for the app 65 65 - [manhunt-transport/](https://github.com/Bwc9876/manhunt-app/tree/main/manhunt-transport): 66 66 Transport (networking) implementation for communication between apps 67 - - [backend/](https://github.com/Bwc9876/manhunt-app/tree/main/backend): App 67 + - [manhunt-app/](https://github.com/Bwc9876/manhunt-app/tree/main/manhunt-app): App 68 68 backend, Rust side of the Tauri application 69 69 - [frontend/](https://github.com/Bwc9876/manhunt-app/tree/main/frontend): App 70 70 frontend, Web side of the Tauri application ··· 83 83 - `just check-frontend`: Check for potential issues on the frontend 84 84 (only need to run if you edited the frontend) 85 85 86 - **Important**: When changing any type in `backend` that derives `specta::Type`, 86 + **Important**: When changing any type in a rust file that derives `specta::Type`, 87 87 you need to run `just export-types` to sync these type bindings to the frontend. 88 88 Otherwise the TypeScript definitions will not match the ones that the backend expects. 89 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 5 use tauri_plugin_store::{Store, StoreExt}; 6 6 use uuid::Uuid; 7 7 8 - use manhunt_logic::{GameHistory, PlayerProfile}; 8 + use manhunt_logic::{GameHistory, GameSettings, PlayerProfile}; 9 9 10 10 use crate::UtcDT; 11 11 ··· 15 15 pub struct AppGameHistory { 16 16 history: GameHistory, 17 17 profiles: HashMap<Uuid, PlayerProfile>, 18 + settings: GameSettings, 18 19 } 19 20 20 21 impl AppGameHistory { 21 - pub fn new(history: GameHistory, profiles: HashMap<Uuid, PlayerProfile>) -> Self { 22 - Self { history, profiles } 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 + } 23 32 } 24 33 25 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 156 export type AppGameHistory = { 157 157 history: GameHistory; 158 158 profiles: Partial<{ [key in string]: PlayerProfile }>; 159 + settings: GameSettings; 159 160 }; 160 161 export type AppScreen = "Setup" | "Menu" | "Lobby" | "Game" | "Replay"; 161 162 /**
+2 -3
justfile
··· 37 37 npm run lint 38 38 39 39 # Export types from the backend to TypeScript bindings 40 - [working-directory('backend')] 41 40 export-types: 42 - cargo run --bin export-types ../frontend/src/bindings.ts 43 - prettier --write ../frontend/src/bindings.ts --config ../.prettierrc.yaml 41 + cargo run --bin export-types frontend/src/bindings.ts 42 + prettier --write frontend/src/bindings.ts --config .prettierrc.yaml 44 43 45 44 # Start the signaling server on localhost:3536 46 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 10 mod tests; 11 11 mod transport; 12 12 13 - pub use game::{Game, StateUpdateSender}; 13 + pub use game::{Game, StateUpdateSender, UtcDT}; 14 14 pub use game_events::GameEvent; 15 15 pub use game_state::{GameHistory, GameUiState}; 16 16 pub use lobby::{Lobby, LobbyMessage, LobbyState, StartGameInfo};
+1 -1
nix/packages/manhunt-signaling.nix
··· 9 9 toSource { 10 10 root = ../../.; 11 11 fileset = unions [ 12 - ../../backend 12 + ../../manhunt-app 13 13 ../../manhunt-logic 14 14 ../../manhunt-transport 15 15 ../../manhunt-signaling
+6 -7
nix/packages/manhunt.nix
··· 11 11 copyDesktopItems, 12 12 rustPlatform, 13 13 manhunt-frontend, 14 - cargo-nextest, 15 14 }: 16 15 rustPlatform.buildRustPackage { 17 16 pname = "manhunt"; ··· 20 19 toSource { 21 20 root = ../../.; 22 21 fileset = unions [ 23 - ../../backend 22 + ../../manhunt-app 24 23 ../../manhunt-logic 25 24 ../../manhunt-transport 26 25 ../../manhunt-signaling ··· 29 28 ]; 30 29 }; 31 30 cargoLock.lockFile = ../../Cargo.lock; 32 - buildAndTestSubdir = "backend"; 31 + buildAndTestSubdir = "manhunt-app"; 33 32 buildFeatures = [ 34 33 "tauri/custom-protocol" 35 34 ]; ··· 50 49 ]; 51 50 52 51 postPatch = '' 53 - substituteInPlace backend/tauri.conf.json \ 52 + substituteInPlace manhunt-app/tauri.conf.json \ 54 53 --replace-fail '"frontendDist": "../frontend/dist"' '"frontendDist": "${manhunt-frontend}"' 55 54 ''; 56 55 ··· 59 58 cargoTestFlags = "-p manhunt-logic -p manhunt-transport -p manhunt-app"; 60 59 61 60 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 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 65 64 ''; 66 65 67 66 meta = with lib; {