Live location tracking and playback for the game "manhunt"

Lobby screen mockup

bwc9876.dev b218c8d3 63fb8643

verified
+121 -69
+6 -6
Cargo.lock
··· 706 706 707 707 [[package]] 708 708 name = "bumpalo" 709 - version = "3.19.1" 709 + version = "3.20.1" 710 710 source = "registry+https://github.com/rust-lang/crates.io-index" 711 - checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" 711 + checksum = "5c6f81257d10a0f602a294ae4182251151ff97dbb504ef9afcdda4a64b24d9b4" 712 712 713 713 [[package]] 714 714 name = "byte-unit" ··· 4407 4407 "quinn-udp", 4408 4408 "rustc-hash", 4409 4409 "rustls 0.23.36", 4410 - "socket2 0.6.2", 4410 + "socket2 0.5.10", 4411 4411 "thiserror 2.0.18", 4412 4412 "tokio", 4413 4413 "tracing", ··· 4445 4445 "cfg_aliases", 4446 4446 "libc", 4447 4447 "once_cell", 4448 - "socket2 0.6.2", 4448 + "socket2 0.5.10", 4449 4449 "tracing", 4450 - "windows-sys 0.60.2", 4450 + "windows-sys 0.52.0", 4451 4451 ] 4452 4452 4453 4453 [[package]] ··· 5054 5054 "security-framework", 5055 5055 "security-framework-sys", 5056 5056 "webpki-root-certs", 5057 - "windows-sys 0.61.2", 5057 + "windows-sys 0.52.0", 5058 5058 ] 5059 5059 5060 5060 [[package]]
+59 -47
frontend/src/components/LobbyScreen.tsx
··· 1 - import React from "react"; 2 - import { commands } from "@/bindings"; 3 - import { sharedSwrConfig, useTauriEvent } from "@/lib/hooks"; 4 - import useSWR from "swr"; 1 + import React, { useEffect, useState } from "react"; 2 + import { commands, LobbyState, PlayerProfile } from "@/bindings"; 3 + import { useTauriEvent } from "@/lib/hooks"; 4 + import ProfilePicture, { ProfileDecor } from "./ProfilePicture"; 5 + import { tempSettings } from "./MenuScreen"; 6 + import { IconCircleCheckFilled, IconCircleDashedPlus } from "@tabler/icons-react"; 7 + 8 + function ProfileList({ profiles, decoration }: { profiles: [string, PlayerProfile][], decoration: ProfileDecor }) { 9 + return <div className="lobby-pfps"> 10 + {profiles.map(([k, p]) => <ProfilePicture key={k} decoration={decoration} src={p.pfp_base64} fallbackName={p.display_name} />)} 11 + </div>; 12 + } 13 + 14 + function TeamButton({ onClick, active, deco, text }: { onClick: () => void, active: boolean, deco: ProfileDecor, text: string }) { 15 + return <button onClick={onClick} className={`team-button ${deco}`}> 16 + {active ? "You're On" : "Join"} {text} 17 + {active ? <IconCircleCheckFilled /> : <IconCircleDashedPlus />} 18 + </button>; 19 + } 20 + 21 + const initLobbyState: LobbyState = { 22 + profiles: {}, 23 + join_code: "", 24 + teams: {}, 25 + self_id: "", 26 + is_host: false, 27 + settings: tempSettings, 28 + }; 5 29 6 30 export default function LobbyScreen() { 7 - const { data: lobbyState, mutate } = useSWR( 8 - "fetch-lobby-state", 9 - commands.getLobbyState, 10 - sharedSwrConfig 11 - ); 31 + const [lobbyState, setLobbyState] = useState(initLobbyState); 32 + 33 + useEffect(() => { 34 + let cancel = false; 35 + commands.getLobbyState().then((state) => { 36 + if (!cancel) { 37 + setLobbyState(state); 38 + } 39 + }); 40 + return () => { 41 + cancel = true; 42 + }; 43 + }, [setLobbyState]); 12 44 13 45 useTauriEvent("lobbyStateUpdate", () => { 14 - mutate(); 46 + commands.getLobbyState().then((state) => { 47 + setLobbyState(state); 48 + }); 15 49 }); 16 50 17 - const setSeeker = async (seeker: boolean) => { 18 - await commands.switchTeams(seeker); 19 - }; 51 + const profiles = Object.entries(lobbyState.profiles).filter(([_, p]) => p !== undefined) as [string, PlayerProfile][]; 20 52 21 - const startGame = async () => { 22 - await commands.hostStartGame(); 23 - }; 53 + const seekers = profiles.filter(([id, _]) => lobbyState.teams[id] ?? false); 54 + const hiders = profiles.filter(([id, _]) => !(lobbyState.teams[id] ?? false)); 24 55 25 - const quit = async () => { 26 - await commands.quitToMenu(); 27 - }; 28 - 29 - if (lobbyState.self_id === null) { 30 - return <h2>Connecting to Lobby...</h2>; 31 - } 56 + const isSeeker = lobbyState.teams[lobbyState.self_id] ?? false; 32 57 33 58 return ( 34 59 <> 35 - <h2>Join Code: {lobbyState.join_code}</h2> 36 - 37 - {lobbyState.is_host && <button onClick={startGame}>Start Game</button>} 38 - 39 - <button onClick={() => setSeeker(true)}>Become Seeker</button> 40 - <button onClick={() => setSeeker(false)}>Become Hider</button> 41 - 42 - <h3>Seekers</h3> 43 - <ul> 44 - {Object.keys(lobbyState.teams) 45 - .filter((k) => lobbyState.teams[k]) 46 - .map((key) => ( 47 - <li key={key}>{lobbyState.profiles[key]?.display_name ?? key}</li> 48 - ))} 49 - </ul> 50 - <h3>Hiders</h3> 51 - <ul> 52 - {Object.keys(lobbyState.teams) 53 - .filter((k) => !lobbyState.teams[k]) 54 - .map((key) => ( 55 - <li key={key}>{lobbyState.profiles[key]?.display_name ?? key}</li> 56 - ))} 57 - </ul> 58 - <button onClick={quit}>Quit to Menu</button> 60 + <header> 61 + <span className="grow">Lobby</span> 62 + <span>Join: {lobbyState.join_code}</span> 63 + </header> 64 + <main> 65 + <ProfileList profiles={seekers} decoration="seeker" /> 66 + <TeamButton onClick={() => commands.switchTeams(true)} active={isSeeker} text="Seekers" deco="seeker" /> 67 + <div className="map" /> 68 + <TeamButton onClick={() => commands.switchTeams(false)} active={!isSeeker} text="Hiders" deco="hider" /> 69 + <ProfileList profiles={hiders} decoration="hider" /> 70 + </main> 59 71 </> 60 72 ); 61 73 }
+5 -5
frontend/src/components/MenuScreen.tsx
··· 5 5 import ProfilePicture from "./ProfilePicture"; 6 6 7 7 // Temp settings for now. 8 - const settings: GameSettings = { 8 + export const tempSettings: GameSettings = { 9 9 random_seed: 21341234, 10 10 hiding_time_seconds: 10, 11 11 ping_start: "Instant", ··· 43 43 }, [setProfile]); 44 44 45 45 const startLobby = useCallback(() => { 46 - commands.startLobby(null, settings); 46 + commands.startLobby(null, tempSettings); 47 47 }, []); 48 48 49 49 const joinLobby = useCallback(() => { ··· 51 51 if (!code) { 52 52 return; 53 53 } 54 - const cleanedCode = code.toLowerCase().trim(); 54 + const cleanedCode = code.toUpperCase().trim(); 55 55 commands.checkRoomCode(cleanedCode).then((valid) => { 56 56 if (valid) { 57 - commands.startLobby(cleanedCode, settings); 57 + commands.startLobby(cleanedCode, tempSettings); 58 58 } else { 59 59 window.alert("Invalid Join Code"); 60 60 } ··· 66 66 <ProfilePicture fallbackName={profile.display_name} src={profile.pfp_base64} /> 67 67 {profile.display_name} 68 68 </header> 69 - <main> 69 + <main className="menu"> 70 70 <button onClick={startLobby}> 71 71 <IconBuildingBroadcastTowerFilled size="5em" /> 72 72 Start Lobby
+3 -1
frontend/src/components/ProfilePicture.tsx
··· 1 1 import React from "react"; 2 2 import fallbackPicture from "@/default-pfp.png"; 3 3 4 + export type ProfileDecor = "hider" | "seeker"; 5 + 4 6 export type ProfilePictureProps = { 5 7 fallbackName: string; 6 8 src: string | null; 7 - decoration?: "hider" | "seeker"; 9 + decoration?: ProfileDecor; 8 10 }; 9 11 10 12 const hashName = (str: string, seed = 3) => {
+48 -10
frontend/src/style.css
··· 43 43 padding: var(--1); 44 44 gap: var(--1); 45 45 46 - img { 47 - width: 2em; 48 - height: 2em; 49 - background-color: grey; 50 - border-radius: 50%; 46 + .grow { 47 + flex-grow: 1; 51 48 } 52 49 } 53 50 ··· 56 53 flex-direction: column; 57 54 flex-grow: 1; 58 55 59 - button { 56 + .map { 57 + flex-grow: 1; 58 + background-color: #111; 59 + } 60 + 61 + .lobby-pfps { 62 + z-index: 2; 63 + overflow-x: auto; 64 + box-shadow: 0 0 10px #0004; 65 + display: flex; 66 + flex-direction: row; 67 + gap: var(--2); 68 + width: 100%; 69 + font-size: 20pt; 70 + min-height: calc(20pt + var(--2)); 71 + padding: var(--small); 72 + } 73 + 74 + .team-button { 75 + font-family: "Bungee"; 76 + display: flex; 77 + flex-direction: row; 78 + align-items: center; 79 + justify-content: center; 80 + font-size: 16pt; 81 + gap: var(--small); 82 + padding: var(--1) 0; 83 + border: none; 84 + 85 + &.hider { 86 + background-color: #67c; 87 + } 88 + 89 + &.seeker { 90 + background-color: #c67; 91 + } 92 + } 93 + 94 + &.menu button { 60 95 font-family: "Bungee"; 61 96 display: flex; 62 97 flex-direction: row; ··· 85 120 background-color: #67c; 86 121 margin-top: calc(-1 * var(--8)); 87 122 padding-bottom: var(--5); 88 - transform: rotateZ(-2deg) translateX(-6px); 123 + transform: rotateZ(-2deg) translateX(-6px); 89 124 } 90 125 91 126 &:nth-child(3) { ··· 112 147 border-width: 4px; 113 148 border-color: #0000; 114 149 150 + img { 151 + width: 2em; 152 + height: 2em; 153 + border-radius: 50%; 154 + } 155 + 115 156 &[data-initial]::after { 116 157 content: attr(data-initial); 117 158 color: white; ··· 130 171 border-color: #67c; 131 172 } 132 173 } 133 - 134 - 135 -