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