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