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