1import {
2 Button,
3 ButtonGroup,
4 createStyles,
5 Grid,
6 IconButton,
7 makeStyles,
8 Paper,
9 Slider,
10 Theme,
11 Typography,
12 useTheme,
13} from '@material-ui/core';
14import { green, orange } from '@material-ui/core/colors';
15import {
16 Add,
17 ArrowBack,
18 Delete,
19 Link,
20 Person,
21 Search,
22 Timer,
23 TimerOff,
24 Visibility,
25 VisibilityOff,
26} from '@material-ui/icons';
27import { ok as assertTrue } from 'assert';
28import isArray from 'lodash/isArray';
29import range from 'lodash/range';
30import { DropzoneDialog } from 'material-ui-dropzone';
31import * as React from 'react';
32
33import { isDefined } from '../common';
34import { Board } from '../components/board';
35import { ClipboardButton } from '../components/clipboard';
36import { useServerTime } from '../hooks/useServerTime';
37import { State, StatePlayer, StateTimer, WordPack } from '../protocol';
38import { teamSpecs } from '../teams';
39
40export interface Sender {
41 reveal: (row: number, col: number) => void;
42 newGame: () => void;
43 endTurn: () => void;
44 changeNickname: (nickname: string) => void;
45 changeRole: (spymaster: boolean) => void;
46 changeTeam: (team: number) => void;
47 randomizeTeams: () => void;
48 changePack: (num: number, enable: boolean) => void;
49 changeTurnMode: (timed: boolean) => void;
50 changeTurnTime: (seconds: number) => void;
51 addPacks: (packs: { name: string; words: string[] }[]) => void;
52 removePack: (num: number) => void;
53 changeHideBomb: (HideBomb: boolean) => void;
54}
55
56export interface GameViewProps {
57 roomID: string;
58 leave: () => void;
59 send: Sender;
60 state: State;
61 pState: StatePlayer;
62 pTeam: number;
63}
64
65const useCenterStyles = makeStyles((_theme: Theme) =>
66 createStyles({
67 blink: {
68 animation: '$blinker 0.5s cubic-bezier(.5, 0, 1, 1) infinite alternate',
69 },
70 '@keyframes blinker': {
71 to: {
72 opacity: 0,
73 },
74 },
75 })
76);
77
78const CenterText = ({ winner, timer, turn }: State) => {
79 const classes = useCenterStyles();
80 const [countdown, setCountdown] = React.useState<number | undefined>();
81 const { now } = useServerTime();
82
83 React.useEffect(() => {
84 const updateCountdown = () => {
85 if (isDefined(winner)) {
86 setCountdown(undefined);
87 return;
88 }
89
90 if (!isDefined(timer)) {
91 if (countdown !== undefined) {
92 setCountdown(undefined);
93 }
94 return;
95 }
96
97 const deadline = timer.turnEnd;
98 const diff = deadline.getTime() - now();
99
100 const between = Math.floor(diff / 1000);
101 if (between < 0) {
102 if (countdown === 0) {
103 return;
104 }
105 setCountdown(0);
106 } else {
107 setCountdown(between);
108 }
109 };
110
111 updateCountdown();
112
113 const interval = window.setInterval(() => {
114 updateCountdown();
115 }, 200);
116
117 return () => window.clearInterval(interval);
118 }, [countdown, setCountdown, winner, timer, now]);
119
120 const centerText = React.useMemo(() => {
121 const text = isDefined(winner) ? `${teamSpecs[winner].name} wins!` : `${teamSpecs[turn].name}'s turn`;
122
123 if (!isDefined(countdown) || isDefined(winner)) {
124 return text;
125 }
126
127 return `${text} [${countdown}s]`;
128 }, [winner, turn, countdown]);
129
130 return (
131 <h1
132 style={{ color: teamSpecs[winner ?? turn].hue[600] }}
133 className={isDefined(countdown) && countdown < 10 ? classes.blink : undefined}
134 >
135 {centerText}
136 </h1>
137 );
138};
139
140const Header = ({ send, state, pState, pTeam }: GameViewProps) => {
141 const myTurn = state.turn === pTeam;
142
143 return (
144 <Grid container direction="row" justify="space-between" alignItems="center" spacing={2}>
145 <Grid item xs style={{ textAlign: 'left' }}>
146 <h1>
147 {state.wordsLeft.map((n, team) => {
148 return (
149 <span key={team}>
150 {team !== 0 ? <span> - </span> : null}
151 <span
152 style={{
153 color: teamSpecs[team].hue[600],
154 fontWeight: state.turn === team ? 'bold' : undefined,
155 }}
156 >
157 {n}
158 </span>
159 </span>
160 );
161 })}
162 </h1>
163 </Grid>
164 <Grid item xs style={{ textAlign: 'center' }}>
165 <CenterText {...state} />
166 </Grid>
167 <Grid item xs style={{ textAlign: 'right' }}>
168 <Button
169 type="button"
170 variant="outlined"
171 onClick={() => myTurn && !pState.spymaster && send.endTurn()}
172 disabled={!myTurn || pState.spymaster || isDefined(state.winner)}
173 >
174 End turn
175 </Button>
176 </Grid>
177 </Grid>
178 );
179};
180
181const sliderMarks = range(30, 301, 30).map((v) => ({ value: v }));
182
183interface TimerSliderProps {
184 version: number;
185 timer: StateTimer;
186 onCommit: (value: number) => void;
187}
188
189interface TimerValue {
190 version: number;
191 turnTime: number;
192}
193
194const TimerSlider = ({ version, timer, onCommit }: TimerSliderProps) => {
195 const [value, setValue] = React.useState<TimerValue>({ version, turnTime: timer.turnTime });
196
197 React.useEffect(() => {
198 if (version !== value.version) {
199 setValue({ version, turnTime: timer.turnTime });
200 }
201 }, [version, value.version, timer.turnTime]);
202
203 const valueStr = React.useMemo(() => {
204 const turnTime = value.turnTime;
205 switch (turnTime) {
206 case 30:
207 return '30 seconds';
208 case 60:
209 return '60 seconds';
210 default:
211 if (turnTime % 60 === 0) {
212 return `${turnTime / 60} minutes`;
213 }
214
215 return `${(turnTime / 60).toFixed(1)} minutes`;
216 }
217 }, [value.turnTime]);
218
219 return (
220 <>
221 <Typography id="timer-slider" gutterBottom>
222 Timer: {valueStr}
223 </Typography>
224 <Slider
225 style={{ color: orange[500] }}
226 aria-labelledby="timer-slider"
227 value={value.turnTime}
228 marks={sliderMarks}
229 step={null}
230 min={sliderMarks[0].value}
231 max={sliderMarks[sliderMarks.length - 1].value}
232 onChange={(_e, v) => {
233 assertTrue(!isArray(v));
234 setValue({ version: value.version, turnTime: v });
235 }}
236 onChangeCommitted={(_e, v) => {
237 assertTrue(!isArray(v));
238 onCommit(v);
239 }}
240 />
241 </>
242 );
243};
244
245const useSidebarStyles = makeStyles((_theme: Theme) =>
246 createStyles({
247 dropzone: {
248 backgroundColor: 'initial',
249 },
250 previewGrid: {
251 width: '100%',
252 },
253 })
254);
255
256const Sidebar = ({ send, state, pState, pTeam }: GameViewProps) => {
257 const classes = useSidebarStyles();
258 const theme = useTheme();
259 const nameShade = theme.palette.type === 'dark' ? 400 : 600;
260
261 const teams = state.teams;
262 const lists = state.lists;
263
264 const wordCount = React.useMemo(
265 () =>
266 lists.reduce((curr, l) => {
267 if (l.enabled) {
268 return curr + l.count;
269 }
270 return curr;
271 }, 0),
272 [lists]
273 );
274
275 const [uploadOpen, setUploadOpen] = React.useState(false);
276
277 return (
278 <>
279 <h2>Teams</h2>
280 <Paper style={{ padding: '0.5rem' }}>
281 <div
282 style={{
283 display: 'grid',
284 gridGap: '0.5rem',
285 gridTemplateColumns: `repeat(${teams.length}, 1fr)`,
286 }}
287 >
288 {teams.map((team, i) => (
289 <React.Fragment key={i}>
290 <Button
291 type="button"
292 variant="contained"
293 size="small"
294 style={{
295 gridRow: 1,
296 gridColumn: i + 1,
297 width: '100%',
298 color: 'white',
299 backgroundColor: teamSpecs[i].hue[600],
300 }}
301 disabled={pTeam === i}
302 onClick={() => send.changeTeam(i)}
303 >
304 Join {teamSpecs[i].name}
305 </Button>
306 {team.map((member, j) => (
307 <span
308 key={`member-${j}`}
309 style={{
310 gridRow: j + 2,
311 gridColumn: i + 1,
312 color: teamSpecs[i].hue[nameShade],
313 fontStyle: member.playerID === pState.playerID ? 'italic' : undefined,
314 }}
315 >
316 {member.spymaster ? `[${member.nickname}]` : member.nickname}
317 </span>
318 ))}
319 </React.Fragment>
320 ))}
321 </div>
322 <Button
323 type="button"
324 variant="outlined"
325 size="small"
326 style={{ width: '100%', marginTop: '0.5rem' }}
327 onClick={send.randomizeTeams}
328 >
329 Randomize teams
330 </Button>
331 </Paper>
332
333 <h2>Packs</h2>
334 <p style={{ fontStyle: 'italic' }}>{wordCount} words in the selected packs.</p>
335 <div style={{ display: 'grid', gridGap: '0.5rem' }}>
336 {lists.map((pack, i) => (
337 <div key={i} style={{ gridRow: i + 1 }}>
338 <Button
339 type="button"
340 variant={pack.enabled ? 'contained' : 'outlined'}
341 size="small"
342 style={{ width: pack.custom && !pack.enabled ? '90%' : '100%' }}
343 onClick={() => send.changePack(i, !pack.enabled)}
344 >
345 {pack.custom ? `Custom: ${pack.name}` : pack.name}
346 </Button>
347 {pack.custom && !pack.enabled ? (
348 <IconButton size="small" style={{ width: '10%' }} onClick={() => send.removePack(i)}>
349 <Delete />
350 </IconButton>
351 ) : null}
352 </div>
353 ))}
354 {lists.length >= 10 ? null : (
355 <>
356 <Button
357 type="button"
358 size="small"
359 startIcon={<Add />}
360 style={{ width: '100%', gridRow: lists.length + 2 }}
361 onClick={() => setUploadOpen(true)}
362 >
363 Upload packs
364 </Button>
365 <DropzoneDialog
366 acceptedFiles={['.txt']}
367 cancelButtonText={'cancel'}
368 submitButtonText={'submit'}
369 dropzoneClass={classes.dropzone}
370 dropzoneText={'Text files, one word per line. Click or drag to upload.'}
371 previewGridClasses={{ container: classes.previewGrid }}
372 previewText={'Files:'}
373 maxFileSize={1000000}
374 open={uploadOpen}
375 onClose={() => setUploadOpen(false)}
376 onSave={async (files) => {
377 setUploadOpen(false);
378
379 const packs: WordPack[] = [];
380
381 for (let i = 0; i < files.length; i++) {
382 const file = files[i];
383 const name = file.name.substring(0, file.name.lastIndexOf('.')) || file.name;
384 const words = (await file.text())
385 .split('\n')
386 .map((word) => word.trim())
387 .filter((word) => word);
388
389 if (words.length < 25) {
390 continue;
391 }
392
393 packs.push({ name, words });
394 }
395
396 if (packs.length) {
397 send.addPacks(packs);
398 }
399 }}
400 />
401 </>
402 )}
403 </div>
404 {!isDefined(state.timer) ? null : (
405 <div style={{ textAlign: 'left', marginTop: '1rem' }}>
406 <TimerSlider version={state.version} timer={state.timer} onCommit={send.changeTurnTime} />
407 </div>
408 )}
409 </>
410 );
411};
412
413const Board2 = ({ send, state, pState, pTeam }: GameViewProps) => {
414 const myTurn = state.turn === pTeam;
415 return (
416 <Board
417 words={state.board}
418 onClick={(row, col) => myTurn && !pState.spymaster && send.reveal(row, col)}
419 spymaster={pState.spymaster}
420 myTurn={myTurn}
421 winner={isDefined(state.winner)}
422 />
423 );
424};
425
426const Footer = ({ send, state, pState }: GameViewProps) => {
427 const end = isDefined(state.winner);
428
429 return (
430 <div style={{ display: 'flex', justifyContent: 'space-between', alignContent: 'flex-start', flexWrap: 'wrap' }}>
431 <div style={{ display: 'flex', alignContent: 'flex-start', flexWrap: 'wrap' }}>
432 <ButtonGroup variant="outlined" style={{ marginBottom: '0.5rem', marginRight: '0.5rem' }}>
433 <Button
434 type="button"
435 variant={pState.spymaster ? undefined : 'contained'}
436 onClick={() => send.changeRole(false)}
437 startIcon={<Search />}
438 disabled={end}
439 >
440 Guesser
441 </Button>
442 <Button
443 type="button"
444 variant={pState.spymaster ? 'contained' : undefined}
445 onClick={() => send.changeRole(true)}
446 startIcon={<Person />}
447 disabled={end}
448 >
449 Spymaster
450 </Button>
451 </ButtonGroup>
452 <ButtonGroup variant="outlined" style={{ marginBottom: '0.5rem', marginRight: '0.5rem' }}>
453 <Button
454 type="button"
455 variant={state.hideBomb ? undefined : 'contained'}
456 onClick={() => send.changeHideBomb(false)}
457 startIcon={<Visibility />}
458 >
459 Show bomb
460 </Button>
461 <Button
462 type="button"
463 variant={state.hideBomb ? 'contained' : undefined}
464 onClick={() => send.changeHideBomb(true)}
465 startIcon={<VisibilityOff />}
466 >
467 Hide bomb
468 </Button>
469 </ButtonGroup>
470 <ButtonGroup variant="outlined" style={{ marginBottom: '0.5rem', marginRight: '0.5rem' }}>
471 <Button
472 type="button"
473 variant={isDefined(state.timer) ? undefined : 'contained'}
474 onClick={() => send.changeTurnMode(false)}
475 >
476 <TimerOff />
477 </Button>
478 <Button
479 type="button"
480 variant={isDefined(state.timer) ? 'contained' : undefined}
481 onClick={() => send.changeTurnMode(true)}
482 >
483 <Timer />
484 </Button>
485 </ButtonGroup>
486 </div>
487 <div>
488 <Button
489 type="button"
490 variant={end ? 'contained' : 'outlined'}
491 color={end ? undefined : 'secondary'}
492 style={end ? { color: 'white', backgroundColor: green[500] } : undefined}
493 onClick={send.newGame}
494 >
495 New game
496 </Button>
497 </div>
498 </div>
499 );
500};
501
502const useStyles = makeStyles((theme: Theme) =>
503 createStyles({
504 root: {
505 height: '100vh',
506 display: 'flex',
507 },
508 wrapper: {
509 width: '100%',
510 textAlign: 'center',
511 paddingLeft: theme.spacing(2),
512 paddingRight: theme.spacing(2),
513 // Emulate the MUI Container component.
514 maxWidth: `1560px`, // TODO: Surely this shouldn't be hardcoded.
515 margin: 'auto',
516 // marginRight: 'auto',
517 display: 'grid',
518 gridGap: theme.spacing(2),
519 gridTemplateAreas: '"header" "board" "footer" "sidebar"',
520 [theme.breakpoints.down('lg')]: {
521 paddingTop: theme.spacing(5),
522 },
523 [theme.breakpoints.up('lg')]: {
524 gridTemplateColumns: '1fr 4fr 1fr',
525 gridTemplateRows: '1fr auto 1fr',
526 gridTemplateAreas: '". header ." "sidebar board ." ". footer ."',
527 },
528 },
529 header: {
530 gridArea: 'header',
531 },
532 board: {
533 gridArea: 'board',
534 },
535 footer: {
536 gridArea: 'footer',
537 },
538 sidebar: {
539 gridArea: 'sidebar',
540 },
541 })
542);
543
544export const GameView = (props: GameViewProps) => {
545 const classes = useStyles();
546
547 return (
548 <div className={classes.root}>
549 <div
550 style={{
551 position: 'absolute',
552 top: 0,
553 left: 0,
554 margin: '0.5rem',
555 }}
556 >
557 <Button type="button" onClick={props.leave} startIcon={<ArrowBack />} style={{ marginRight: '0.5rem' }}>
558 Leave
559 </Button>
560 <ClipboardButton
561 buttonText="Copy Room URL"
562 toCopy={`${window.location.origin}/?roomID=${props.roomID}`}
563 icon={<Link />}
564 />
565 </div>
566 <div className={classes.wrapper}>
567 <div className={classes.header}>
568 <Header {...props} />
569 </div>
570 <div className={classes.board}>
571 <Board2 {...props} />
572 </div>
573 <div className={classes.footer}>
574 <Footer {...props} />
575 </div>
576 <div className={classes.sidebar}>
577 <Sidebar {...props} />
578 </div>
579 </div>
580 </div>
581 );
582};