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