Live location tracking and playback for the game "manhunt"

Basic frontend structuring

bwc9876.dev 419651c3 5593c48a

verified
+608 -68
+8 -1
Cargo.lock
··· 1005 1005 checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 1006 1006 1007 1007 [[package]] 1008 + name = "const-str" 1009 + version = "0.6.2" 1010 + source = "registry+https://github.com/rust-lang/crates.io-index" 1011 + checksum = "9e991226a70654b49d34de5ed064885f0bef0348a8e70018b8ff1ac80aa984a2" 1012 + 1013 + [[package]] 1008 1014 name = "convert_case" 1009 1015 version = "0.4.0" 1010 1016 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2907 2913 dependencies = [ 2908 2914 "anyhow", 2909 2915 "chrono", 2916 + "const-str", 2910 2917 "futures", 2911 2918 "log", 2912 2919 "matchbox_socket", ··· 4113 4120 "once_cell", 4114 4121 "socket2", 4115 4122 "tracing", 4116 - "windows-sys 0.52.0", 4123 + "windows-sys 0.59.0", 4117 4124 ] 4118 4125 4119 4126 [[package]]
+1
backend/Cargo.toml
··· 41 41 tokio-util = "0.7.15" 42 42 anyhow = "1.0.98" 43 43 reqwest = { version = "0.12.20", default-features = false, features = ["charset", "http2", "rustls-tls", "system-proxy"] } 44 + const-str = "0.6.2"
+3
backend/src/game/state.rs
··· 395 395 available_powerup: self.available_powerup, 396 396 pings: self.pings.clone(), 397 397 game_started: self.game_started, 398 + game_ended: self.game_ended, 398 399 last_global_ping: self.last_global_ping, 399 400 held_powerup: self.held_powerup, 400 401 seekers_started: self.seekers_started, ··· 428 429 pings: HashMap<Uuid, PlayerPing>, 429 430 /// When the game was started **in UTC** 430 431 game_started: UtcDT, 432 + /// When the game ended, when this is Option::Some, the game has ended 433 + game_ended: Option<UtcDT>, 431 434 /// The last time all hiders were pinged **in UTC** 432 435 last_global_ping: Option<UtcDT>, 433 436 /// The [PowerUpType] the local player is holding
+11 -16
backend/src/lib.rs
··· 3 3 mod lobby; 4 4 mod location; 5 5 mod profile; 6 + mod server; 6 7 mod transport; 7 8 8 9 use std::{collections::HashMap, sync::Arc, time::Duration}; ··· 11 12 use history::AppGameHistory; 12 13 use lobby::{Lobby, LobbyState, StartGameInfo}; 13 14 use location::TauriLocation; 14 - use log::{error, warn}; 15 + use log::{error, info, warn, LevelFilter}; 15 16 use profile::PlayerProfile; 16 - use reqwest::StatusCode; 17 17 use serde::{Deserialize, Serialize}; 18 18 use tauri::{AppHandle, Manager, State}; 19 19 use tauri_specta::{collect_commands, collect_events, Event}; ··· 60 60 61 61 const GAME_TICK_RATE: Duration = Duration::from_secs(1); 62 62 63 - pub const fn server_url() -> &'static str { 64 - if let Some(url) = option_env!("APP_SERVER_URL") { 65 - url 66 - } else { 67 - "ws://localhost:3536" 68 - } 69 - } 70 - 71 63 /// The app is changing screens, contains the screen it's switching to 72 64 #[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] 73 65 struct ChangeScreen(AppScreen); ··· 104 96 state_updates, 105 97 )); 106 98 *self = AppState::Game(game.clone(), profiles.clone()); 99 + Self::emit_screen_change(&app, AppScreen::Game); 107 100 tokio::spawn(async move { 108 101 let res = game.main_loop().await; 109 102 let app2 = app.clone(); ··· 212 205 let host = join_code.is_none(); 213 206 let room_code = join_code.unwrap_or_else(generate_join_code); 214 207 let lobby = Arc::new(Lobby::new( 215 - server_url(), 216 208 &room_code, 217 209 host, 218 210 profile.clone(), ··· 228 220 let mut state = state_handle.write().await; 229 221 match res { 230 222 Ok((my_id, start)) => { 223 + info!("Starting game as {my_id}"); 231 224 state.start_game(app_game, my_id, start).await; 232 225 } 233 226 Err(why) => { ··· 344 337 #[specta::specta] 345 338 /// (Screen: Menu) Check if a room code is valid to join, use this before starting a game 346 339 /// for faster error checking. 347 - async fn check_room_code(code: &str) -> Result<bool, String> { 348 - let url = format!("{}/room_exists/{code}", server_url()); 349 - reqwest::get(url) 340 + async fn check_room_code(code: &str) -> Result<bool> { 341 + server::room_exists(code) 350 342 .await 351 - .map(|resp| resp.status() == StatusCode::OK) 352 343 .map_err(|err| err.to_string()) 353 344 } 354 345 ··· 523 514 524 515 tauri::Builder::default() 525 516 .plugin(tauri_plugin_notification::init()) 526 - .plugin(tauri_plugin_log::Builder::new().build()) 517 + .plugin( 518 + tauri_plugin_log::Builder::new() 519 + .level(LevelFilter::Debug) 520 + .build(), 521 + ) 527 522 .plugin(tauri_plugin_opener::init()) 528 523 .plugin(tauri_plugin_geolocation::init()) 529 524 .plugin(tauri_plugin_store::Builder::default().build())
+38 -33
backend/src/lobby.rs
··· 11 11 game::GameSettings, 12 12 prelude::*, 13 13 profile::PlayerProfile, 14 - server_url, 14 + server, 15 15 transport::{MatchboxTransport, TransportMessage}, 16 16 }; 17 17 ··· 39 39 join_code: String, 40 40 /// True represents seeker, false hider 41 41 teams: HashMap<Uuid, bool>, 42 + self_id: Option<Uuid>, 42 43 self_seeker: bool, 44 + is_host: bool, 43 45 settings: GameSettings, 44 46 } 45 47 ··· 59 61 60 62 impl Lobby { 61 63 pub fn new( 62 - ws_url_base: &str, 63 64 join_code: &str, 64 65 host: bool, 65 66 profile: PlayerProfile, ··· 68 69 ) -> Self { 69 70 Self { 70 71 app, 71 - transport: Arc::new(MatchboxTransport::new(&format!( 72 - "{ws_url_base}/{join_code}{}", 73 - if host { "?create" } else { "" } 74 - ))), 72 + transport: Arc::new(MatchboxTransport::new(join_code, host)), 75 73 is_host: host, 76 74 self_profile: profile, 77 75 join_code: join_code.to_string(), ··· 80 78 join_code: join_code.to_string(), 81 79 profiles: HashMap::with_capacity(5), 82 80 self_seeker: false, 81 + self_id: None, 82 + is_host: host, 83 83 settings, 84 84 }), 85 85 } ··· 108 108 pub async fn switch_teams(&self, seeker: bool) { 109 109 let mut state = self.state.lock().await; 110 110 state.self_seeker = seeker; 111 + if let Some(id) = state.self_id { 112 + if let Some(state_seeker) = state.teams.get_mut(&id) { 113 + *state_seeker = seeker; 114 + } 115 + } 111 116 drop(state); 112 117 self.transport 113 118 .send_transport_message(None, LobbyMessage::PlayerSwitch(seeker).into()) ··· 131 136 self.transport.send_transport_message(id, msg.into()).await 132 137 } 133 138 134 - async fn singaling_mark_started(&self) -> Result { 135 - let url = format!("{}/mark_started/{}", server_url(), &self.join_code); 136 - let client = reqwest::Client::builder().build()?; 137 - client.post(url).send().await?.error_for_status()?; 138 - Ok(()) 139 + async fn signaling_mark_started(&self) -> Result { 140 + server::mark_room_started(&self.join_code).await 139 141 } 140 142 141 143 /// (Host) Start the game 142 144 pub async fn start_game(&self) { 143 145 if self.is_host { 144 - if let Some(my_id) = self.transport.get_my_id().await { 145 - let mut state = self.state.lock().await; 146 - let seeker = state.self_seeker; 147 - state.teams.insert(my_id, seeker); 148 - let start_game_info = StartGameInfo { 149 - settings: state.settings.clone(), 150 - initial_caught_state: state.teams.clone(), 151 - }; 152 - drop(state); 153 - let msg = LobbyMessage::StartGame(start_game_info); 154 - self.send_transport_message(None, msg).await; 155 - if let Err(why) = self.singaling_mark_started().await { 156 - warn!("Failed to tell signalling server that the match started: {why:?}"); 157 - } 158 - self.emit_state_update(); 146 + let state = self.state.lock().await; 147 + let start_game_info = StartGameInfo { 148 + settings: state.settings.clone(), 149 + initial_caught_state: state.teams.clone(), 150 + }; 151 + drop(state); 152 + let msg = LobbyMessage::StartGame(start_game_info); 153 + self.send_transport_message(None, msg).await; 154 + if let Err(why) = self.signaling_mark_started().await { 155 + warn!("Failed to tell signalling server that the match started: {why:?}"); 159 156 } 157 + self.emit_state_update(); 160 158 } 161 159 } 162 160 ··· 175 173 176 174 for (peer, msg) in msgs { 177 175 match msg { 176 + TransportMessage::IdAssigned(id) => { 177 + let mut state = self.state.lock().await; 178 + state.self_id = Some(id); 179 + let seeker = state.self_seeker; 180 + state.teams.insert(id, seeker); 181 + state.profiles.insert(id, self.self_profile.clone()); 182 + } 178 183 TransportMessage::Disconnected => { 179 184 break 'lobby Err(anyhow!( 180 185 "Transport disconnected before lobby could start game" ··· 193 198 state.settings = game_settings; 194 199 } 195 200 LobbyMessage::StartGame(start_game_info) => { 196 - break 'lobby Ok(( 197 - self.transport 198 - .get_my_id() 199 - .await 200 - .expect("Error getting self ID"), 201 - start_game_info, 202 - )); 201 + let id = self 202 + .state 203 + .lock() 204 + .await 205 + .self_id 206 + .expect("Error getting self ID"); 207 + break 'lobby Ok((id, start_game_info)); 203 208 } 204 209 LobbyMessage::PlayerSwitch(seeker) => { 205 210 let mut state = self.state.lock().await;
+79
backend/src/server.rs
··· 1 + use reqwest::StatusCode; 2 + 3 + use crate::prelude::*; 4 + 5 + const fn server_host() -> &'static str { 6 + if let Some(host) = option_env!("SIGNAL_SERVER_HOST") { 7 + host 8 + } else { 9 + "localhost" 10 + } 11 + } 12 + 13 + const fn server_port() -> u16 { 14 + if let Some(port) = option_env!("SIGNAL_SERVER_PORT") { 15 + const_str::parse!(port, u16) 16 + } else { 17 + 3536 18 + } 19 + } 20 + 21 + const fn server_secure() -> bool { 22 + if let Some(secure) = option_env!("SIGNAL_SERVER_SECURE") { 23 + const_str::eq_ignore_ascii_case!(secure, "true") || const_str::equal!(secure, "1") 24 + } else { 25 + false 26 + } 27 + } 28 + 29 + const fn server_ws_proto() -> &'static str { 30 + if server_secure() { 31 + "wss" 32 + } else { 33 + "ws" 34 + } 35 + } 36 + 37 + const fn server_http_proto() -> &'static str { 38 + if server_secure() { 39 + "https" 40 + } else { 41 + "http" 42 + } 43 + } 44 + 45 + const SERVER_HOST: &str = server_host(); 46 + const SERVER_PORT: u16 = server_port(); 47 + const SERVER_WS_PROTO: &str = server_ws_proto(); 48 + const SERVER_HTTP_PROTO: &str = server_http_proto(); 49 + 50 + const SERVER_SOCKET: &str = const_str::concat!(SERVER_HOST, ":", SERVER_PORT); 51 + 52 + const SERVER_WEBSOCKET_URL: &str = const_str::concat!(SERVER_WS_PROTO, "://", SERVER_SOCKET); 53 + const SERVER_HTTP_URL: &str = const_str::concat!(SERVER_HTTP_PROTO, "://", SERVER_SOCKET); 54 + 55 + pub fn room_url(code: &str, host: bool) -> String { 56 + let query_param = if host { "?create" } else { "" }; 57 + format!("{SERVER_WEBSOCKET_URL}/{code}{query_param}") 58 + } 59 + 60 + pub async fn room_exists(code: &str) -> Result<bool> { 61 + let url = format!("{SERVER_HTTP_URL}/room_exists/{code}"); 62 + reqwest::get(url) 63 + .await 64 + .map(|resp| resp.status() == StatusCode::OK) 65 + .context("Failed to make request") 66 + } 67 + 68 + pub async fn mark_room_started(code: &str) -> Result { 69 + let url = format!("{SERVER_HTTP_URL}/mark_started/{code}"); 70 + let client = reqwest::Client::builder().build()?; 71 + client 72 + .post(url) 73 + .send() 74 + .await 75 + .context("Could not send request")? 76 + .error_for_status() 77 + .context("Server returned error")?; 78 + Ok(()) 79 + }
+17 -9
backend/src/transport.rs
··· 16 16 game::{GameEvent, Transport}, 17 17 lobby::LobbyMessage, 18 18 prelude::*, 19 + server, 19 20 }; 20 21 21 22 #[derive(Serialize, Deserialize, Debug, Clone)] ··· 28 29 29 30 #[derive(Debug, Serialize, Deserialize, Clone)] 30 31 pub enum TransportMessage { 32 + /// The transport has received a peer id 33 + IdAssigned(Uuid), 31 34 /// Message related to the actual game 32 35 /// Boxed for space reasons 33 36 Game(Box<GameEvent>), ··· 124 127 } 125 128 126 129 impl MatchboxTransport { 127 - pub fn new(ws_url: &str) -> Self { 130 + pub fn new(join_code: &str, is_host: bool) -> Self { 128 131 let (itx, irx) = tokio::sync::mpsc::channel(15); 129 132 let (otx, orx) = tokio::sync::mpsc::channel(15); 130 133 131 134 Self { 132 - ws_url: ws_url.to_string(), 135 + ws_url: server::room_url(join_code, is_host), 133 136 incoming: (itx, Mutex::new(irx)), 134 137 outgoing: (otx, Mutex::new(orx)), 135 138 my_id: RwLock::new(None), ··· 152 155 buffer 153 156 } 154 157 155 - pub async fn get_my_id(&self) -> Option<Uuid> { 156 - *self.my_id.read().await 157 - } 158 - 159 158 async fn push_incoming(&self, id: Uuid, msg: TransportMessage) { 160 159 self.incoming 161 160 .0 ··· 168 167 &self, 169 168 socket: &mut WebRtcSocket, 170 169 all_peers: &HashSet<PeerId>, 171 - messages: impl Iterator<Item = OutgoingMsgPair>, 170 + messages: &mut Vec<OutgoingMsgPair>, 172 171 ) { 173 - let packets = messages.flat_map(|(id, msg)| { 172 + if let Some(my_id) = *self.my_id.read().await { 173 + for (_, msg) in messages.iter().filter(|(id, _)| id.is_none()) { 174 + self.push_incoming(my_id, msg.clone()).await; 175 + } 176 + } 177 + 178 + let packets = messages.drain(..).flat_map(|(id, msg)| { 174 179 msg.to_packets() 175 180 .into_iter() 176 181 .map(move |packet| (id, packet.into_boxed_slice())) ··· 293 298 if let Some(new_id) = socket.id() { 294 299 my_id = Some(new_id.0); 295 300 *self.my_id.write().await = Some(new_id.0); 301 + self.push_incoming(new_id.0, TransportMessage::IdAssigned(new_id.0)) 302 + .await; 296 303 } 297 304 } 298 305 ··· 311 318 } 312 319 313 320 _ = outgoing_rx.recv_many(&mut buffer, 30) => { 314 - self.handle_send(&mut socket, &all_peers, buffer.drain(..)).await; 321 + 322 + self.handle_send(&mut socket, &all_peers, &mut buffer).await; 315 323 } 316 324 317 325 _ = &mut loop_fut => {
+1
frontend/index.html
··· 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 6 <title>Man Hunt</title> 7 + <script type="module" src="/src/main.tsx"></script> 7 8 </head> 8 9 9 10 <body>
+51 -1
frontend/package-lock.json
··· 14 14 "@tauri-apps/plugin-notification": "^2", 15 15 "@tauri-apps/plugin-opener": "^2", 16 16 "react": "^19", 17 - "react-dom": "^19" 17 + "react-dom": "^19", 18 + "swr": "^2.3.3" 18 19 }, 19 20 "devDependencies": { 20 21 "@eslint/js": "^9", 22 + "@types/node": "^24.0.3", 21 23 "@types/react": "^19", 22 24 "@types/react-dom": "^19", 23 25 "@vitejs/plugin-react": "^4", ··· 1468 1470 "dev": true, 1469 1471 "license": "MIT" 1470 1472 }, 1473 + "node_modules/@types/node": { 1474 + "version": "24.0.3", 1475 + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz", 1476 + "integrity": "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==", 1477 + "dev": true, 1478 + "license": "MIT", 1479 + "dependencies": { 1480 + "undici-types": "~7.8.0" 1481 + } 1482 + }, 1471 1483 "node_modules/@types/react": { 1472 1484 "version": "19.1.8", 1473 1485 "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", ··· 2337 2349 }, 2338 2350 "funding": { 2339 2351 "url": "https://github.com/sponsors/ljharb" 2352 + } 2353 + }, 2354 + "node_modules/dequal": { 2355 + "version": "2.0.3", 2356 + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", 2357 + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", 2358 + "license": "MIT", 2359 + "engines": { 2360 + "node": ">=6" 2340 2361 } 2341 2362 }, 2342 2363 "node_modules/doctrine": { ··· 4868 4889 "url": "https://github.com/sponsors/ljharb" 4869 4890 } 4870 4891 }, 4892 + "node_modules/swr": { 4893 + "version": "2.3.3", 4894 + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.3.tgz", 4895 + "integrity": "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==", 4896 + "license": "MIT", 4897 + "dependencies": { 4898 + "dequal": "^2.0.3", 4899 + "use-sync-external-store": "^1.4.0" 4900 + }, 4901 + "peerDependencies": { 4902 + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 4903 + } 4904 + }, 4871 4905 "node_modules/tinyglobby": { 4872 4906 "version": "0.2.14", 4873 4907 "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", ··· 5086 5120 "url": "https://github.com/sponsors/ljharb" 5087 5121 } 5088 5122 }, 5123 + "node_modules/undici-types": { 5124 + "version": "7.8.0", 5125 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", 5126 + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", 5127 + "dev": true, 5128 + "license": "MIT" 5129 + }, 5089 5130 "node_modules/update-browserslist-db": { 5090 5131 "version": "1.1.3", 5091 5132 "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", ··· 5125 5166 "license": "BSD-2-Clause", 5126 5167 "dependencies": { 5127 5168 "punycode": "^2.1.0" 5169 + } 5170 + }, 5171 + "node_modules/use-sync-external-store": { 5172 + "version": "1.5.0", 5173 + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", 5174 + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", 5175 + "license": "MIT", 5176 + "peerDependencies": { 5177 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 5128 5178 } 5129 5179 }, 5130 5180 "node_modules/vite": {
+4 -2
frontend/package.json
··· 12 12 "dependencies": { 13 13 "@tauri-apps/api": "^2", 14 14 "@tauri-apps/plugin-geolocation": "^2", 15 - "@tauri-apps/plugin-opener": "^2", 16 15 "@tauri-apps/plugin-log": "^2", 17 16 "@tauri-apps/plugin-notification": "^2", 17 + "@tauri-apps/plugin-opener": "^2", 18 18 "react": "^19", 19 - "react-dom": "^19" 19 + "react-dom": "^19", 20 + "swr": "^2.3.3" 20 21 }, 21 22 "devDependencies": { 22 23 "@eslint/js": "^9", 24 + "@types/node": "^24.0.3", 23 25 "@types/react": "^19", 24 26 "@types/react-dom": "^19", 25 27 "@vitejs/plugin-react": "^4",
+6
frontend/src/bindings.ts
··· 378 378 */ 379 379 game_started: string; 380 380 /** 381 + * When the game ended, when this is Option::Some, the game has ended 382 + */ 383 + game_ended: string | null; 384 + /** 381 385 * The last time all hiders were pinged **in UTC** 382 386 */ 383 387 last_global_ping: string | null; ··· 397 401 * True represents seeker, false hider 398 402 */ 399 403 teams: Partial<{ [key in string]: boolean }>; 404 + self_id: string | null; 400 405 self_seeker: boolean; 406 + is_host: boolean; 401 407 settings: GameSettings; 402 408 }; 403 409 /**
+38
frontend/src/components/App.tsx
··· 1 + import React from "react"; 2 + import { AppScreen, commands } from "@/bindings"; 3 + import { useTauriEvent } from "@/lib/hooks"; 4 + import { unwrapResult } from "@/lib/result"; 5 + import SetupScreen from "./SetupScreen"; 6 + import MenuScreen from "./MenuScreen"; 7 + import LobbyScreen from "./LobbyScreen"; 8 + import GameScreen from "./GameScreen"; 9 + 10 + function ScreenRouter({ screen }: { screen: AppScreen }) { 11 + switch (screen) { 12 + case "Setup": 13 + return <SetupScreen />; 14 + case "Menu": 15 + return <MenuScreen />; 16 + case "Lobby": 17 + return <LobbyScreen />; 18 + case "Game": 19 + return <GameScreen />; 20 + default: 21 + return <p>???</p>; 22 + } 23 + } 24 + 25 + const startingScreen = unwrapResult(await commands.getCurrentScreen()); 26 + 27 + export default function App() { 28 + const [screen, setScreen] = React.useState<AppScreen>(startingScreen); 29 + 30 + useTauriEvent("changeScreen", setScreen); 31 + 32 + return ( 33 + <> 34 + <h1>Screen: {screen}</h1> 35 + <ScreenRouter screen={screen} /> 36 + </> 37 + ); 38 + }
+92
frontend/src/components/GameScreen.tsx
··· 1 + import React from "react"; 2 + import { commands } from "@/bindings"; 3 + import { useTauriEvent } from "@/lib/hooks"; 4 + import useSWR from "swr"; 5 + import { unwrapResult } from "@/lib/result"; 6 + 7 + export default function GameScreen() { 8 + const profiles = unwrapResult(React.use(commands.getProfiles())); 9 + const { data: gameState, mutate } = useSWR( 10 + "fetch-game-state", 11 + async () => { 12 + return unwrapResult(await commands.getGameState()); 13 + }, 14 + { 15 + suspense: true, 16 + dedupingInterval: 100 17 + } 18 + ); 19 + 20 + useTauriEvent("gameStateUpdate", () => { 21 + mutate(); 22 + }); 23 + 24 + const isSeeker = gameState.caught_state[gameState.my_id]; 25 + 26 + const markCaught = async () => { 27 + if (!isSeeker) { 28 + unwrapResult(await commands.markCaught()); 29 + } 30 + }; 31 + 32 + const grabPowerup = async () => { 33 + if (gameState.available_powerup !== null) { 34 + unwrapResult(await commands.grabPowerup()); 35 + } 36 + }; 37 + 38 + const usePowerup = async () => { 39 + if (gameState.held_powerup !== null && gameState.held_powerup !== "PingSeeker") { 40 + unwrapResult(await commands.usePowerup()); 41 + } 42 + }; 43 + 44 + if (gameState.game_ended) { 45 + return <h2>Game Over! Syncing histories...</h2>; 46 + } else if (isSeeker && gameState.seekers_started === null) { 47 + return <h2>Waiting for hiders to hide...</h2>; 48 + } else { 49 + return ( 50 + <> 51 + <h2>Hiders Left</h2> 52 + {Object.keys(gameState.caught_state) 53 + .filter((k) => !gameState.caught_state[k]) 54 + .map((key) => ( 55 + <li key={key}>{profiles[key]?.display_name ?? key}</li> 56 + ))} 57 + {!isSeeker && <button onClick={markCaught}>I got caught!</button>} 58 + <h2>Pings</h2> 59 + {gameState.last_global_ping !== null ? ( 60 + <> 61 + <p>Last Ping: {gameState.last_global_ping}</p> 62 + {Object.entries(gameState.pings) 63 + .filter(([key, v]) => key && v !== undefined) 64 + .map(([k, v]) => ( 65 + <li key={k}> 66 + {profiles[v!.display_player]?.display_name ?? v!.display_player} 67 + : {v && JSON.stringify(v.loc)} 68 + </li> 69 + ))} 70 + </> 71 + ) : ( 72 + <small>Pings haven&apos;t started yet</small> 73 + )} 74 + <h2>Powerups</h2> 75 + {gameState.available_powerup && ( 76 + <p> 77 + Powerup Available: {JSON.stringify(gameState.available_powerup)}{" "} 78 + <button onClick={grabPowerup}>Grab!</button> 79 + </p> 80 + )} 81 + {gameState.held_powerup && ( 82 + <p> 83 + Held Powerup: {gameState.held_powerup} 84 + {(gameState.held_powerup === "PingSeeker" && ( 85 + <small>(Will be used next ping)</small> 86 + )) || <button onClick={usePowerup}>Use</button>} 87 + </p> 88 + )} 89 + </> 90 + ); 91 + } 92 + }
+65
frontend/src/components/LobbyScreen.tsx
··· 1 + import React from "react"; 2 + import { commands } from "@/bindings"; 3 + import { useTauriEvent } from "@/lib/hooks"; 4 + import useSWR from "swr"; 5 + import { unwrapResult } from "@/lib/result"; 6 + 7 + export default function LobbyScreen() { 8 + const { data: lobbyState, mutate } = useSWR( 9 + "fetch-lobby-state", 10 + async () => { 11 + return unwrapResult(await commands.getLobbyState()); 12 + }, 13 + { 14 + suspense: true, 15 + dedupingInterval: 100 16 + } 17 + ); 18 + 19 + useTauriEvent("lobbyStateUpdate", () => { 20 + mutate(); 21 + }); 22 + 23 + const setSeeker = async (seeker: boolean) => { 24 + unwrapResult(await commands.switchTeams(seeker)); 25 + }; 26 + 27 + const startGame = async () => { 28 + unwrapResult(await commands.hostStartGame()); 29 + }; 30 + 31 + const quit = async () => { 32 + unwrapResult(await commands.quitToMenu()); 33 + }; 34 + 35 + return ( 36 + <> 37 + <h2>Join Code: {lobbyState.join_code}</h2> 38 + 39 + {lobbyState.is_host && <button onClick={startGame}>Start Game</button>} 40 + 41 + <button onClick={() => setSeeker(true)}>Become Seeker</button> 42 + <button onClick={() => setSeeker(false)}>Become Hider</button> 43 + 44 + <h3>Seekers</h3> 45 + <ul> 46 + {Object.keys(lobbyState.teams) 47 + .filter((k) => lobbyState.teams[k]) 48 + .map((key) => ( 49 + <li key={key}>{lobbyState.profiles[key]?.display_name ?? key}</li> 50 + ))} 51 + </ul> 52 + <h3>Hiders</h3> 53 + <ul> 54 + {Object.keys(lobbyState.teams) 55 + .filter((k) => !lobbyState.teams[k]) 56 + .map((key) => ( 57 + <li key={key}>{lobbyState.profiles[key]?.display_name ?? key}</li> 58 + ))} 59 + </ul> 60 + <button onClick={quit}>Quit to Menu</button> 61 + 62 + <code>{JSON.stringify(lobbyState)}</code> 63 + </> 64 + ); 65 + }
+102
frontend/src/components/MenuScreen.tsx
··· 1 + import { commands, GameSettings } from "@/bindings"; 2 + import { unwrapResult } from "@/lib/result"; 3 + import React from "react"; 4 + 5 + // Temp settings for now. 6 + const settings: GameSettings = { 7 + random_seed: 21341234, 8 + hiding_time_seconds: 10, 9 + ping_start: "Instant", 10 + ping_minutes_interval: 1, 11 + powerup_start: "Instant", 12 + powerup_chance: 60, 13 + powerup_minutes_cooldown: 1, 14 + powerup_locations: [ 15 + { 16 + lat: 0, 17 + long: 0, 18 + heading: null 19 + } 20 + ] 21 + }; 22 + 23 + function MainMenu({ 24 + profilePromise, 25 + historyPromise 26 + }: { 27 + profilePromise: ReturnType<typeof commands.getProfile>; 28 + historyPromise: ReturnType<typeof commands.listGameHistories>; 29 + }) { 30 + const initialProfile = unwrapResult(React.use(profilePromise)); 31 + const gameHistory = unwrapResult(React.use(historyPromise)); 32 + const [profile, setProfile] = React.useState(initialProfile); 33 + const [newName, setName] = React.useState(initialProfile.display_name); 34 + const [roomCode, setRoomCode] = React.useState(""); 35 + 36 + const onStartGame = async (code: string | null) => { 37 + if (code) { 38 + try { 39 + const validCode = unwrapResult(await commands.checkRoomCode(code)); 40 + if (!validCode) { 41 + window.alert("Invalid Join Code"); 42 + return; 43 + } 44 + } catch (e) { 45 + window.alert(`Failed to connect to Server ${e}`); 46 + return; 47 + } 48 + } 49 + await commands.startLobby(code, settings); 50 + }; 51 + 52 + const onSaveProfile = async () => { 53 + unwrapResult(await commands.updateProfile({ ...profile, display_name: newName })); 54 + setProfile((p) => { 55 + return { ...p, display_name: newName }; 56 + }); 57 + }; 58 + 59 + return ( 60 + <> 61 + {profile.pfp_base64 && ( 62 + <img src={profile.pfp_base64} alt={`${profile.display_name}'s Profile Picture`} /> 63 + )} 64 + <h2>Welcome, {profile.display_name}</h2> 65 + <hr /> 66 + <h3>Play</h3> 67 + <button onClick={() => onStartGame(null)}>Start Lobby</button> 68 + <div> 69 + <input 70 + value={roomCode} 71 + placeholder="Room Code" 72 + onChange={(e) => setRoomCode(e.target.value)} 73 + /> 74 + <button onClick={() => onStartGame(roomCode)} disabled={roomCode === ""}> 75 + Join Lobby 76 + </button> 77 + </div> 78 + <hr /> 79 + <h3>Edit Profile</h3> 80 + <input value={newName} onChange={(e) => setName(e.target.value)} /> 81 + <button onClick={onSaveProfile}>Save</button> 82 + <hr /> 83 + <h3>Previous Games</h3> 84 + <ul> 85 + {gameHistory.map((time) => ( 86 + <li key={time}>{time}</li> 87 + ))} 88 + </ul> 89 + </> 90 + ); 91 + } 92 + 93 + export default function MenuScreen() { 94 + const profilePromise = commands.getProfile(); 95 + const previousGamesPromise = commands.listGameHistories(); 96 + 97 + return ( 98 + <React.Suspense fallback={<p>Loading profile</p>}> 99 + <MainMenu profilePromise={profilePromise} historyPromise={previousGamesPromise} /> 100 + </React.Suspense> 101 + ); 102 + }
+26
frontend/src/components/SetupScreen.tsx
··· 1 + import React from "react"; 2 + import { commands, PlayerProfile } from "@/bindings"; 3 + import { unwrapResult } from "@/lib/result"; 4 + 5 + export default function SetupScreen() { 6 + const [displayName, setName] = React.useState("User"); 7 + 8 + const onSave = async () => { 9 + const profile = { display_name: displayName, pfp_base64: null } as PlayerProfile; 10 + unwrapResult(await commands.completeSetup(profile)); 11 + }; 12 + 13 + return ( 14 + <> 15 + <input 16 + name="displayName" 17 + value={displayName} 18 + placeholder="Display Name" 19 + onChange={(e) => setName(e.target.value)} 20 + /> 21 + <button disabled={displayName === ""} onClick={onSave}> 22 + Save 23 + </button> 24 + </> 25 + ); 26 + }
+24
frontend/src/lib/hooks.ts
··· 1 + import { useEffect } from "react"; 2 + import { events } from "@/bindings"; 3 + 4 + type ExtractCallback<E extends keyof typeof events> = ( 5 + payload: Parameters<Parameters<(typeof events)[E]["listen"]>[0]>[0]["payload"] 6 + ) => void; 7 + 8 + /** 9 + * Convenience hook that does useEffect for a Tauri event and handles unsubscribing on unmount 10 + */ 11 + export const useTauriEvent = <E extends keyof typeof events>( 12 + tauriEvent: E, 13 + cb: ExtractCallback<E> 14 + ) => { 15 + useEffect(() => { 16 + const unlisten = events[tauriEvent].listen((e) => { 17 + cb(e.payload); 18 + }); 19 + 20 + return () => { 21 + unlisten.then((f) => f()); 22 + }; 23 + }, [tauriEvent, cb]); 24 + };
+10
frontend/src/lib/result.ts
··· 1 + import { Result } from "@/bindings.ts"; 2 + 3 + export const unwrapResult = <T, E>(res: Result<T, E>): T => { 4 + switch (res.status) { 5 + case "ok": 6 + return res.data; 7 + case "error": 8 + throw res.error; 9 + } 10 + };
+12
frontend/src/main.tsx
··· 1 + import React from "react"; 2 + import ReactDOM from "react-dom/client"; 3 + 4 + const App = React.lazy(() => import("@/components/App")); 5 + 6 + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 7 + <React.StrictMode> 8 + <React.Suspense> 9 + <App /> 10 + </React.Suspense> 11 + </React.StrictMode> 12 + );
+10 -4
frontend/tsconfig.json
··· 5 5 "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 6 "module": "ESNext", 7 7 "skipLibCheck": true, 8 - 9 8 /* Bundler mode */ 10 9 "moduleResolution": "bundler", 11 10 "allowImportingTsExtensions": true, ··· 13 12 "isolatedModules": true, 14 13 "noEmit": true, 15 14 "jsx": "react-jsx", 16 - 17 15 /* Linting */ 18 16 "strict": true, 19 17 "noUnusedLocals": false, 20 18 "noUnusedParameters": false, 21 - "noFallthroughCasesInSwitch": true 19 + "noFallthroughCasesInSwitch": true, 20 + "baseUrl": ".", 21 + "paths": { 22 + "@/*": ["./src/*"] 23 + } 22 24 }, 23 25 "include": ["src"], 24 - "references": [{ "path": "./tsconfig.node.json" }] 26 + "references": [ 27 + { 28 + "path": "./tsconfig.node.json" 29 + } 30 + ] 25 31 }
+6 -2
frontend/vite.config.ts
··· 1 + /// <reference types="vite/client" /> 2 + 1 3 import { defineConfig } from "vite"; 2 4 import react from "@vitejs/plugin-react"; 5 + import path from "path"; 3 6 4 - // @ts-expect-error process is a nodejs global 5 7 const host = process.env.TAURI_DEV_HOST; 6 8 7 - // https://vite.dev/config/ 8 9 export default defineConfig(async () => ({ 9 10 plugins: [react()], 10 11 clearScreen: false, ··· 19 20 port: 1421 20 21 } 21 22 : undefined 23 + }, 24 + resolve: { 25 + alias: [{ find: "@", replacement: path.resolve(__dirname, "./src") }] 22 26 } 23 27 }));
+4
justfile
··· 10 10 dev: 11 11 cargo tauri dev 12 12 13 + # Start a webview window *without* running the frontend, only one frontend needs to run at once 14 + dev-window: 15 + cargo run -p manhunt-app 16 + 13 17 # Format everything 14 18 fmt: 15 19 cargo fmt