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