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};