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