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