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