Live location tracking and playback for the game "manhunt"
1import React, { useEffect, useState } from "react";
2import { commands, GameSettings, LobbyState, PingStartCondition, PlayerProfile } from "@/bindings";
3import { useTauriEvent } from "@/lib/hooks";
4import ProfilePicture, { iconForDecor, ProfileDecor } from "./ProfilePicture";
5import { defaultSettings } from "./MenuScreen";
6import { ask } from "@tauri-apps/plugin-dialog";
7import {
8 IconArrowBigLeftLinesFilled,
9 IconCircleCheckFilled,
10 IconCircleDashedPlus,
11 IconFlagFilled,
12 IconInfoCircleFilled,
13 IconSettingsFilled
14} from "@tabler/icons-react";
15import LoadingCover from "./LoadingCover";
16import GameSettingsModal from "./game-settings/GameSettingsModal";
17
18function ProfileList({
19 profiles,
20 decoration,
21 emptyText
22}: {
23 profiles: [string, PlayerProfile][];
24 decoration: ProfileDecor;
25 emptyText: string;
26}) {
27 return (
28 <div className="pfp-list">
29 {profiles.length === 0 && <small>{emptyText}</small>}
30 {profiles.map(([k, p]) => (
31 <ProfilePicture
32 key={k}
33 decoration={decoration}
34 src={p.pfp_base64}
35 fallbackName={p.display_name}
36 />
37 ))}
38 </div>
39 );
40}
41
42function TeamButton({
43 onClick,
44 active,
45 deco,
46 text
47}: {
48 onClick: () => void;
49 active: boolean;
50 deco: ProfileDecor;
51 text: string;
52}) {
53 const Icon = iconForDecor(deco);
54
55 return (
56 <button onClick={onClick} className={`team-button ${deco}`}>
57 <Icon />
58 {active ? "You're On" : "Join"} {text}
59 {active ? <IconCircleCheckFilled /> : <IconCircleDashedPlus />}
60 </button>
61 );
62}
63
64const initLobbyState: LobbyState = {
65 profiles: {},
66 join_code: "",
67 teams: {},
68 self_id: "",
69 is_host: false,
70 settings: defaultSettings()
71};
72
73export default function LobbyScreen() {
74 const [lobbyState, setLobbyState] = useState(initLobbyState);
75 const [loadingCover, setLoadingCover] = useState(true);
76 const [settingOpen, setSettingsOpen] = useState(false);
77
78 useEffect(() => {
79 let cancel = false;
80 const clear = setTimeout(() => {
81 commands.getLobbyState().then((state) => {
82 if (!cancel) {
83 setLobbyState(state);
84 setLoadingCover(false);
85 }
86 });
87 }, 300);
88 return () => {
89 cancel = true;
90 clearTimeout(clear);
91 };
92 }, [setLobbyState]);
93
94 useTauriEvent("lobbyStateUpdate", () => {
95 commands.getLobbyState().then((state) => {
96 setLobbyState(state);
97 });
98 });
99
100 const profiles = Object.entries(lobbyState.profiles).filter(([_, p]) => p !== undefined) as [
101 string,
102 PlayerProfile
103 ][];
104
105 const seekers = profiles.filter(([id, _]) => lobbyState.teams[id] ?? false);
106 const hiders = profiles.filter(([id, _]) => !(lobbyState.teams[id] ?? false));
107
108 const isSeeker = lobbyState.teams[lobbyState.self_id] ?? false;
109
110 const onLeaveLobby = () => {
111 // Don't prompt since the lobby is empty (besides us obv)
112 // <= even though it should never be 0... hopefully....
113 if (lobbyState.is_host && profiles.length <= 1) {
114 commands.quitToMenu();
115 return;
116 }
117 const hostMsg = lobbyState.is_host ? " You are the host so this will cancel the lobby" : "";
118
119 const msg = `Are you sure you want to leave this lobby?${hostMsg}`;
120
121 ask(msg, {
122 title: "Leave Lobby",
123 kind: lobbyState.is_host ? "warning" : "info"
124 }).then((choice) => {
125 if (choice) {
126 commands.quitToMenu();
127 }
128 });
129 };
130
131 const canStart = lobbyState.is_host && seekers.length !== 0 && hiders.length !== 0;
132
133 const onStartGame = () => {
134 if (canStart) {
135 setLoadingCover(true);
136 ask(
137 "Are you ready to start the game? New players will be unable to join. The hiding timer will begin the moment you press start."
138 )
139 .then((choice) => {
140 if (choice) {
141 commands.hostStartGame().finally(() => {
142 setLoadingCover(false);
143 });
144 } else {
145 setLoadingCover(false);
146 }
147 })
148 .catch(() => {
149 setLoadingCover(false);
150 });
151 }
152 };
153
154 const onOpenSettings = () => {
155 setSettingsOpen(true);
156 };
157
158 const onSettingsSave = (settings: GameSettings) => {
159 commands.hostUpdateSettings(settings);
160 setSettingsOpen(false);
161 };
162
163 return (
164 <>
165 <LoadingCover show={loadingCover} />
166 {settingOpen && (
167 <GameSettingsModal gameSettings={lobbyState.settings} onSave={onSettingsSave} />
168 )}
169 <header>
170 <span className="grow">Lobby</span>
171 <span>Join: {lobbyState.join_code}</span>
172 </header>
173 <main className="lobby">
174 <ProfileList profiles={seekers} decoration="seeker" emptyText="No Seekers" />
175 <TeamButton
176 onClick={() => commands.switchTeams(true)}
177 active={isSeeker}
178 text="Seekers"
179 deco="seeker"
180 />
181 <div className="frame">
182 <button onClick={onLeaveLobby} className="fab tl">
183 <IconArrowBigLeftLinesFilled size="1.5em" />
184 Leave
185 </button>
186 {lobbyState.is_host && (
187 <button onClick={onOpenSettings} className="fab bl">
188 <IconSettingsFilled size="1.5em" />
189 Rules
190 </button>
191 )}
192 {lobbyState.is_host && (
193 <button disabled={!canStart} onClick={onStartGame} className="fab br">
194 <IconFlagFilled size="1.5em" />
195 Start
196 </button>
197 )}
198 </div>
199 <TeamButton
200 onClick={() => commands.switchTeams(false)}
201 active={!isSeeker}
202 text="Hiders"
203 deco="hider"
204 />
205 <ProfileList profiles={hiders} decoration="hider" emptyText="No Hiders" />
206 </main>
207 </>
208 );
209}