gameView.tsx

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