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 words = (await file.text())
364 .split('\n')
365 .map((word) => word.trim())
366 .filter((word) => word);
367
368 if (words.length < 25) {
369 continue;
370 }
371
372 packs.push({ name, words });
373 }
374
375 if (packs.length) {
376 send.addPacks(packs);
377 }
378 }}
379 />
380 </>
381 )}
382 </div>
383 {!isDefined(state.timer) ? null : (
384 <div style={{ textAlign: 'left', marginTop: '1rem' }}>
385 <TimerSlider id="timer-slider" timer={state.timer} onCommit={send.changeTurnTime} />
386 </div>
387 )}
388 </>
389 );
390};
391
392const Board2 = ({ send, state, pState, pTeam }: GameViewProps) => {
393 const myTurn = state.turn === pTeam;
394 return (
395 <Board
396 words={state.board}
397 onClick={(row, col) => myTurn && !pState.spymaster && send.reveal(row, col)}
398 spymaster={pState.spymaster}
399 myTurn={myTurn}
400 winner={isDefined(state.winner)}
401 />
402 );
403};
404
405const Footer = ({ send, state, pState }: GameViewProps) => {
406 const end = isDefined(state.winner);
407
408 return (
409 <Grid container direction="row" justify="space-between" alignItems="flex-start" spacing={2}>
410 <Grid item xs style={{ textAlign: 'left' }}>
411 <ButtonGroup
412 variant="outlined"
413 style={{ marginBottom: '0.5rem', marginRight: '0.5rem', display: 'inline' }}
414 >
415 <Button
416 type="button"
417 variant={pState.spymaster ? undefined : 'contained'}
418 onClick={() => send.changeRole(false)}
419 startIcon={<Search />}
420 disabled={end}
421 >
422 Guesser
423 </Button>
424 <Button
425 type="button"
426 variant={pState.spymaster ? 'contained' : undefined}
427 onClick={() => send.changeRole(true)}
428 startIcon={<Person />}
429 disabled={end}
430 >
431 Spymaster
432 </Button>
433 </ButtonGroup>
434 <ButtonGroup
435 variant="outlined"
436 style={{ marginBottom: '0.5rem', marginRight: '0.5rem', display: 'inline' }}
437 >
438 <Button
439 type="button"
440 variant={isDefined(state.timer) ? undefined : 'contained'}
441 onClick={() => send.changeTurnMode(false)}
442 >
443 <TimerOff />
444 </Button>
445 <Button
446 type="button"
447 variant={isDefined(state.timer) ? 'contained' : undefined}
448 onClick={() => send.changeTurnMode(true)}
449 >
450 <Timer />
451 </Button>
452 </ButtonGroup>
453 </Grid>
454 <Grid item xs style={{ textAlign: 'right' }}>
455 <Button
456 type="button"
457 variant={end ? 'contained' : 'outlined'}
458 color={end ? undefined : 'secondary'}
459 style={end ? { color: 'white', backgroundColor: green[500] } : undefined}
460 onClick={send.newGame}
461 >
462 New game
463 </Button>
464 </Grid>
465 </Grid>
466 );
467};
468
469const useStyles = makeStyles((theme: Theme) =>
470 createStyles({
471 root: {
472 height: '100vh',
473 display: 'flex',
474 },
475 wrapper: {
476 width: '100%',
477 textAlign: 'center',
478 paddingLeft: theme.spacing(2),
479 paddingRight: theme.spacing(2),
480 // Emulate the MUI Container component.
481 maxWidth: `1560px`, // TODO: Surely this shouldn't be hardcoded.
482 margin: 'auto',
483 // marginRight: 'auto',
484 display: 'grid',
485 gridGap: theme.spacing(2),
486 gridTemplateAreas: '"header" "board" "footer" "sidebar"',
487 [theme.breakpoints.down('lg')]: {
488 paddingTop: theme.spacing(5),
489 },
490 [theme.breakpoints.up('lg')]: {
491 gridTemplateColumns: '1fr 4fr 1fr',
492 gridTemplateRows: '1fr auto 1fr',
493 gridTemplateAreas: '". header ." "sidebar board ." ". footer ."',
494 },
495 },
496 header: {
497 gridArea: 'header',
498 },
499 board: {
500 gridArea: 'board',
501 },
502 footer: {
503 gridArea: 'footer',
504 },
505 sidebar: {
506 gridArea: 'sidebar',
507 },
508 })
509);
510
511export const GameView = (props: GameViewProps) => {
512 const classes = useStyles();
513
514 return (
515 <div className={classes.root}>
516 <div
517 style={{
518 position: 'absolute',
519 top: 0,
520 left: 0,
521 margin: '0.5rem',
522 }}
523 >
524 <Button type="button" onClick={props.leave} startIcon={<ArrowBack />} style={{ marginRight: '0.5rem' }}>
525 Leave
526 </Button>
527 <ClipboardButton
528 buttonText="Copy Room URL"
529 toCopy={`${window.location.origin}/?roomID=${props.roomID}`}
530 icon={<Link />}
531 />
532 </div>
533 <div className={classes.wrapper}>
534 <div className={classes.header}>
535 <Header {...props} />
536 </div>
537 <div className={classes.board}>
538 <Board2 {...props} />
539 </div>
540 <div className={classes.footer}>
541 <Footer {...props} />
542 </div>
543 <div className={classes.sidebar}>
544 <Sidebar {...props} />
545 </div>
546 </div>
547 </div>
548 );
549};