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