@ -0,0 +1,32 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Project binaries
|
||||
codies
|
||||
|
||||
# IDEs
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
.git/
|
||||
config/
|
||||
|
||||
# common benchstat filenames
|
||||
old.txt
|
||||
new.txt
|
||||
|
||||
frontend/node_modules/
|
||||
frontend/build/
|
@ -0,0 +1,22 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Project binaries
|
||||
codies
|
||||
|
||||
# IDEs
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
@ -0,0 +1,2 @@
|
||||
**/build/**
|
||||
**/node_modules/**
|
@ -0,0 +1,15 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"printWidth": 120,
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.yml", "*.yaml"],
|
||||
"options": {
|
||||
"tabWidth": 2,
|
||||
"singleQuote": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
FROM node:14 AS JS_BUILD
|
||||
WORKDIR /frontend
|
||||
COPY ./frontend/package.json ./frontend/yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
COPY ./frontend ./
|
||||
RUN yarn build
|
||||
|
||||
FROM golang:1.14 as GO_BUILD
|
||||
WORKDIR /codies
|
||||
COPY ./go.mod ./go.sum ./
|
||||
RUN go mod download
|
||||
# Manually copying the required files to make this image's cache only include Go code.
|
||||
COPY ./main.go ./main.go
|
||||
COPY ./internal ./internal
|
||||
RUN go build .
|
||||
|
||||
# TODO: Use distroless/static and statically compile above. (https://golang.org/issue/26492)
|
||||
FROM gcr.io/distroless/base:nonroot
|
||||
WORKDIR /codies
|
||||
COPY --from=GO_BUILD /codies/codies ./codies
|
||||
COPY --from=JS_BUILD /frontend/build ./frontend/build
|
||||
ENTRYPOINT [ "/codies/codies" ]
|
||||
EXPOSE 5000
|
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 zikaeroh
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
@ -0,0 +1,17 @@
|
||||
# codies
|
||||
|
||||
Yet another Codenames webapp. Featuring:
|
||||
|
||||
- Custom word packs
|
||||
- Timed mode
|
||||
- Quick room joining
|
||||
- Dark/light mode
|
||||
- Responsiveness for mobile play
|
||||
- And more!
|
||||
|
||||
This is entirely inspired by the wonderful [codenames.plus](https://github.com/Joooop/codenames.plus),
|
||||
which works very well, but hasn't been scaling too well recently. I wanted an opportunity
|
||||
to learn TypeScript and React, and figured I could make something that worked just as well
|
||||
with a few extra niceties (and a more stable backend).
|
||||
|
||||

|
@ -0,0 +1,43 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "frontend"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[markdown]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[yaml]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"files.exclude": {
|
||||
"**/node_modules": true
|
||||
},
|
||||
"typescript.tsdk": "frontend/node_modules/typescript/lib"
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 56 KiB |
@ -0,0 +1,2 @@
|
||||
**/build/**
|
||||
**/node_modules/**
|
@ -0,0 +1,37 @@
|
||||
{
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"prettier",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true
|
||||
},
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 6,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["@typescript-eslint/eslint-plugin", "simple-import-sort"],
|
||||
"rules": {
|
||||
"eqeqeq": "error",
|
||||
"no-undef": 0,
|
||||
"simple-import-sort/sort": "warn",
|
||||
"@typescript-eslint/explicit-function-return-type": 0,
|
||||
"@typescript-eslint/no-empty-interface": 0,
|
||||
"@typescript-eslint/no-empty-function": 0,
|
||||
"@typescript-eslint/no-explicit-any": 0,
|
||||
"@typescript-eslint/no-namespace": 0,
|
||||
"@typescript-eslint/no-use-before-define": 0,
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/explicit-member-accessibility": "warn"
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@material-ui/core": "^4.9.13",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@rehooks/local-storage": "^2.4.0",
|
||||
"@testing-library/jest-dom": "^5.7.0",
|
||||
"@testing-library/react": "^10.0.4",
|
||||
"@testing-library/user-event": "^10.1.1",
|
||||
"@types/jest": "^25.2.1",
|
||||
"@types/lodash-es": "^4.17.3",
|
||||
"@types/node": "^14.0.1",
|
||||
"@types/react": "^16.9.0",
|
||||
"@types/react-dom": "^16.9.7",
|
||||
"@types/uuid": "^8.0.0",
|
||||
"clipboard-copy": "^3.1.0",
|
||||
"fireworks": "^2.2.5",
|
||||
"lodash": "^4.17.15",
|
||||
"material-ui-dropzone": "^3.0.0",
|
||||
"myzod": "^1.0.0-alpha.9",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-hook-form": "^5.6.3",
|
||||
"react-scripts": "^3.4.1",
|
||||
"react-use-websocket": "^2.0.1",
|
||||
"typeface-roboto": "^0.0.75",
|
||||
"typescript": "^3.9.2",
|
||||
"uuid": "^8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^2.31.0",
|
||||
"@typescript-eslint/parser": "^2.31.0",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-config-prettier": "^6.11.0",
|
||||
"eslint-plugin-react": "^7.19.0",
|
||||
"eslint-plugin-react-hooks": "^4.0.0",
|
||||
"eslint-plugin-simple-import-sort": "^5.0.3",
|
||||
"mutationobserver-shim": "^0.3.5",
|
||||
"prettier": "2.0.5",
|
||||
"source-map-explorer": "^2.4.2"
|
||||
},
|
||||
"scripts": {
|
||||
"analyze": "source-map-explorer 'build/static/js/*.js'",
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
"defaults",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"proxy": "http://localhost:5000"
|
||||
}
|
After Width: | Height: | Size: 6.5 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 6.0 KiB |
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#00aba9</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
After Width: | Height: | Size: 843 B |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 15 KiB |
@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Codenames, on the web." />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest" />
|
||||
<link rel="mask-icon" href="%PUBLIC_URL%/safari-pinned-tab.svg" color="#5bbad5" />
|
||||
<meta name="msapplication-TileColor" content="#00aba9" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
|
||||
<title>Codies</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
After Width: | Height: | Size: 5.2 KiB |
After Width: | Height: | Size: 9.4 KiB |
After Width: | Height: | Size: 3.5 KiB |
@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
After Width: | Height: | Size: 5.0 KiB |
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { App } from './app';
|
||||
|
||||
test('renders codies name', () => {
|
||||
const { getByText } = render(<App />);
|
||||
const element = getByText(/codies/i);
|
||||
expect(element).toBeInTheDocument();
|
||||
});
|
@ -0,0 +1,32 @@
|
||||
import querystring from 'querystring';
|
||||
import * as React from 'react';
|
||||
|
||||
import { ServerTimeProvider } from './hooks/useServerTime';
|
||||
import { Game, GameProps } from './pages/game';
|
||||
import { Login } from './pages/login';
|
||||
import { StaticView } from './pages/staticView';
|
||||
|
||||
export const App = (_props: {}) => {
|
||||
const [gameProps, setGameProps] = React.useState<GameProps | undefined>();
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const query = querystring.parse(window.location.search.substring(1));
|
||||
if (query.static !== undefined) {
|
||||
return <StaticView />;
|
||||
}
|
||||
}
|
||||
|
||||
if (gameProps) {
|
||||
return (
|
||||
<ServerTimeProvider>
|
||||
<Game {...gameProps} />
|
||||
</ServerTimeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Login
|
||||
onLogin={(roomID, nickname) => setGameProps({ roomID, nickname, leave: () => setGameProps(undefined) })}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,32 @@
|
||||
import { fail } from 'assert';
|
||||
|
||||
export function noop() {}
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
export function websocketUrl(path: string): string {
|
||||
const loc = window.location;
|
||||
|
||||
if (isDev) {
|
||||
// react-scripts does not properly proxy websocket requests, so manually select the URL here.
|
||||
return `ws://${loc.hostname}:5000${path}`;
|
||||
}
|
||||
|
||||
return `${loc.protocol === 'https:' ? 'wss:' : 'ws:'}//${loc.host}${path}`;
|
||||
}
|
||||
|
||||
export function assertNever(x: never): never {
|
||||
throw new Error('Unexpected object: ' + x);
|
||||
}
|
||||
|
||||
export function isDefined<T>(x: T | undefined | null): x is T {
|
||||
return x !== undefined && x !== null;
|
||||
}
|
||||
|
||||
export function assertIsDefined<T>(val: T): asserts val is NonNullable<T> {
|
||||
if (val === undefined || val === null) {
|
||||
fail(`Expected 'val' to be defined, but received ${val}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const nameofFactory = <T>() => (name: keyof T) => name;
|
@ -0,0 +1,62 @@
|
||||
import { Backdrop, Button, createStyles, Fade, makeStyles, Modal, Paper, Theme } from '@material-ui/core';
|
||||
import { Help } from '@material-ui/icons';
|
||||
import * as React from 'react';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
modal: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
paper: {
|
||||
border: '2px solid #000',
|
||||
boxShadow: theme.shadows[5],
|
||||
padding: theme.spacing(2, 4, 3),
|
||||
maxWidth: '500px',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export const AboutButton = (props: { style?: React.CSSProperties }) => {
|
||||
const classes = useStyles();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<span style={props.style}>
|
||||
<Button type="button" startIcon={<Help />} onClick={handleOpen}>
|
||||
About
|
||||
</Button>
|
||||
<Modal
|
||||
className={classes.modal}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
closeAfterTransition
|
||||
BackdropComponent={Backdrop}
|
||||
BackdropProps={{
|
||||
timeout: 500,
|
||||
}}
|
||||
>
|
||||
<Fade in={open}>
|
||||
<Paper className={classes.paper}>
|
||||
<h2>How to play</h2>
|
||||
<p>
|
||||
In Codenames, spymasters give one word clues pointing to multiple words on the board, as
|
||||
well as the number of words corresponding to that clue (for example, "animal 3").
|
||||
Their teammates then try to guess the words while avoiding the opposing team's, and may
|
||||
guess as many times as the words the spymaster gave in their clue, plus an additional guess.
|
||||
</p>
|
||||
</Paper>
|
||||
</Fade>
|
||||
</Modal>
|
||||
</span>
|
||||
);
|
||||
};
|
@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export interface AspectDivProps {
|
||||
aspectRatio: string;
|
||||
}
|
||||
|
||||
export const AspectDiv = (props: React.PropsWithChildren<AspectDivProps>) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 0,
|
||||
paddingBottom: props.aspectRatio,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,177 @@
|
||||
import { Button, createStyles, makeStyles, Theme, Typography } from '@material-ui/core';
|
||||
import { grey, orange, red } from '@material-ui/core/colors';
|
||||
import { Fireworks } from 'fireworks/lib/react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { isDefined } from '../common';
|
||||
import { StateBoard, StateTile } from '../protocol';
|
||||
import { TeamHue, teamSpecs } from '../teams';
|
||||
import { AspectDiv } from './aspectDiv';
|
||||
|
||||
function neutralStyle(revealed: boolean, spymaster: boolean): React.CSSProperties {
|
||||
return {
|
||||
color: revealed ? 'white' : 'black',
|
||||
backgroundColor: grey[revealed ? 500 : 200],
|
||||
fontWeight: spymaster ? 'bold' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function bombStyle(revealed: boolean, spymaster: boolean): React.CSSProperties {
|
||||
return {
|
||||
color: revealed ? 'white' : grey[900],
|
||||
backgroundColor: grey[revealed ? 900 : 700],
|
||||
fontWeight: spymaster ? 'bold' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function teamStyle(teamHue: TeamHue, revealed: boolean, spymaster: boolean): React.CSSProperties {
|
||||
return {
|
||||
color: revealed ? 'white' : teamHue[900],
|
||||
backgroundColor: teamHue[revealed ? 600 : 200],
|
||||
fontWeight: spymaster ? 'bold' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function tileStyle(tile: StateTile, spymaster: boolean): React.CSSProperties {
|
||||
if (!isDefined(tile.view) || tile.view.neutral) {
|
||||
return neutralStyle(tile.revealed, spymaster);
|
||||
}
|
||||
|
||||
if (tile.view.bomb) {
|
||||
return bombStyle(tile.revealed, spymaster);
|
||||
}
|
||||
|
||||
const teamHue = teamSpecs[tile.view.team].hue;
|
||||
return teamStyle(teamHue, tile.revealed, spymaster);
|
||||
}
|
||||
|
||||
const useTileStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
button: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
padding: '6px',
|
||||
},
|
||||
},
|
||||
typo: {
|
||||
wordWrap: 'break-word',
|
||||
width: '100%',
|
||||
fontSize: theme.typography.h6.fontSize,
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
fontSize: theme.typography.button.fontSize,
|
||||
lineHeight: '1rem',
|
||||
},
|
||||
},
|
||||
explosionWrapper: {
|
||||
zIndex: 100,
|
||||
position: 'absolute',
|
||||
margin: 'auto',
|
||||
height: 0,
|
||||
width: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
},
|
||||
explosion: {
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
interface TileProps {
|
||||
tile: StateTile;
|
||||
onClick: () => void;
|
||||
spymaster: boolean;
|
||||
myTurn: boolean;
|
||||
winner: boolean;
|
||||
}
|
||||
|
||||
const Tile = ({ tile, onClick, spymaster, myTurn, winner }: TileProps) => {
|
||||
const classes = useTileStyles();
|
||||
|
||||
const bombRevealed = !!(tile.revealed && tile.view?.bomb);
|
||||
const alreadyExploded = React.useRef(bombRevealed);
|
||||
const explode = bombRevealed && !alreadyExploded.current;
|
||||
|
||||
return (
|
||||
<AspectDiv aspectRatio="75%">
|
||||
<Button
|
||||
type="button"
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={onClick}
|
||||
style={tileStyle(tile, spymaster)}
|
||||
disabled={spymaster || !myTurn || winner}
|
||||
>
|
||||
<Typography variant="h6" className={classes.typo}>
|
||||
{tile.word}
|
||||
</Typography>
|
||||
</Button>
|
||||
{explode ? (
|
||||
<div className={classes.explosionWrapper}>
|
||||
<div className={classes.explosion}>
|
||||
<Fireworks
|
||||
{...{
|
||||
interval: 0,
|
||||
colors: [red[700], orange[800], grey[500]],
|
||||
x: 0,
|
||||
y: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AspectDiv>
|
||||
);
|
||||
};
|
||||
|
||||
export interface BoardProps {
|
||||
words: StateBoard;
|
||||
spymaster: boolean;
|
||||
myTurn: boolean;
|
||||
winner: boolean;
|
||||
onClick: (row: number, col: number) => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
display: 'grid',
|
||||
gridGap: theme.spacing(0.5),
|
||||
[theme.breakpoints.up('lg')]: {
|
||||
gridGap: theme.spacing(1),
|
||||
},
|
||||
gridTemplateRows: (props: BoardProps) => `repeat(${props.words.length}, 1fr)`,
|
||||
gridTemplateColumns: (props: BoardProps) => `repeat(${props.words[0].length}, 1fr)`,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export const Board = (props: BoardProps) => {
|
||||
const classes = useStyles(props);
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
{props.words.map((arr, row) =>
|
||||
arr.map((tile, col) => (
|
||||
<div key={row * props.words.length + col}>
|
||||
<Tile
|
||||
tile={tile}
|
||||
onClick={() => props.onClick(row, col)}
|
||||
spymaster={props.spymaster}
|
||||
myTurn={props.myTurn}
|
||||
winner={props.winner}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,33 @@
|
||||
import { Button, Tooltip } from '@material-ui/core';
|
||||
import copy from 'clipboard-copy';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface ClipboardButtonProps {
|
||||
buttonText: string;
|
||||
toCopy: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ClipboardButton = (props: ClipboardButtonProps) => {
|
||||
const [showTooltip, setShowTooltip] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
open={showTooltip}
|
||||
title="Copied to clipboard."
|
||||
leaveDelay={2000}
|
||||
onClose={() => setShowTooltip(false)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
copy(props.toCopy);
|
||||
setShowTooltip(true);
|
||||
}}
|
||||
startIcon={props.icon}
|
||||
>
|
||||
{props.buttonText}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
@ -0,0 +1,130 @@
|
||||
import { Button, ButtonGroup, createStyles, makeStyles, TextField, Theme } from '@material-ui/core';
|
||||
import * as React from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import { nameofFactory } from '../common';
|
||||
|
||||
export interface LoginFormData {
|
||||
nickname: string;
|
||||
roomName: string;
|
||||
roomPass: string;
|
||||
create: boolean;
|
||||
}
|
||||
|
||||
const formName = nameofFactory<LoginFormData>();
|
||||
|
||||
const noComplete = {
|
||||
autoComplete: 'off',
|
||||
'data-lpignore': 'true',
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
padBottom: {
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export interface LoginFormProps {
|
||||
existingRoom: boolean;
|
||||
onSubmit: (data: LoginFormData) => Promise<void>;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export function LoginForm(props: LoginFormProps) {
|
||||
const classes = useStyles();
|
||||
const { control, handleSubmit, errors, setValue, register } = useForm<LoginFormData>({});
|
||||
React.useEffect(() => register({ name: formName('create') }), [register]);
|
||||
const doSubmit = handleSubmit(props.onSubmit);
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
{props.existingRoom ? (
|
||||
<div>
|
||||
<em>Joining existing game; please choose a nickname.</em>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={props.existingRoom ? classes.padBottom : undefined}>
|
||||
<Controller
|
||||
control={control}
|
||||
as={TextField}
|
||||
name={formName('nickname')}
|
||||
label="Nickname"
|
||||
defaultValue=""
|
||||
error={!!errors.nickname}
|
||||
rules={{ required: true, minLength: 3, maxLength: 16 }}
|
||||
fullWidth={true}
|
||||
inputProps={noComplete}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{props.existingRoom ? null : (
|
||||
<>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
as={TextField}
|
||||
name={formName('roomName')}
|
||||
label="Room name"
|
||||
defaultValue=""
|
||||
error={!!errors.roomName}
|
||||
rules={{ required: true, minLength: 3, maxLength: 16 }}
|
||||
fullWidth={true}
|
||||
inputProps={noComplete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={classes.padBottom}>
|
||||
<Controller
|
||||
control={control}
|
||||
as={TextField}
|
||||
name={formName('roomPass')}
|
||||
label="Password"
|
||||
defaultValue=""
|
||||
type="password"
|
||||
error={!!errors.roomPass}
|
||||
rules={{ required: true, minLength: 1 }}
|
||||
fullWidth={true}
|
||||
inputProps={noComplete}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{props.errorMessage && (
|
||||
<div className={classes.padBottom}>
|
||||
<em>{props.errorMessage}</em>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<ButtonGroup variant="contained">
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
setValue(formName('create'), false);
|
||||
doSubmit();
|
||||
}}
|
||||
>
|
||||
Join game
|
||||
</Button>
|
||||
|
||||
{props.existingRoom ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setValue(formName('create'), true);
|
||||
doSubmit();
|
||||
}}
|
||||
>
|
||||
Create new game
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export interface ServerTime {
|
||||
setOffset: (v: number) => void;
|
||||
now: () => number;
|
||||
}
|
||||
|
||||
const Context = React.createContext<ServerTime>(Object.seal({ setOffset: () => {}, now: Date.now }));
|
||||
|
||||
export const ServerTimeProvider = (props: React.PropsWithChildren<{}>) => {
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const value = React.useMemo(() => Object.seal({ setOffset, now: () => Date.now() + offset }), [offset, setOffset]);
|
||||
return <Context.Provider value={value}>{props.children}</Context.Provider>;
|
||||
};
|
||||
|
||||
export function useServerTime() {
|
||||
return React.useContext(Context);
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
import 'typeface-roboto';
|
||||
|
||||
import { createMuiTheme, CssBaseline, IconButton, responsiveFontSizes, ThemeProvider } from '@material-ui/core';
|
||||
import { Brightness4, Brightness7 } from '@material-ui/icons';
|
||||
import { useLocalStorage } from '@rehooks/local-storage';
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
|
||||
import { App } from './app';
|
||||
import { AboutButton } from './components/about';
|
||||
|
||||
function useTheme() {
|
||||
const [themeName, setThemeName] = useLocalStorage<'light' | 'dark'>('themeName', 'dark');
|
||||
|
||||
// Workaround for https://github.com/mui-org/material-ui/issues/20708.
|
||||
//
|
||||
// When in strict mode (development only), this is required to properly allow
|
||||
// the theme to be changed, as unused styles are not cleaned up. Create a new theme
|
||||
// each time, so that they're forced to be injected again at the end of the existing
|
||||
// block of stylesheets (as the styling library will see them as "new" styles, rather than
|
||||
// assuming they can just be reused).
|
||||
//
|
||||
// This is gross, as it means every time the button is clicked it's a slew of extra
|
||||
// stylesheets (each overriding the previous), but in production the cleanup works
|
||||
// so this extra work is "only" a performance hit. If the bug is ever fixed, we can
|
||||
// simply store two global themes and swap between them.
|
||||
const theme = responsiveFontSizes(
|
||||
createMuiTheme({
|
||||
palette: {
|
||||
type: themeName,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (themeName === 'light') {
|
||||
setThemeName('dark');
|
||||
} else {
|
||||
setThemeName('light');
|
||||
}
|
||||
};
|
||||
|
||||
return { theme, toggleTheme, isDark: themeName === 'dark' };
|
||||
}
|
||||
|
||||
const Root = (_props: {}) => {
|
||||
const { theme, toggleTheme, isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
margin: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<AboutButton style={{ marginRight: '0.5rem' }} />
|
||||
<IconButton size="small" onClick={toggleTheme}>
|
||||
{isDark ? <Brightness7 /> : <Brightness4 />}
|
||||
</IconButton>
|
||||
</div>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.render(<Root />, document.getElementById('root'));
|
@ -0,0 +1,180 @@
|
||||
import { fail } from 'assert';
|
||||
import * as React from 'react';
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { assertIsDefined, assertNever, noop, websocketUrl } from '../common';
|
||||
import { useServerTime } from '../hooks/useServerTime';
|
||||
import { ClientNote, ServerNote, State, StatePlayer, TimeResponse, WordPack } from '../protocol';
|
||||
import { GameView, Sender } from './gameView';
|
||||
import { Loading } from './loading';
|
||||
|
||||
const socketUrl = websocketUrl('/api/ws');
|
||||
|
||||
function useSender(sendNote: (r: ClientNote) => void, version: number): Sender {
|
||||
return React.useMemo<Sender>(() => {
|
||||
return {
|
||||
reveal: (row: number, col: number) =>
|
||||
sendNote({
|
||||
method: 'reveal',
|
||||
version,
|
||||
params: {
|
||||
row,
|
||||
col,
|
||||
},
|
||||
}),
|
||||
newGame: () => sendNote({ method: 'newGame', version, params: {} }),
|
||||
endTurn: () => sendNote({ method: 'endTurn', version, params: {} }),
|
||||
changeNickname: (nickname: string) => sendNote({ method: 'changeNickname', version, params: { nickname } }),
|
||||
changeRole: (spymaster: boolean) => sendNote({ method: 'changeRole', version, params: { spymaster } }),
|
||||
changeTeam: (team: number) => sendNote({ method: 'changeTeam', version, params: { team } }),
|
||||
randomizeTeams: () => sendNote({ method: 'randomizeTeams', version, params: {} }),
|
||||
changePack: (num: number, enable: boolean) =>
|
||||
sendNote({ method: 'changePack', version, params: { num, enable } }),
|
||||
changeTurnMode: (timed: boolean) => sendNote({ method: 'changeTurnMode', version, params: { timed } }),
|
||||
changeTurnTime: (seconds: number) => sendNote({ method: 'changeTurnTime', version, params: { seconds } }),
|
||||
addPacks: (packs: WordPack[]) => sendNote({ method: 'addPacks', version, params: { packs } }),
|
||||
removePack: (num: number) => sendNote({ method: 'removePack', version, params: { num } }),
|
||||
};
|
||||
}, [sendNote, version]);
|
||||
}
|
||||
|
||||
function usePlayer(playerID: string, state?: State): { pState: StatePlayer; pTeam: number } | undefined {
|
||||
return React.useMemo(() => {
|
||||
if (!state) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (let i = 0; i < state.teams.length; i++) {
|
||||
const pState = state.teams[i].find((p) => p.playerID === playerID);
|
||||
if (pState) {
|
||||
return { pState, pTeam: i };
|
||||
}
|
||||
}
|
||||
|
||||
fail('Player not found in any team');
|
||||
}, [playerID, state]);
|
||||
}
|
||||
|
||||
const reconnectAttempts = 5;
|
||||
|
||||
function useWS(roomID: string, playerID: string, nickname: string, dead: () => void, onOpen: () => void) {
|
||||
const didUnmount = React.useRef(false);
|
||||
const retry = React.useRef(0);
|
||||
|
||||
return useWebSocket(socketUrl, {
|
||||
queryParams: { roomID, playerID, nickname },
|
||||
reconnectAttempts,
|
||||
onMessage: () => {
|
||||
retry.current = 0;
|
||||
},
|
||||
onOpen,
|
||||
shouldReconnect: () => {
|
||||
if (didUnmount.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
retry.current++;
|
||||
|
||||
if (retry.current >= reconnectAttempts) {
|
||||
dead();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function syncTime(setOffset: (offset: number) => void) {
|
||||
const fn = async () => {
|
||||
let bestRTT: number | undefined;
|
||||
let offset = 0;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const before = Date.now();
|
||||
const resp = await fetch('/api/time');
|
||||
const after = Date.now();
|
||||
|
||||
const body = await resp.json();
|
||||
if (resp.ok) {
|
||||
const rtt = (after - before) / 2;
|
||||
|
||||
if (bestRTT !== undefined && rtt > bestRTT) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bestRTT = rtt;
|
||||
|
||||
const t = TimeResponse.parse(body);
|
||||
const serverTime = t.time.getTime() + rtt;
|
||||
offset = serverTime - Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
setOffset(offset);
|
||||
};
|
||||
fn().catch(noop);
|
||||
}
|
||||
|
||||
export interface GameProps {
|
||||
roomID: string;
|
||||
nickname: string;
|
||||
leave: () => void;
|
||||
}
|
||||
|
||||
export const Game = (props: GameProps) => {
|
||||
const [playerID] = React.useState(v4);
|
||||
const nickname = React.useRef(props.nickname); // Preserve a nickname for use in reconnects.
|
||||
const [state, setState] = React.useState<State | undefined>();
|
||||
const { setOffset } = useServerTime();
|
||||
|
||||
const { sendJsonMessage, lastJsonMessage } = useWS(props.roomID, playerID, nickname.current, props.leave, () =>
|
||||
syncTime(setOffset)
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
syncTime(setOffset);
|
||||
}, 10 * 60 * 1000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [setOffset]);
|
||||
|
||||
const send = useSender(sendJsonMessage, state?.version ?? 0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!lastJsonMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const note = ServerNote.parse(lastJsonMessage);
|
||||
|
||||
switch (note.method) {
|
||||
case 'state':
|
||||
setState(note.params);
|
||||
break;
|
||||
default:
|
||||
assertNever(note.method);
|
||||
}
|
||||
}, [lastJsonMessage]);
|
||||
|
||||
const player = usePlayer(playerID, state);
|
||||
|
||||
if (!state) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
assertIsDefined(player);
|
||||
nickname.current = player.pState.nickname;
|
||||
|
||||
return (
|
||||
<GameView
|
||||
roomID={props.roomID}
|
||||
leave={props.leave}
|
||||
send={send}
|
||||
state={state}
|
||||
pState={player.pState}
|
||||
pTeam={player.pTeam}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,539 @@
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
createStyles,
|
||||
Grid,
|
||||
IconButton,
|
||||
makeStyles,
|
||||
Paper,
|
||||
Slider,
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { green, orange } from '@material-ui/core/colors';
|
||||
import { Add, ArrowBack, Delete, Link, Person, Search, Timer, TimerOff } from '@material-ui/icons';
|
||||
import { ok as assertTrue } from 'assert';
|
||||
import isArray from 'lodash/isArray';
|
||||
import range from 'lodash/range';
|
||||
import { DropzoneDialog } from 'material-ui-dropzone';
|
||||
import * as React from 'react';
|
||||
|
||||
import { isDefined } from '../common';
|
||||
import { Board } from '../components/board';
|
||||
import { ClipboardButton } from '../components/clipboard';
|
||||
import { useServerTime } from '../hooks/useServerTime';
|
||||
import { State, StatePlayer, StateTimer, WordPack } from '../protocol';
|
||||
import { teamSpecs } from '../teams';
|
||||
|
||||
export interface Sender {
|
||||
reveal: (row: number, col: number) => void;
|
||||
newGame: () => void;
|
||||
endTurn: () => void;
|
||||
changeNickname: (nickname: string) => void;
|
||||
changeRole: (spymaster: boolean) => void;
|
||||
changeTeam: (team: number) => void;
|
||||
randomizeTeams: () => void;
|
||||
changePack: (num: number, enable: boolean) => void;
|
||||
changeTurnMode: (timed: boolean) => void;
|
||||
changeTurnTime: (seconds: number) => void;
|
||||
addPacks: (packs: { name: string; words: string[] }[]) => void;
|
||||
removePack: (num: number) => void;
|
||||
}
|
||||
|
||||
export interface GameViewProps {
|
||||
roomID: string;
|
||||
leave: () => void;
|
||||
send: Sender;
|
||||
state: State;
|
||||
pState: StatePlayer;
|
||||
pTeam: number;
|
||||
}
|
||||
|
||||
const useCenterStyles = makeStyles((_theme: Theme) =>
|
||||
createStyles({
|
||||
blink: {
|
||||
animation: '$blinker 0.5s cubic-bezier(.5, 0, 1, 1) infinite alternate',
|
||||
},
|
||||
'@keyframes blinker': {
|
||||
to: {
|
||||
opacity: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const CenterText = ({ winner, timer, turn }: State) => {
|
||||
const classes = useCenterStyles();
|
||||
const [countdown, setCountdown] = React.useState<number | undefined>();
|
||||
const { now } = useServerTime();
|
||||
|
||||
React.useEffect(() => {
|
||||
const updateCountdown = () => {
|
||||
if (isDefined(winner)) {
|
||||
setCountdown(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDefined(timer)) {
|
||||
if (countdown !== undefined) {
|
||||
setCountdown(undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const deadline = timer.turnEnd;
|
||||
const diff = deadline.getTime() - now();
|
||||
|
||||
const between = Math.floor(diff / 1000);
|
||||
if (between < 0) {
|
||||
if (countdown === 0) {
|
||||
return;
|
||||
}
|
||||
setCountdown(0);
|
||||
} else {
|
||||
setCountdown(between);
|
||||
}
|
||||
};
|
||||
|
||||
updateCountdown();
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
updateCountdown();
|
||||
}, 200);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [countdown, setCountdown, winner, timer, now]);
|
||||
|
||||
const centerText = React.useMemo(() => {
|
||||
const text = isDefined(winner) ? `${teamSpecs[winner].name} wins!` : `${teamSpecs[turn].name}'s turn`;
|
||||
|
||||
if (!isDefined(countdown) || isDefined(winner)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text} [${countdown}s]`;
|
||||
}, [winner, turn, countdown]);
|
||||
|
||||
return (
|
||||
<h1
|
||||
style={{ color: teamSpecs[winner ?? turn].hue[600] }}
|
||||
className={isDefined(countdown) && countdown < 10 ? classes.blink : undefined}
|
||||
>
|
||||
{centerText}
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ send, state, pState, pTeam }: GameViewProps) => {
|
||||
const myTurn = state.turn === pTeam;
|
||||
|
||||
return (
|
||||
<Grid container direction="row" justify="space-between" alignItems="center" spacing={2}>
|
||||
<Grid item xs style={{ textAlign: 'left' }}>
|
||||
<h1>
|
||||
{state.wordsLeft.map((n, team) => {
|
||||
return (
|
||||
<span key={team}>
|
||||
{team !== 0 ? <span> - </span> : null}
|
||||
<span
|
||||
style={{
|
||||
color: teamSpecs[team].hue[600],
|
||||
fontWeight: state.turn === team ? 'bold' : undefined,
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</h1>
|
||||
</Grid>
|
||||
<Grid item xs style={{ textAlign: 'center' }}>
|
||||
<CenterText {...state} />
|
||||
</Grid>
|
||||
<Grid item xs style={{ textAlign: 'right' }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
onClick={() => myTurn && !pState.spymaster && send.endTurn()}
|
||||
disabled={!myTurn || pState.spymaster || isDefined(state.winner)}
|
||||
>
|
||||
End turn
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||