Initial commit

This commit is contained in:
zikaeroh 2020-05-23 14:51:11 -07:00
commit 64bc116ebc
67 changed files with 19415 additions and 0 deletions

32
.dockerignore Normal file
View File

@ -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/

22
.gitignore vendored Normal file
View File

@ -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

2
.prettierignore Normal file
View File

@ -0,0 +1,2 @@
**/build/**
**/node_modules/**

15
.prettierrc Normal file
View File

@ -0,0 +1,15 @@
{
"singleQuote": true,
"tabWidth": 4,
"useTabs": false,
"printWidth": 120,
"overrides": [
{
"files": ["*.yml", "*.yaml"],
"options": {
"tabWidth": 2,
"singleQuote": false
}
}
]
}

23
Dockerfile Normal file
View File

@ -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

21
LICENSE Normal file
View File

@ -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.

17
README.md Normal file
View File

@ -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).
![Game board](/docs/screenshot1.png?raw=true 'Game board')

43
codies.code-workspace Normal file
View File

@ -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"
}
}

BIN
docs/screenshot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

2
frontend/.eslintignore Normal file
View File

@ -0,0 +1,2 @@
**/build/**
**/node_modules/**

37
frontend/.eslintrc.json Normal file
View File

@ -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"
}
}
}

23
frontend/.gitignore vendored Normal file
View File

@ -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*

66
frontend/package.json Normal file
View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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>

BIN
frontend/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -0,0 +1,75 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1024.000000pt" height="1024.000000pt" viewBox="0 0 1024.000000 1024.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M4065 9463 c-46 -10 -156 -56 -197 -83 -65 -43 -126 -113 -246 -283
-34 -48 -62 -92 -62 -97 0 -6 -4 -10 -10 -10 -5 0 -10 -5 -10 -11 0 -6 -10
-25 -22 -42 -75 -104 -263 -483 -323 -652 -10 -27 -22 -54 -25 -60 -7 -10 -81
-229 -84 -250 -1 -5 -4 -14 -7 -20 -3 -5 -12 -31 -18 -57 -13 -45 -15 -46 -60
-54 -25 -4 -50 -8 -56 -10 -5 -1 -26 -5 -45 -8 -19 -4 -118 -27 -220 -52 -545
-135 -809 -287 -816 -472 -4 -129 99 -231 347 -341 106 -47 333 -119 457 -146
9 -2 53 -13 97 -24 44 -11 95 -23 113 -26 19 -3 39 -7 45 -8 20 -4 24 -16 26
-90 1 -40 4 -83 5 -97 40 -303 94 -495 209 -730 33 -69 66 -137 73 -151 l12
-26 -507 0 c-444 0 -513 -2 -557 -17 -105 -34 -177 -126 -179 -229 -2 -47 5
-77 35 -150 65 -159 144 -352 279 -676 72 -172 128 -317 126 -321 -3 -5 -38
-30 -78 -56 -193 -127 -422 -337 -547 -503 -14 -18 -30 -38 -36 -44 -28 -31
-156 -233 -203 -321 -103 -193 -183 -410 -216 -584 -2 -13 -6 -31 -9 -40 -10
-35 -23 -142 -33 -272 -8 -117 -9 -924 -1 -1000 24 -214 95 -343 287 -520 48
-45 166 -103 271 -133 l75 -22 3165 0 3165 0 80 23 c306 89 515 334 553 647
14 124 5 1040 -12 1131 -2 10 -7 46 -10 79 -12 104 -83 362 -126 460 -10 22
-31 69 -46 105 -15 36 -38 82 -51 102 -13 21 -23 41 -23 46 0 20 -198 318
-245 368 -5 6 -34 38 -63 72 -71 81 -194 200 -262 253 -30 24 -57 46 -60 49
-3 4 -43 33 -90 65 -47 32 -87 59 -88 61 -4 3 6 32 88 239 28 72 87 225 131
340 44 116 84 219 90 230 18 41 82 212 98 265 41 135 -35 286 -169 334 -46 17
-95 18 -569 19 -472 0 -519 1 -512 16 3 9 28 58 54 108 143 280 216 546 233
848 6 98 11 116 36 122 17 3 97 21 123 27 11 2 40 9 65 16 25 7 56 14 70 16
14 3 34 8 45 11 11 4 25 8 30 9 55 11 252 75 340 111 251 102 375 212 381 337
9 178 -216 327 -696 460 -52 15 -106 29 -120 32 -48 11 -115 25 -140 30 -13 3
-62 14 -107 25 -46 10 -92 19 -102 19 -17 0 -24 13 -37 65 -6 27 -79 246 -94
285 -10 25 -35 88 -56 140 -141 360 -419 823 -593 989 -102 98 -249 148 -404
138 -101 -6 -114 -11 -464 -184 -298 -148 -343 -163 -463 -158 -106 5 -145 19
-424 158 -349 174 -380 186 -500 185 -50 -1 -100 -3 -111 -5z m-485 -2807 c36
-4 76 -8 90 -11 14 -2 50 -7 80 -10 30 -3 69 -7 85 -9 17 -3 68 -7 115 -11 90
-7 110 -9 285 -20 61 -4 128 -8 150 -10 278 -23 1357 -23 1495 0 14 2 81 6
150 10 69 3 136 7 150 9 24 4 156 17 210 21 50 4 166 16 190 21 14 2 52 7 84
10 33 3 64 6 70 7 7 1 12 -33 14 -94 l3 -96 -31 -13 c-31 -13 -62 -49 -73 -85
-20 -71 -96 -254 -149 -359 -68 -136 -159 -234 -264 -285 -73 -36 -69 -34
-164 -56 -69 -16 -177 -13 -227 6 -15 6 -28 10 -28 9 0 -8 -78 22 -154 59
-168 83 -292 232 -372 448 -41 111 -80 143 -172 143 -60 0 -108 -26 -133 -70
-23 -43 -54 -113 -54 -124 0 -15 -106 -198 -132 -227 -104 -118 -157 -161
-262 -210 -73 -34 -138 -47 -222 -43 -48 2 -130 12 -154 19 -54 15 -99 31
-115 42 -11 7 -25 13 -32 13 -16 0 -143 79 -143 88 0 4 -13 21 -28 37 -15 17
-31 37 -35 45 -4 8 -16 29 -26 45 -11 17 -34 59 -51 95 -17 36 -36 74 -42 85
-16 29 -98 247 -98 261 0 14 -75 84 -90 84 -6 0 -10 36 -10 88 0 84 4 107 19
98 3 -3 35 -7 71 -10z m61 -2111 c10 -5 70 -44 134 -85 210 -136 309 -200 390
-251 44 -28 124 -79 178 -114 l97 -63 203 -338 c192 -321 202 -341 195 -374
-10 -41 -10 -41 -38 -145 -12 -44 -23 -87 -25 -95 -2 -8 -27 -100 -55 -205
-28 -104 -53 -197 -55 -205 -4 -20 -51 -196 -128 -485 -61 -228 -79 -297 -78
-302 0 -2 -5 -17 -10 -35 -6 -18 -41 -146 -77 -284 l-67 -252 -43 202 c-24
110 -50 228 -57 261 -22 95 -66 298 -71 325 -5 25 -116 535 -180 825 -20 88
-38 169 -40 180 -2 11 -15 72 -29 135 -20 90 -88 399 -110 501 -2 9 -13 61
-25 115 -12 55 -24 110 -26 124 -3 13 -14 61 -24 106 -11 45 -22 97 -26 116
-3 18 -7 38 -8 43 -1 6 -6 26 -10 45 -4 19 -16 71 -26 115 -10 44 -21 92 -24
108 -3 15 -8 37 -12 50 -6 20 -5 21 11 7 9 -8 25 -19 36 -25z m2982 -77 c-30
-140 -27 -126 -53 -244 -12 -55 -24 -110 -26 -124 -3 -14 -11 -52 -19 -85 -13
-54 -39 -172 -51 -234 -3 -12 -12 -53 -20 -90 -9 -36 -18 -77 -21 -91 -6 -37
-92 -431 -100 -460 -3 -14 -8 -34 -10 -45 -3 -18 -14 -68 -73 -335 -12 -52
-23 -105 -26 -118 -3 -13 -16 -76 -30 -140 -14 -64 -27 -124 -29 -133 -2 -8
-26 -121 -55 -250 -28 -129 -54 -245 -56 -259 -3 -14 -10 -45 -15 -70 -6 -25
-13 -56 -15 -70 -3 -14 -11 -53 -19 -86 -8 -34 -16 -74 -19 -88 -3 -14 -7 -34
-10 -44 -3 -9 -7 -28 -10 -42 -3 -14 -11 -54 -18 -90 -11 -57 -13 -61 -19 -35
-3 17 -16 66 -28 110 -12 44 -24 87 -26 95 -2 8 -13 50 -24 92 -12 43 -30 113
-41 155 -12 43 -25 92 -31 109 -5 17 -11 41 -14 52 -3 20 -13 56 -70 267 -19
67 -35 131 -40 158 -2 10 -8 32 -14 50 -12 37 -28 97 -70 257 -17 63 -49 185
-72 270 -22 85 -43 162 -45 170 -2 8 -16 64 -32 124 l-29 109 126 211 c70 116
162 270 205 341 l79 130 304 195 c231 148 485 313 539 349 0 1 -10 -50 -23
-111z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -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"
}

10
frontend/src/app.test.tsx Normal file
View File

@ -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();
});

32
frontend/src/app.tsx Normal file
View File

@ -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) })}
/>
);
};

View File

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

View File

@ -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, &quot;animal 3&quot;).
Their teammates then try to guess the words while avoiding the opposing team&apos;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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

72
frontend/src/index.tsx Normal file
View File

@ -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'));

180
frontend/src/pages/game.tsx Normal file
View File

@ -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}
/>
);
};

View File

@ -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