gameView.tsx

  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};