gameView.tsx

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