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