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