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