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    id: string;
172    timer: StateTimer;
173    onCommit: (value: number) => void;
174}
175
176const TimerSlider = ({ timer, onCommit, id }: TimerSliderProps) => {
177    // Keep around the original value when this component is created.
178    // This prevents React from complaining about the defaultValue
179    // changing when the overall state changes.
180    const defaultValue = React.useRef(timer.turnTime);
181    const [value, setValue] = React.useState(timer.turnTime);
182
183    const valueStr = React.useMemo(() => {
184        switch (value) {
185            case 30:
186                return '30 seconds';
187            case 60:
188                return '60 seconds';
189            default:
190                if (value % 60 === 0) {
191                    return `${value / 60} minutes`;
192                }
193
194                return `${(value / 60).toFixed(1)} minutes`;
195        }
196    }, [value]);
197
198    return (
199        <>
200            <Typography id={id} gutterBottom>
201                Timer: {valueStr}
202            </Typography>
203            <Slider
204                style={{ color: orange[500] }}
205                aria-labelledby={id}
206                marks={sliderMarks}
207                defaultValue={defaultValue.current}
208                step={null}
209                min={sliderMarks[0].value}
210                max={sliderMarks[sliderMarks.length - 1].value}
211                onChange={(_e, v) => {
212                    assertTrue(!isArray(v));
213                    if (v !== value) {
214                        setValue(v);
215                    }
216                }}
217                onChangeCommitted={(_e, v) => {
218                    assertTrue(!isArray(v));
219                    onCommit(v);
220                }}
221            />
222        </>
223    );
224};
225
226const useSidebarStyles = makeStyles((_theme: Theme) =>
227    createStyles({
228        dropzone: {
229            backgroundColor: 'initial',
230        },
231        previewGrid: {
232            width: '100%',
233        },
234    })
235);
236
237const Sidebar = ({ send, state, pState, pTeam }: GameViewProps) => {
238    const classes = useSidebarStyles();
239
240    const teams = state.teams;
241    const lists = state.lists;
242
243    const wordCount = React.useMemo(
244        () =>
245            lists.reduce((curr, l) => {
246                if (l.enabled) {
247                    return curr + l.count;
248                }
249                return curr;
250            }, 0),
251        [lists]
252    );
253
254    const [uploadOpen, setUploadOpen] = React.useState(false);
255
256    return (
257        <>
258            <h2>Teams</h2>
259            <Paper style={{ padding: '0.5rem' }}>
260                <div
261                    style={{
262                        display: 'grid',
263                        gridGap: '0.5rem',
264                        gridTemplateColumns: `repeat(${teams.length}, 1fr)`,
265                    }}
266                >
267                    {teams.map((team, i) => (
268                        <React.Fragment key={i}>
269                            <Button
270                                type="button"
271                                variant="contained"
272                                size="small"
273                                style={{
274                                    gridRow: 1,
275                                    gridColumn: i + 1,
276                                    width: '100%',
277                                    color: 'white',
278                                    backgroundColor: teamSpecs[i].hue[600],
279                                }}
280                                disabled={pTeam === i}
281                                onClick={() => send.changeTeam(i)}
282                            >
283                                Join {teamSpecs[i].name}
284                            </Button>
285                            {team.map((member, j) => (
286                                <span
287                                    key={`member-${j}`}
288                                    style={{
289                                        gridRow: j + 2,
290                                        gridColumn: i + 1,
291                                        color: teamSpecs[i].hue[500],
292                                        fontStyle: member.playerID === pState.playerID ? 'italic' : undefined,
293                                    }}
294                                >
295                                    {member.spymaster ? `[${member.nickname}]` : member.nickname}
296                                </span>
297                            ))}
298                        </React.Fragment>
299                    ))}
300                </div>
301                <Button
302                    type="button"
303                    variant="outlined"
304                    size="small"
305                    style={{ width: '100%', marginTop: '0.5rem' }}
306                    onClick={send.randomizeTeams}
307                >
308                    Randomize teams
309                </Button>
310            </Paper>
311
312            <h2>Packs</h2>
313            <p style={{ fontStyle: 'italic' }}>{wordCount} words in the selected packs.</p>
314            <div style={{ display: 'grid', gridGap: '0.5rem' }}>
315                {lists.map((pack, i) => (
316                    <div key={i} style={{ gridRow: i + 1 }}>
317                        <Button
318                            type="button"
319                            variant={pack.enabled ? 'contained' : 'outlined'}
320                            size="small"
321                            style={{ width: pack.custom && !pack.enabled ? '90%' : '100%' }}
322                            onClick={() => send.changePack(i, !pack.enabled)}
323                        >
324                            {pack.custom ? `Custom: ${pack.name}` : pack.name}
325                        </Button>
326                        {pack.custom && !pack.enabled ? (
327                            <IconButton size="small" style={{ width: '10%' }} onClick={() => send.removePack(i)}>
328                                <Delete />
329                            </IconButton>
330                        ) : null}
331                    </div>
332                ))}
333                {lists.length >= 10 ? null : (
334                    <>
335                        <Button
336                            type="button"
337                            size="small"
338                            startIcon={<Add />}
339                            style={{ width: '100%', gridRow: lists.length + 2 }}
340                            onClick={() => setUploadOpen(true)}
341                        >
342                            Upload packs
343                        </Button>
344                        <DropzoneDialog
345                            acceptedFiles={['.txt']}
346                            cancelButtonText={'cancel'}
347                            submitButtonText={'submit'}
348                            dropzoneClass={classes.dropzone}
349                            dropzoneText={'Text files, one word per line. Click or drag to upload.'}
350                            previewGridClasses={{ container: classes.previewGrid }}
351                            previewText={'Files:'}
352                            maxFileSize={1000000}
353                            open={uploadOpen}
354                            onClose={() => setUploadOpen(false)}
355                            onSave={async (files) => {
356                                setUploadOpen(false);
357
358                                const packs: WordPack[] = [];
359
360                                for (let i = 0; i < files.length; i++) {
361                                    const file = files[i];
362                                    const name = file.name.substring(0, file.name.lastIndexOf('.')) || file.name;
363                                    const text = (await file.text()).trim();
364                                    const words = text.split('\n');
365
366                                    if (words.length < 25) {
367                                        continue;
368                                    }
369
370                                    packs.push({ name, words });
371                                }
372
373                                send.addPacks(packs);
374                            }}
375                        />
376                    </>
377                )}
378            </div>
379            {!isDefined(state.timer) ? null : (
380                <div style={{ textAlign: 'left', marginTop: '1rem' }}>
381                    <TimerSlider id="timer-slider" timer={state.timer} onCommit={send.changeTurnTime} />
382                </div>
383            )}
384        </>
385    );
386};
387
388const Board2 = ({ send, state, pState, pTeam }: GameViewProps) => {
389    const myTurn = state.turn === pTeam;
390    return (
391        <Board
392            words={state.board}
393            onClick={(row, col) => myTurn && !pState.spymaster && send.reveal(row, col)}
394            spymaster={pState.spymaster}
395            myTurn={myTurn}
396            winner={isDefined(state.winner)}
397        />
398    );
399};
400
401const Footer = ({ send, state, pState }: GameViewProps) => {
402    const end = isDefined(state.winner);
403
404    return (
405        <Grid container direction="row" justify="space-between" alignItems="flex-start" spacing={2}>
406            <Grid item xs style={{ textAlign: 'left' }}>
407                <ButtonGroup
408                    variant="outlined"
409                    style={{ marginBottom: '0.5rem', marginRight: '0.5rem', display: 'inline' }}
410                >
411                    <Button
412                        type="button"
413                        variant={pState.spymaster ? undefined : 'contained'}
414                        onClick={() => send.changeRole(false)}
415                        startIcon={<Search />}
416                        disabled={end}
417                    >
418                        Guesser
419                    </Button>
420                    <Button
421                        type="button"
422                        variant={pState.spymaster ? 'contained' : undefined}
423                        onClick={() => send.changeRole(true)}
424                        startIcon={<Person />}
425                        disabled={end}
426                    >
427                        Spymaster
428                    </Button>
429                </ButtonGroup>
430                <ButtonGroup
431                    variant="outlined"
432                    style={{ marginBottom: '0.5rem', marginRight: '0.5rem', display: 'inline' }}
433                >
434                    <Button
435                        type="button"
436                        variant={isDefined(state.timer) ? undefined : 'contained'}
437                        onClick={() => send.changeTurnMode(false)}
438                    >
439                        <TimerOff />
440                    </Button>
441                    <Button
442                        type="button"
443                        variant={isDefined(state.timer) ? 'contained' : undefined}
444                        onClick={() => send.changeTurnMode(true)}
445                    >
446                        <Timer />
447                    </Button>
448                </ButtonGroup>
449            </Grid>
450            <Grid item xs style={{ textAlign: 'right' }}>
451                <Button
452                    type="button"
453                    variant={end ? 'contained' : 'outlined'}
454                    color={end ? undefined : 'secondary'}
455                    style={end ? { color: 'white', backgroundColor: green[500] } : undefined}
456                    onClick={send.newGame}
457                >
458                    New game
459                </Button>
460            </Grid>
461        </Grid>
462    );
463};
464
465const useStyles = makeStyles((theme: Theme) =>
466    createStyles({
467        root: {
468            height: '100vh',
469            display: 'flex',
470        },
471        wrapper: {
472            width: '100%',
473            textAlign: 'center',
474            paddingLeft: theme.spacing(2),
475            paddingRight: theme.spacing(2),
476            // Emulate the MUI Container component.
477            maxWidth: `1560px`, // TODO: Surely this shouldn't be hardcoded.
478            margin: 'auto',
479            // marginRight: 'auto',
480            display: 'grid',
481            gridGap: theme.spacing(2),
482            gridTemplateAreas: '"header" "board" "footer" "sidebar"',
483            [theme.breakpoints.down('lg')]: {
484                paddingTop: theme.spacing(5),
485            },
486            [theme.breakpoints.up('lg')]: {
487                gridTemplateColumns: '1fr 4fr 1fr',
488                gridTemplateRows: '1fr auto 1fr',
489                gridTemplateAreas: '". header ." "sidebar board ." ". footer ."',
490            },
491        },
492        header: {
493            gridArea: 'header',
494        },
495        board: {
496            gridArea: 'board',
497        },
498        footer: {
499            gridArea: 'footer',
500        },
501        sidebar: {
502            gridArea: 'sidebar',
503        },
504    })
505);
506
507export const GameView = (props: GameViewProps) => {
508    const classes = useStyles();
509
510    return (
511        <div className={classes.root}>
512            <div
513                style={{
514                    position: 'absolute',
515                    top: 0,
516                    left: 0,
517                    margin: '0.5rem',
518                }}
519            >
520                <Button type="button" onClick={props.leave} startIcon={<ArrowBack />} style={{ marginRight: '0.5rem' }}>
521                    Leave
522                </Button>
523                <ClipboardButton
524                    buttonText="Copy Room URL"
525                    toCopy={`${window.location.origin}/?roomID=${props.roomID}`}
526                    icon={<Link />}
527                />
528            </div>
529            <div className={classes.wrapper}>
530                <div className={classes.header}>
531                    <Header {...props} />
532                </div>
533                <div className={classes.board}>
534                    <Board2 {...props} />
535                </div>
536                <div className={classes.footer}>
537                    <Footer {...props} />
538                </div>
539                <div className={classes.sidebar}>
540                    <Sidebar {...props} />
541                </div>
542            </div>
543        </div>
544    );
545};