Live location tracking and playback for the game "manhunt"

Game Setting Modal

bwc9876.dev 0c840d95 291b733a

verified
+413 -11
+26 -9
frontend/src/components/LobbyScreen.tsx
··· 1 1 import React, { useEffect, useState } from "react"; 2 - import { commands, LobbyState, PlayerProfile } from "@/bindings"; 2 + import { commands, GameSettings, LobbyState, PingStartCondition, PlayerProfile } from "@/bindings"; 3 3 import { useTauriEvent } from "@/lib/hooks"; 4 4 import ProfilePicture, { iconForDecor, ProfileDecor } from "./ProfilePicture"; 5 5 import { defaultSettings } from "./MenuScreen"; ··· 8 8 IconArrowBigLeftLinesFilled, 9 9 IconCircleCheckFilled, 10 10 IconCircleDashedPlus, 11 - IconFlagFilled 11 + IconFlagFilled, 12 + IconInfoCircleFilled, 13 + IconSettingsFilled 12 14 } from "@tabler/icons-react"; 13 15 import LoadingCover from "./LoadingCover"; 16 + import GameSettingsModal from "./game-settings/GameSettingsModal"; 14 17 15 18 function ProfileList({ 16 19 profiles, ··· 70 73 export default function LobbyScreen() { 71 74 const [lobbyState, setLobbyState] = useState(initLobbyState); 72 75 const [loadingCover, setLoadingCover] = useState(true); 76 + const [settingOpen, setSettingsOpen] = useState(false); 73 77 74 78 useEffect(() => { 75 79 let cancel = false; ··· 147 151 } 148 152 }; 149 153 154 + const onOpenSettings = () => { 155 + setSettingsOpen(true); 156 + }; 157 + 158 + const onSettingsSave = (settings: GameSettings) => { 159 + commands.hostUpdateSettings(settings); 160 + setSettingsOpen(false); 161 + }; 162 + 150 163 return ( 151 164 <> 152 165 <LoadingCover show={loadingCover} /> 166 + {settingOpen && ( 167 + <GameSettingsModal gameSettings={lobbyState.settings} onSave={onSettingsSave} /> 168 + )} 153 169 <header> 154 170 <span className="grow">Lobby</span> 155 171 <span>Join: {lobbyState.join_code}</span> ··· 163 179 deco="seeker" 164 180 /> 165 181 <div className="frame"> 166 - <button onClick={onLeaveLobby} aria-label="Leave Lobby" className="fab left"> 182 + <button onClick={onLeaveLobby} className="fab left"> 167 183 <IconArrowBigLeftLinesFilled size="1.5em" /> 168 184 Leave 169 185 </button> 170 186 {lobbyState.is_host && ( 171 - <button 172 - disabled={!canStart} 173 - onClick={onStartGame} 174 - aria-label="Start Game" 175 - className="fab right" 176 - > 187 + <button onClick={onOpenSettings} className="fab center"> 188 + <IconSettingsFilled size="1.5em" /> 189 + Rules 190 + </button> 191 + )} 192 + {lobbyState.is_host && ( 193 + <button disabled={!canStart} onClick={onStartGame} className="fab right"> 177 194 <IconFlagFilled size="1.5em" /> 178 195 Start 179 196 </button>
+1 -1
frontend/src/components/MenuScreen.tsx
··· 14 14 random_seed: Math.floor(Math.random() * 2 ** 32), 15 15 hiding_time_seconds: 60 * 5, 16 16 ping_start: "Instant", 17 - ping_minutes_interval: 1, 17 + ping_minutes_interval: 10, 18 18 powerup_start: "Instant", 19 19 powerup_chance: 60, 20 20 powerup_minutes_cooldown: 1,
+79
frontend/src/components/game-settings/GameSettingsModal.tsx
··· 1 + import { GameSettings } from "@/bindings"; 2 + import React, { useState } from "react"; 3 + import SettingsSection from "./SettingsSection"; 4 + import StartConditionField from "./StartConditionField"; 5 + import SliderField from "./SliderField"; 6 + import SettingsAdmo from "./SettingsAdmo"; 7 + import { iconForDecor } from "../ProfilePicture"; 8 + import { IconMapPinFilled, IconSettingsFilled } from "@tabler/icons-react"; 9 + 10 + export default function GameSettingsModal({ 11 + gameSettings, 12 + onSave 13 + }: { 14 + gameSettings: GameSettings; 15 + onSave: (settings: GameSettings) => void; 16 + }) { 17 + const [hidingTime, setHidingTime] = useState(gameSettings.hiding_time_seconds); 18 + const [pingStart, setPingStart] = useState(gameSettings.ping_start); 19 + const [pingInterval, setPingInterval] = useState(gameSettings.ping_minutes_interval); 20 + 21 + const onSaveClick = () => { 22 + const newSettings = {...gameSettings, hiding_time_seconds: hidingTime, ping_start: pingStart, ping_minutes_interval: pingInterval}; 23 + onSave(newSettings); 24 + }; 25 + 26 + const HiderIcon = iconForDecor("hider"); 27 + 28 + return ( 29 + <div className="screen-cover"> 30 + <dialog className="settings-popup"> 31 + <h2><IconSettingsFilled/> Game Rules</h2> 32 + <div> 33 + <SettingsSection icon={<HiderIcon/>} title="Hiding"> 34 + <SettingsAdmo> 35 + Hiders will have a period of time to hide. 36 + <br /> 37 + After this, seekers are allowed to begin searching. 38 + </SettingsAdmo> 39 + <SliderField 40 + label="Hiding Time" 41 + displayValue={`${hidingTime / 60} minutes`} 42 + value={hidingTime / 60} 43 + min={2} 44 + max={120} 45 + onChange={(e) => { 46 + setHidingTime(parseInt(e.target.value) * 60); 47 + }} 48 + /> 49 + </SettingsSection> 50 + <SettingsSection icon={<IconMapPinFilled/>} title="Pings"> 51 + <SettingsAdmo> 52 + Pings are when hider locations are shared with everyone. 53 + <br /> 54 + They start after seekers are released and continue at an interval. 55 + </SettingsAdmo> 56 + <StartConditionField 57 + label="Pings Will Start" 58 + value={pingStart} 59 + onChange={(val) => setPingStart(val)} 60 + /> 61 + <SliderField 62 + label="Ping Every" 63 + displayValue={`${pingInterval.toString()} minutes`} 64 + value={pingInterval} 65 + min={5} 66 + max={60} 67 + onChange={(e) => { 68 + setPingInterval(parseInt(e.target.value)); 69 + }} 70 + /> 71 + </SettingsSection> 72 + </div> 73 + <div> 74 + <button onClick={onSaveClick}>Save Settings</button> 75 + </div> 76 + </dialog> 77 + </div> 78 + ); 79 + }
+9
frontend/src/components/game-settings/SettingsAdmo.tsx
··· 1 + import { IconInfoCircleFilled } from "@tabler/icons-react"; 2 + import React, { PropsWithChildren } from "react"; 3 + 4 + export default function SettingsAdmo({children}: PropsWithChildren<{}>) { 5 + return <p> 6 + {children} 7 + </p>; 8 + } 9 +
+17
frontend/src/components/game-settings/SettingsField.tsx
··· 1 + import React, { PropsWithChildren } from "react"; 2 + 3 + export type SettingsFieldProps = PropsWithChildren<{ label: string; displayValue: string }>; 4 + 5 + export default function SettingsField({ 6 + label, 7 + displayValue, 8 + children 9 + }: SettingsFieldProps) { 10 + return ( 11 + <label> 12 + <span>{label}</span> 13 + <span>{displayValue}</span> 14 + {children} 15 + </label> 16 + ); 17 + }
+10
frontend/src/components/game-settings/SettingsSection.tsx
··· 1 + import React, { PropsWithChildren, ReactNode } from "react"; 2 + 3 + 4 + export default function SettingsSection({title, children, icon}: PropsWithChildren<{title: string, icon: ReactNode}>) { 5 + return <fieldset> 6 + <h3>{icon} {title}</h3> 7 + {children} 8 + </fieldset>; 9 + } 10 +
+13
frontend/src/components/game-settings/SliderField.tsx
··· 1 + import React, { HTMLProps } from "react"; 2 + import SettingsField, { SettingsFieldProps } from "./SettingsField"; 3 + 4 + type Props = Omit<Omit<HTMLProps<HTMLInputElement>,"type">, "label"> & 5 + Omit<SettingsFieldProps, "children">; 6 + 7 + export default function SliderField({ label, displayValue, ...rest }: Props) { 8 + return ( 9 + <SettingsField label={label} displayValue={displayValue}> 10 + <input type="range" {...rest} /> 11 + </SettingsField> 12 + ); 13 + }
+116
frontend/src/components/game-settings/StartConditionField.tsx
··· 1 + import { PingStartCondition } from "@/bindings"; 2 + import React from "react"; 3 + import SettingsField from "./SettingsField"; 4 + 5 + export type StartConditionInputProps = { 6 + value: PingStartCondition; 7 + onChange: (val: PingStartCondition) => void; 8 + label: string; 9 + }; 10 + 11 + const startConditionOptions = { 12 + instant: "Instantly", 13 + minutes: "After _ Minutes", 14 + players: "After _ Players Caught" 15 + }; 16 + 17 + const startConditionDefaults: Record<keyof typeof startConditionOptions, PingStartCondition> = { 18 + instant: "Instant", 19 + minutes: { Minutes: 5 }, 20 + players: { Players: 1 } 21 + }; 22 + 23 + const getStartConditionName = (cond: PingStartCondition) => { 24 + if (cond === "Instant") { 25 + return startConditionOptions.instant; 26 + } else if (cond.hasOwnProperty("Minutes")) { 27 + const mins = cond as { Minutes: number }; 28 + return startConditionOptions.minutes.replace("_", mins["Minutes"].toString()); 29 + } else if (cond.hasOwnProperty("Players")) { 30 + const plays = cond as { Players: number }; 31 + return startConditionOptions.players.replace("_", plays["Players"].toString()); 32 + } else { 33 + return "Instant"; 34 + } 35 + }; 36 + 37 + const getStartConditionKey = (cond: PingStartCondition): keyof typeof startConditionOptions => { 38 + if (cond === "Instant") { 39 + return "instant"; 40 + } else if (cond.hasOwnProperty("Minutes")) { 41 + return "minutes"; 42 + } else if (cond.hasOwnProperty("Players")) { 43 + return "players"; 44 + } else { 45 + return "instant"; 46 + } 47 + }; 48 + 49 + const getStartConditionValue = ( 50 + cond: PingStartCondition, 51 + key: keyof typeof startConditionOptions 52 + ) => { 53 + switch (key) { 54 + case "instant": 55 + return null; 56 + case "minutes": 57 + return (cond as { Minutes: number })["Minutes"]; 58 + case "players": 59 + return (cond as { Players: number })["Players"]; 60 + } 61 + }; 62 + 63 + const setStartConditionValue = ( 64 + key: keyof typeof startConditionOptions, 65 + val: number 66 + ): PingStartCondition => { 67 + switch (key) { 68 + case "instant": 69 + return "Instant"; 70 + case "minutes": 71 + return { Minutes: val }; 72 + case "players": 73 + return { Players: val }; 74 + } 75 + }; 76 + 77 + export default function StartConditionField({ value, onChange, label }: StartConditionInputProps) { 78 + const key = getStartConditionKey(value); 79 + const sliderVal = getStartConditionValue(value, key); 80 + 81 + return ( 82 + <> 83 + <SettingsField label={label} displayValue={getStartConditionName(value)}> 84 + <select 85 + onChange={(e) => { 86 + onChange( 87 + startConditionDefaults[ 88 + e.target.value as keyof typeof startConditionOptions 89 + ] 90 + ); 91 + }} 92 + value={key} 93 + > 94 + {Object.entries(startConditionOptions).map(([k, v]) => ( 95 + <option value={k} key={k}> 96 + {v} 97 + </option> 98 + ))} 99 + </select> 100 + </SettingsField> 101 + {(sliderVal !== null) && ( 102 + <input 103 + className="extra" 104 + min={1} 105 + max={key === "minutes" ? 120 : 20} 106 + type="range" 107 + value={sliderVal} 108 + onChange={(e) => { 109 + const newVal = setStartConditionValue(key, parseInt(e.target.value)); 110 + onChange(newVal); 111 + }} 112 + /> 113 + )} 114 + </> 115 + ); 116 + }
+142 -1
frontend/src/style.css
··· 39 39 font-size: 18pt; 40 40 font-weight: bold; 41 41 box-sizing: border-box; 42 - z-index: 10; 42 + z-index: 80; 43 43 44 44 display: flex; 45 45 flex-direction: row; ··· 198 198 } 199 199 } 200 200 201 + .settings-popup { 202 + position: relative; 203 + font-family: "sans-serif"; 204 + font-size: 18pt; 205 + background-color: white; 206 + display: flex; 207 + padding: var(--small) var(--1); 208 + flex-direction: column; 209 + width: 93vw; 210 + height: 95vh; 211 + border-radius: 10px; 212 + overflow: hidden; 213 + border: none; 214 + transition: 100ms ease-out; 215 + transition-property: transform, opacity; 216 + 217 + @starting-style { 218 + transform: translateY(10px); 219 + opacity: 0; 220 + } 221 + 222 + h2 { 223 + margin: 0; 224 + font-size: 22pt; 225 + font-family: "Bungee"; 226 + border-bottom: solid 2px #aaa; 227 + display: flex; 228 + gap: .2em; 229 + align-items: center; 230 + justify-content: center; 231 + } 232 + 233 + h3 { 234 + font-size: 20pt; 235 + font-family: "Bungee"; 236 + margin: 0; 237 + display: flex; 238 + gap: .2em; 239 + align-items: center; 240 + } 241 + 242 + fieldset { 243 + padding: var(--small) 0; 244 + border: none; 245 + border-bottom: solid 2px #ddd; 246 + display: flex; 247 + flex-direction: column; 248 + 249 + p { 250 + font-size: 16pt; 251 + margin: 0; 252 + margin-bottom: var(--2); 253 + text-wrap: balance; 254 + font-family: sans-serif; 255 + } 256 + } 257 + 258 + & > div:nth-child(2) { 259 + flex-grow: 1; 260 + display: flex; 261 + flex-direction: column; 262 + overflow-y: scroll; 263 + min-height: 0; 264 + } 265 + 266 + & > div:nth-child(3) { 267 + height: 7%; 268 + 269 + button { 270 + font-size: 20pt; 271 + box-shadow: 0 0 5px black; 272 + background-color: #6c7; 273 + border: none; 274 + border-radius: 5px; 275 + margin: 0; 276 + padding: var(--1); 277 + width: 100%; 278 + height: 100%; 279 + } 280 + } 281 + 282 + select { 283 + padding: .5em 0; 284 + } 285 + 286 + input { 287 + width: 100%; 288 + box-shadow: 0 0 5px #0004 inset; 289 + 290 + &.extra { 291 + margin-top: 0em; 292 + } 293 + 294 + &[type="range"] { 295 + appearance: none; 296 + padding: 0.2em 0; 297 + border-radius: 5px; 298 + background-color: #999; 299 + height: var(--1); 300 + 301 + &::-webkit-slider-thumb { 302 + appearance: none; 303 + border-radius: 50%; 304 + color: red; 305 + background-color: white; 306 + box-shadow: 0 0 5px #000a; 307 + height: var(--4); 308 + width: var(--4); 309 + } 310 + } 311 + } 312 + 313 + label { 314 + display: grid; 315 + grid-template-areas: "l p" "i i"; 316 + gap: var(--small); 317 + margin: var(--1) 0; 318 + width: 100%; 319 + 320 + :first-child { 321 + grid-area: l; 322 + } 323 + 324 + :nth-child(2) { 325 + grid-area: p; 326 + text-align: right; 327 + } 328 + 329 + :nth-child(3) { 330 + grid-area: i; 331 + } 332 + } 333 + } 334 + 201 335 :root::view-transition-group(spinner) { 202 336 animation-duration: 0s; 203 337 animation-delay: 0s; ··· 304 438 305 439 &.right { 306 440 right: var(--small); 441 + } 442 + 443 + &.center { 444 + max-width: min-content; 445 + margin: auto; 446 + left: 0; 447 + right: 0; 307 448 } 308 449 }