Merge pull request #75 from AlecM33/develop

Initial merging of new codebase
This commit is contained in:
Alec Maier
2022-01-11 20:39:21 -05:00
committed by GitHub
169 changed files with 13033 additions and 13957 deletions

28
.eslintrc.json Normal file
View File

@@ -0,0 +1,28 @@
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"standard"
],
"ignorePatterns": ["/client/dist/*", "client/certs/*", "client/favicon_package/*", "client/webpack/*", "node_modules/*"],
"parser": "@babel/eslint-parser",
"parserOptions": {
"requireConfigFile": false,
"ecmaVersion": 12,
"sourceType": "module"
},
"rules": {
"indent": ["error", 4, { "SwitchCase": 1 }],
"semi": [2, "always"],
"operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before", "&&": "before", "||": "before", "+": "after" } }],
"no-void": ["error", { "allowAsStatement": true }],
"no-prototype-builtins": "off",
"no-undef": "off",
"no-return-assign": "warn",
"prefer-promise-reject-errors": "warn",
"no-trailing-spaces": "off"
}
}

17
.gcloudignore Normal file
View File

@@ -0,0 +1,17 @@
# This file specifies files that are *not* uploaded to Google Cloud
# using gcloud. It follows the same syntax as .gitignore, with the addition of
# "#!include" directives (which insert the entries of the given .gitignore-style
# file at that point).
#
# For more information, run:
# $ gcloud topic gcloudignore
#
.gcloudignore
# If you would like to upload your .git directory, .gitignore file or files
# from your .gitignore file, remove the corresponding line
# below:
.git
.gitignore
# Node.js dependencies:
node_modules/

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
.idea
node_modules/*
client/certs/
client/dist/
app.yaml
.vscode/launch.json

View File

@@ -1 +1 @@
web: node server.js
web: node main.js

100
README.md
View File

@@ -1,49 +1,83 @@
<img alt="Werewolf" src="/assets/images/roles-small/wolf_logo.png" />
An application to run games of <a href="https://en.wikipedia.org/wiki/Mafia_(party_game)">Werewolf (Mafia)</a>
smoothly when you don't have a deck, or when you and your friends are together virtually. Inspired by my time playing
<a href="https://boardgamegeek.com/boardgame/152242/ultimate-werewolf-deluxe-edition">Ultimate Werewolf</a> and by
2020's quarantine. The app is free to use and anonymous.
This app is still in active development. The latest deployment can be found <a href="https://play-werewolf.herokuapp.com">here</a>
After a long hiatus from maintaining the application, I have come back and undertaken a large-scale redesign, rewriting
most of the code and producing a result that I believe is more stable and has much more sensible client-server interaction.
A Werewolf utility that provides the tools to run games smoothly in the absence of a deck, or in any context in which traditional moderation is hindered.
![player](./client/src/images/screenshots/player.PNG)
This is a Javascript application running on a node express server. I am using the socket.io package as a wrapper for Javascript Websocket. This was built from scratch as a learning project; I do not claim it as a shining example of socket programming or web app design in general. I welcome collaboration and suggestions for improvements.
## Features
All pixel art is my own (for better or for worse).
This is meant to facilitate a game through a shared game state and various utilities - not to control
every aspect of the game flow. The app provides a host the ability to construct a deck with a custom distribution
of roles. Players can join a game with one click and are then dealt a role to their device. The app features a concealable
role card, an optional shared timer (which the moderator can play/pause), a reference for roles in the game, and status
information for players including who is alive/dead and who has had their role revealed. The app also provides the
option for a "dedicated moderator" or a "temporary moderator." Dedicated moderators will never be dealt in, and will
have all controls and information from the beginning of the game. Temporary moderators _will_ be dealt a role and will
have some moderator powers, but will only exist until the first player is out, at which point that player will be made
the game's dedicated moderator. A dedicated moderator can transfer their powers to another player that is out of the
game at any time.
This is meant to facilitate the game in a face-to-face social setting and provide utility/convenience - not control all aspects of the game flow. The app allows players to create or join a game lobby where state is synchronized. The creator of the game can build a deck from either the standard set of provided cards, or from any number of custom cards the user creates. Once the game begins, this deck will be randomly dealt to all participants.
The application prioritizes responsiveness. A key scenario would be when a group is hanging out with only their phones.
Players will see their card (which can be flipped up and down), an optional timer, and a button to say that they have been killed off. If a player presses the button, they will be removed from the game, and their role revealed to other players. The game will continue until the end of the game is detected, or the timer expires.
## Tech Stack
To learn more about this type of game, see the Wikipedia entry on the game's ancestor, <a href="https://en.wikipedia.org/wiki/Mafia_(party_game)">Mafia</a>.
This is a Node.js application. It is written purely using JavaScript/HTML/CSS. The main dependencies are
<a href="https://expressjs.com/">Express.js</a> and <a href="https://socket.io/">Socket.io</a>. It is fully open-source
and under the MIT license. This was (and still is) fundamentally a learning project, and thus I welcome collaboration
and feedback of any kind.
<br>
<div>
<img alt="home" width="200" src="/assets/images/screenshots/home.PNG" />
<img alt="create" width="200" src="/assets/images/screenshots/create.PNG" />
<img alt="lobby" width="200" src="/assets/images/screenshots/lobby.PNG" />
</div>
<br>
<div>
<img alt="game" width="200" src="/assets/images/screenshots/game.PNG" />
<img alt="killed" width="200" src="/assets/images/screenshots/killed.PNG" />
<img alt="hunter" width="200" src="/assets/images/screenshots/hunter.PNG" />
</div>
<br>
<br>
<br>
All pixel art is my own, for better or for worse.
# Run Locally
## Contributing and Developers' Guide
Run `npm install` from the root directory.
### Running Locally
Run `node server.js` from the root directory, navigate to **localhost:5000**
If you haven't already, install <a href="https://nodejs.org/en/">Node.js.</a> This should include the node package
manager, <a href="https://www.npmjs.com/">npm</a>.
# Testing/Debugging
Run `npm install` from the root directory to install the necessary dependencies.
Use `npm run test` to run unit tests using <a href='https://jasmine.github.io/'>Jasmine</a> (test coverage is barebones and is currently being expanded)
<br><br>
To turn on logging at the debug level, add the `debug` argument like so:
These instructions assume you are somewhat familiar with Node.js and npm. At this point, we will use some of the run
commands defined in `package.json`.
`node server.js -- debug`
First, start a terminal in the root directory. Execute `npm run build:dev`. This uses <a href="https://webpack.js.org/">
Webpack</a> to bundle javascript from the `client/src` directory and place it in the `client/dist` directory, which is ignored by Git.
If you look at this command as defined in `package.json`, it uses the `--watch` flag, which means the process will continue
to run in this terminal, watching for changes to JavaScript within the `client/src` directory and re-bundling automatically. You
definitely want this if making frequent JavaScript changes to client-side source code. Any other changes, such as to HTML or CSS
files, are not bundled, and thus your changes will be picked up simply by refreshing the browser.
# Contributing
Next, in a separate terminal, we will start the application:
Contributions of any kind are welcome. Simply open an issue or pull request and I can respond accordingly.
`npm run start:dev` (if developing on a linux machine)<br>
`npm run start:dev:windows` (if developing on a windows machine)
This will start the application and serve it on the default port of **5000**. This command uses <a href="https://www.npmjs.com/package/nodemon">nodemon</a>
to listen for changes to **server-side code** (Node.js modules) and automatically restart the server. If you do not want
this, run instead `npm run start:dev:no-hot-reload` or `npm run start:dev:windows:no-hot-reload`.
And there we go! You should be able to navigate to and use the application on localhost. There are additional CLI arguments
you can provide to the run commands that specify things such as port, HTTP vs HTTPS, or the log level. I **highly recommend**
consulting these below.
### CLI Options
These options will be at the end of your run command following two dashes e.g. `npm run start:dev -- [options]`.
Options are whitespace-delimited key-value pairs with the syntax `[key]=[value]` e.g. `port=4242`. Options include:
- `port`. Specify an integer port for the application.
- `loglevel` the log level for the application. Can be `info`, `error`, `warn`, `debug`, or `trace`.
- `protocol` either HTTP or HTTPS. If you specify HTTPS, the server will look in `client/certs` for localhost certificates
before serving the application over HTTPS - otherwise it will revert to HTTP. Using HTTPS is particularly useful if you
want to make the application public on your home network, which would allow you to test it on your mobile device. **Careful -
I had to disable my computer's firewall for this to work, which would of course make browsing the internet much riskier.**
## Testing
Unit tests are written using <a href="https://jasmine.github.io/">Jasmine</a>. Execute them by running `npm run test`.
They reside in the `spec/unit` directory, which maps 1:1 to the application directory structure - i.e. unit tests for
`server/modules/GameManager` are found in `spec/unit/server/modules/GameManager_Spec.js`

View File

@@ -1,2 +0,0 @@
theme: jekyll-theme-minimal
logo: /assets/images/roles-small/wolf_logo.png

19
app.yaml Normal file
View File

@@ -0,0 +1,19 @@
runtime: nodejs
env: flex
network:
session_affinity: true
liveness_check:
path: "/liveness_check"
check_interval_sec: 60
timeout_sec: 4
failure_threshold: 2
success_threshold: 2
readiness_check:
path: "/readiness_check"
check_interval_sec: 60
timeout_sec: 4
failure_threshold: 2
success_threshold: 2
app_start_timeout_sec: 600
manual_scaling:
instances: 1

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,90 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="123.22025mm"
height="123.59821mm"
viewBox="0 0 123.22025 123.59821"
version="1.1"
id="svg8"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="gallery.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.35"
inkscape:cx="-228.27896"
inkscape:cy="141.42857"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="1027"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-43.389879,-62.654773)">
<g
id="g848"
transform="translate(8.2381001,13.607143)"
style="stroke:none">
<rect
y="49.04763"
x="35.151779"
height="53.672619"
width="53.672619"
id="rect815"
style="opacity:0.95999995;fill:whitesmoke;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" />
<rect
y="49.04763"
x="104.6994"
height="53.672619"
width="53.672619"
id="rect815-7"
style="opacity:0.95999995;fill:whitesmoke;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" />
<rect
y="118.97321"
x="35.151779"
height="53.672619"
width="53.672619"
id="rect815-1"
style="opacity:0.95999995;fill:whitesmoke;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" />
<rect
y="118.97321"
x="104.6994"
height="53.672619"
width="53.672619"
id="rect815-5"
style="opacity:0.95999995;fill:whitesmoke;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-upload"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>

Before

Width:  |  Height:  |  Size: 344 B

View File

@@ -1,14 +0,0 @@
<svg width="223" height="223" xmlns="http://www.w3.org/2000/svg">
<!-- Created with Method Draw - http://github.com/duopixel/Method-Draw/ -->
<g>
<title>background</title>
<rect fill="none" id="canvas_background" height="225" width="225" y="-1" x="-1"/>
<g display="none" overflow="visible" y="0" x="0" height="100%" width="100%" id="canvasGrid">
<rect fill="url(#gridpattern)" stroke-width="0" y="0" x="0" height="100%" width="100%"/>
</g>
</g>
<g>
<title>Layer 1</title>
<path id="svg_1" d="m111.499982,3.500023c-59.646658,0 -107.999967,48.353386 -107.999967,107.999967c0,59.647903 48.353308,107.999993 107.999967,107.999993c59.647946,0 107.999993,-48.352082 107.999993,-107.999993c0,-59.646581 -48.352047,-107.999967 -107.999993,-107.999967zm-9.236728,48.553238l17.983007,0l0,19.128161l-17.983007,0l0,-19.128161zm29.760957,116.085726l-19.780144,0c-7.684691,0 -10.961591,-3.269729 -10.961591,-11.117474l0,-51.012233c0,-2.452006 -1.306774,-3.597168 -3.595768,-3.597168l-6.539459,0l0,-17.662679l19.781535,0c7.690393,0 10.953072,3.432714 10.953072,11.116049l0,51.176601c0,2.296157 1.306782,3.597237 3.595768,3.597237l6.539537,0l0,17.499659l0.00705,0l0,0.000009z" stroke-width="1.5" stroke="#000" fill="#bbb8b8"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,109 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="123.22025mm"
height="123.59821mm"
viewBox="0 0 123.22025 123.59821"
version="1.1"
id="svg8"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="list.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.35"
inkscape:cx="-228.27896"
inkscape:cy="141.42857"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="1027"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-43.389879,-62.654773)">
<circle
style="opacity:0.95999995;fill:whitesmoke;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="path875"
cx="58.697918"
cy="82.385117"
r="5.2916665" />
<circle
style="opacity:0.95999995;fill:whitesmoke;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="path875-4"
cx="58.697918"
cy="111.11131"
r="5.2916665" />
<circle
style="opacity:0.95999995;fill:whitesmoke;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="path875-8"
cx="58.697918"
cy="139.23279"
r="5.2916665" />
<circle
style="opacity:0.95999995;fill:whitesmoke;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="path875-6"
cx="58.697918"
cy="166.52264"
r="5.2916665" />
<rect
style="opacity:0.95999995;fill:whitesmoke;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="rect904"
width="85.422623"
height="10.583333"
x="71.171135"
y="77.093452" />
<rect
style="opacity:0.95999995;fill:whitesmoke;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="rect904-4"
width="85.422623"
height="10.583333"
x="71.171135"
y="161.23097" />
<rect
style="opacity:0.95999995;fill:whitesmoke;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="rect904-43"
width="85.422623"
height="10.583333"
x="71.171135"
y="133.94112" />
<rect
style="opacity:0.95999995;fill:whitesmoke;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="rect904-3"
width="85.422623"
height="10.583333"
x="71.171135"
y="105.81964" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,8 +0,0 @@
<svg width="263" height="271" xmlns="http://www.w3.org/2000/svg">
<g>
<title>Layer 1</title>
<ellipse stroke="gray" ry="131" rx="126" id="svg_5" fill="none" cy="135.237498" cx="131.999999" stroke-opacity="null" stroke-width="8"/>
<rect stroke="#7d0b0b" id="svg_1" height="123.000006" width="41" y="73.737494" x="77.5" stroke-width="0" fill="gray"/>
<rect stroke="#7d0b0b" id="svg_3" height="123.000006" width="41" y="73.737494" x="144.5" stroke-width="0" fill="gray"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 491 B

View File

@@ -1,14 +0,0 @@
<svg width="172" height="172" xmlns="http://www.w3.org/2000/svg">
<!-- Created with Method Draw - http://github.com/duopixel/Method-Draw/ -->
<g>
<title>background</title>
<rect fill="none" id="canvas_background" height="174" width="174" y="-1" x="-1"/>
<g display="none" overflow="visible" y="0" x="0" height="100%" width="100%" id="canvasGrid">
<rect fill="url(#gridpattern)" stroke-width="0" y="0" x="0" height="100%" width="100%"/>
</g>
</g>
<g>
<title>Layer 1</title>
<path id="svg_1" d="m139.382578,14.249727l-18.982996,-11.931355c-4.78947,-3.007365 -11.100429,-1.559921 -14.118291,3.230549l-7.482272,11.909092l36.315275,22.808817l7.488173,-11.903564c3.006409,-4.796172 1.57156,-11.112875 -3.219889,-14.113539l0,0zm-105.206717,106.097424l36.317254,22.808582l59.191104,-94.22748l-36.334543,-22.814538l-59.173821,94.233429l0.000006,0.000006zm-5.547453,28.979153l-0.802118,21.42371l18.953876,-10.02532l17.613535,-9.299898l-35.036418,-22.018086l-0.728868,19.9196l0,0l-0.000006,-0.000006z" stroke-width="1.5" stroke="none" fill="green"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,14 +0,0 @@
<svg width="232" height="232" xmlns="http://www.w3.org/2000/svg">
<!-- Created with Method Draw - http://github.com/duopixel/Method-Draw/ -->
<g>
<title>background</title>
<rect fill="none" id="canvas_background" height="234" width="234" y="-1" x="-1"/>
<g display="none" overflow="visible" y="0" x="0" height="100%" width="100%" id="canvasGrid">
<rect fill="url(#gridpattern)" stroke-width="0" y="0" x="0" height="100%" width="100%"/>
</g>
</g>
<g>
<title>Layer 1</title>
<path id="svg_2" d="m116.249977,0.749996c-63.791862,0 -115.499977,51.711165 -115.499977,115.500022c0,63.792136 51.708115,115.499968 115.499977,115.499968c63.788876,0 115.500013,-51.707832 115.500013,-115.499968c0,-63.788858 -51.711128,-115.500022 -115.500013,-115.500022zm10.552801,182.023345l-21.511705,0l0,-20.629285l21.511705,0l0,20.629285zm0,-42.656193l0,6.817814l-21.511705,0l0,-8.400447c0,-25.349282 28.847653,-29.371968 28.847653,-47.388589c0,-8.215218 -7.345234,-14.508297 -16.96127,-14.508297c-9.964527,0 -18.704243,7.34212 -18.704243,7.34212l-12.247236,-15.218234c0,0 12.071466,-12.589655 32.876448,-12.589655c19.762633,0 38.112195,12.238096 38.112195,32.870404c0.009222,28.862919 -30.411843,32.191492 -30.411843,51.074874l0,0.000009z" stroke-width="3" stroke="whitesmoke" fill="none"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 753 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 871 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 824 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1012 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,54 @@
<?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="380.000000pt" height="380.000000pt" viewBox="0 0 380.000000 380.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,380.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1790 3160 c0 -18 -7 -20 -60 -20 -53 0 -60 -2 -60 -20 0 -18 -7 -20
-80 -20 -73 0 -80 -2 -80 -20 0 -18 -7 -20 -60 -20 -53 0 -60 -2 -60 -20 0
-17 -7 -20 -40 -20 -33 0 -40 -3 -40 -20 0 -17 -7 -20 -40 -20 l-40 0 0 -40 0
-40 -40 0 c-33 0 -40 3 -40 20 0 18 -7 20 -60 20 -53 0 -60 2 -60 20 0 18 -7
20 -60 20 -53 0 -60 2 -60 20 0 18 -7 20 -60 20 -53 0 -60 2 -60 20 0 13 -7
20 -20 20 -13 0 -20 7 -20 20 0 18 -7 20 -60 20 -53 0 -60 -2 -60 -20 0 -17
-7 -20 -40 -20 l-40 0 0 -160 c0 -153 1 -160 20 -160 13 0 20 -7 20 -20 0 -13
7 -20 20 -20 18 0 20 -7 20 -60 0 -53 2 -60 20 -60 17 0 20 -7 20 -40 0 -33 3
-40 20 -40 17 0 20 -7 20 -40 0 -33 3 -40 20 -40 17 0 20 -7 20 -40 0 -33 -3
-40 -20 -40 -13 0 -20 -7 -20 -20 0 -13 -7 -20 -20 -20 -18 0 -20 -7 -20 -80
0 -73 -2 -80 -20 -80 -19 0 -20 -7 -20 -160 0 -153 1 -160 20 -160 19 0 20 -7
20 -100 0 -93 1 -100 20 -100 19 0 20 -7 20 -140 0 -133 1 -140 20 -140 13 0
20 -7 20 -20 0 -13 7 -20 20 -20 17 0 20 -7 20 -40 l0 -40 40 0 c33 0 40 -3
40 -20 0 -13 7 -20 20 -20 13 0 20 -7 20 -20 0 -13 7 -20 20 -20 13 0 20 -7
20 -20 0 -17 7 -20 40 -20 33 0 40 -3 40 -20 0 -18 7 -20 80 -20 l80 0 0 -40
c0 -33 3 -40 20 -40 17 0 20 -7 20 -40 l0 -40 80 0 80 0 0 -40 0 -40 40 0 40
0 0 -40 c0 -33 3 -40 20 -40 13 0 20 -7 20 -20 0 -13 7 -20 20 -20 13 0 20 -7
20 -20 0 -18 7 -20 60 -20 53 0 60 -2 60 -20 0 -13 7 -20 20 -20 17 0 20 -7
20 -40 l0 -40 40 0 c33 0 40 -3 40 -20 0 -13 7 -20 20 -20 17 0 20 -7 20 -40
0 -33 3 -40 20 -40 13 0 20 -7 20 -20 0 -17 7 -20 40 -20 l40 0 0 40 0 40 40
0 40 0 0 40 0 40 40 0 c33 0 40 3 40 20 0 17 7 20 40 20 l40 0 0 40 0 40 40 0
c33 0 40 3 40 20 0 13 7 20 20 20 13 0 20 7 20 20 0 18 7 20 60 20 53 0 60 2
60 20 0 13 7 20 20 20 17 0 20 7 20 40 l0 40 40 0 c33 0 40 3 40 20 0 13 7 20
20 20 13 0 20 7 20 20 0 18 7 20 60 20 53 0 60 2 60 20 0 13 7 20 20 20 13 0
20 7 20 20 0 13 7 20 20 20 13 0 20 7 20 20 0 17 7 20 40 20 33 0 40 3 40 20
0 13 7 20 20 20 18 0 20 7 20 60 0 53 2 60 20 60 17 0 20 7 20 40 0 33 3 40
20 40 13 0 20 7 20 20 0 13 7 20 20 20 17 0 20 7 20 40 0 33 3 40 20 40 18 0
20 7 20 80 0 73 2 80 20 80 19 0 20 7 20 100 0 93 1 100 20 100 13 0 20 7 20
20 0 13 7 20 20 20 19 0 20 7 20 120 0 113 -1 120 -20 120 -19 0 -20 7 -20
100 0 93 -1 100 -20 100 -17 0 -20 7 -20 40 l0 40 -40 0 c-33 0 -40 3 -40 20
0 13 7 20 20 20 13 0 20 7 20 20 0 13 7 20 20 20 17 0 20 7 20 40 0 33 3 40
20 40 13 0 20 7 20 20 0 13 7 20 20 20 17 0 20 7 20 40 0 33 3 40 20 40 17 0
20 7 20 40 0 33 3 40 20 40 19 0 20 7 20 120 0 113 -1 120 -20 120 -18 0 -20
7 -20 60 0 53 -2 60 -20 60 -13 0 -20 7 -20 20 0 18 -7 20 -80 20 -73 0 -80
-2 -80 -20 0 -17 -7 -20 -40 -20 -33 0 -40 -3 -40 -20 0 -17 -7 -20 -40 -20
-33 0 -40 -3 -40 -20 0 -17 -7 -20 -40 -20 -33 0 -40 -3 -40 -20 0 -18 -7 -20
-60 -20 -53 0 -60 -2 -60 -20 0 -18 -7 -20 -80 -20 l-80 0 0 40 0 40 -40 0
c-33 0 -40 3 -40 20 0 17 -7 20 -40 20 -33 0 -40 3 -40 20 0 13 -7 20 -20 20
-13 0 -20 7 -20 20 0 13 -7 20 -20 20 -13 0 -20 7 -20 20 0 18 -7 20 -80 20
-73 0 -80 2 -80 20 0 18 -7 20 -60 20 -53 0 -60 -2 -60 -20 0 -17 -7 -20 -40
-20 -33 0 -40 3 -40 20 0 18 -7 20 -60 20 -53 0 -60 -2 -60 -20z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-256x256.png",
"sizes": "256x256",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

2
client/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /api/

View File

@@ -0,0 +1,47 @@
export const defaultCards = [
{
role: 'Villager',
team: 'good',
description: 'During the day, find the wolves and kill them.'
},
{
role: 'Werewolf',
team: 'evil',
description: "During the night, choose a villager to kill. Don't get killed."
},
{
role: 'Dream Wolf',
team: 'evil',
description: "You are a Werewolf, but you don't wake up with the other Werewolves until one of them dies."
},
{
role: 'Sorceress',
team: 'evil',
description: 'Each night, learn if a chosen person is the Seer.'
},
{
role: 'Minion',
team: 'evil',
description: 'You are an evil Villager, and you know who the Werewolves are.'
},
{
role: 'Blind Minion',
team: 'evil',
description: "You are an evil villager, but you don't know who the Werewolves are."
},
{
role: 'Seer',
team: 'good',
description: 'Each night, learn if a chosen person is a Werewolf.'
},
{
role: 'Parity Hunter',
team: 'good',
description: 'You beat a werewolf in a 1v1 situation, winning the game for the village.'
},
{
role: 'Hunter',
team: 'good',
description: 'When you are eliminated, choose another player to go with you.'
}
];

View File

@@ -0,0 +1,64 @@
export const globals = {
USER_SIGNATURE_LENGTH: 25,
CLOCK_TICK_INTERVAL_MILLIS: 10,
MAX_CUSTOM_ROLE_NAME_LENGTH: 30,
MAX_CUSTOM_ROLE_DESCRIPTION_LENGTH: 500,
TOAST_DURATION_DEFAULT: 6,
ACCESS_CODE_LENGTH: 6,
PLAYER_ID_COOKIE_KEY: 'play-werewolf-anon-id',
ACCESS_CODE_CHAR_POOL: 'abcdefghijklmnopqrstuvwxyz0123456789',
COMMANDS: {
FETCH_GAME_STATE: 'fetchGameState',
GET_ENVIRONMENT: 'getEnvironment',
START_GAME: 'startGame',
PAUSE_TIMER: 'pauseTimer',
RESUME_TIMER: 'resumeTimer',
GET_TIME_REMAINING: 'getTimeRemaining',
KILL_PLAYER: 'killPlayer',
REVEAL_PLAYER: 'revealPlayer',
TRANSFER_MODERATOR: 'transferModerator',
CHANGE_NAME: 'changeName',
END_GAME: 'endGame',
FETCH_IN_PROGRESS_STATE: 'fetchInitialInProgressState'
},
STATUS: {
LOBBY: 'lobby',
IN_PROGRESS: 'in progress',
ENDED: 'ended'
},
ALIGNMENT: {
GOOD: 'good',
EVIL: 'evil'
},
MESSAGES: {
ENTER_NAME: 'Client must enter name.'
},
EVENTS: {
PLAYER_JOINED: 'playerJoined',
SYNC_GAME_STATE: 'syncGameState',
START_TIMER: 'startTimer',
KILL_PLAYER: 'killPlayer',
REVEAL_PLAYER: 'revealPlayer',
CHANGE_NAME: 'changeName',
START_GAME: 'startGame',
PLAYER_LEFT: 'playerLeft'
},
USER_TYPES: {
MODERATOR: 'moderator',
PLAYER: 'player',
TEMPORARY_MODERATOR: 'player / temp mod',
KILLED_PLAYER: 'killed',
SPECTATOR: 'spectator'
},
ENVIRONMENT: {
LOCAL: 'local',
PRODUCTION: 'production'
},
USER_TYPE_ICONS: {
player: ' \uD83C\uDFAE',
moderator: ' \uD83D\uDC51',
'player / temp mod': ' \uD83C\uDFAE\uD83D\uDC51',
spectator: ' \uD83D\uDC7B',
killed: '\uD83D\uDC80'
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="#00a718" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="384px" height="384px"><path d="M 12 2 C 6.4889971 2 2 6.4889971 2 12 C 2 17.511003 6.4889971 22 12 22 C 17.511003 22 22 17.511003 22 12 C 22 6.4889971 17.511003 2 12 2 z M 12 4 C 16.430123 4 20 7.5698774 20 12 C 20 16.430123 16.430123 20 12 20 C 7.5698774 20 4 16.430123 4 12 C 4 7.5698774 7.5698774 4 12 4 z M 11 7 L 11 11 L 7 11 L 7 13 L 11 13 L 11 17 L 13 17 L 13 13 L 17 13 L 17 11 L 13 11 L 13 7 L 11 7 z"/></svg>

After

Width:  |  Height:  |  Size: 502 B

View File

@@ -0,0 +1,14 @@
<svg width="158" height="155" xmlns="http://www.w3.org/2000/svg">
<!-- Created with Method Draw - http://github.com/duopixel/Method-Draw/ -->
<g>
<title>background</title>
<rect fill="none" id="canvas_background" height="157" width="160" y="-1" x="-1"/>
<g display="none" id="canvasGrid">
<rect fill="none" stroke-width="0" y="0" x="0" height="100%" width="100%"/>
</g>
</g>
<g>
<title>Layer 1</title>
<path stroke="none" id="svg_1" d="m78.750021,0.749998c-43.078721,0 -78.000016,34.473574 -78.000016,76.999988c0,42.526414 34.921295,76.999988 78.000016,76.999988c43.078721,0 78.000016,-34.473574 78.000016,-76.999988c0,-42.526414 -34.921295,-76.999988 -78.000016,-76.999988zm0,138.100913c-34.137753,0 -61.899834,-27.411445 -61.899834,-61.100926c0,-33.694779 27.762081,-61.106224 61.899834,-61.106224c34.137753,0 61.894467,27.406147 61.894467,61.106224c0,33.700077 -27.767448,61.100926 -61.894467,61.100926zm-0.005367,-118.71582c-2.9678,0 -5.361361,2.373469 -5.361361,5.297921l0,48.852132l-32.447234,14.007704c-2.715564,1.176139 -3.949911,4.301912 -2.758498,6.98266c0.880143,1.98672 2.844366,3.173455 4.910556,3.173455c0.719141,0 1.454383,-0.143044 2.152058,-0.450323l35.559936,-15.353376c0.026834,-0.010596 0.048301,-0.021192 0.069767,-0.031788l0.021467,-0.010596c0.080501,-0.031788 0.123435,-0.105958 0.198569,-0.132448c0.55814,-0.275492 1.078712,-0.598665 1.497317,-1.033095c0.182469,-0.180129 0.284437,-0.413238 0.423971,-0.619857c0.257603,-0.339067 0.542039,-0.672836 0.697675,-1.080776c0.128801,-0.317875 0.139535,-0.66224 0.203936,-1.001307c0.069767,-0.344365 0.203936,-0.646346 0.203936,-0.990711l0,-52.316972c0,-2.919155 -2.404294,-5.292623 -5.372094,-5.292623z" stroke-width="1.5" fill="#d7d7d7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

210
client/src/images/copy.svg Normal file
View File

@@ -0,0 +1,210 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="121.00714mm"
height="144.81964mm"
viewBox="0 0 121.00714 144.81964"
version="1.1"
id="svg8"
inkscape:version="1.0.2-2 (e86c870879, 2021-01-15)"
sodipodi:docname="copy.svg">
<defs
id="defs2">
<inkscape:path-effect
effect="powerclip"
id="path-effect951"
is_visible="true"
lpeversion="1"
inverse="true"
flatten="false"
hide_clip="false"
message="Use fill-rule evenodd on &lt;b&gt;fill and stroke&lt;/b&gt; dialog if no flatten result after convert clip to paths." />
<inkscape:path-effect
effect="powerclip"
id="path-effect937"
is_visible="true"
lpeversion="1"
inverse="true"
flatten="false"
hide_clip="false"
message="Use fill-rule evenodd on &lt;b&gt;fill and stroke&lt;/b&gt; dialog if no flatten result after convert clip to paths." />
<inkscape:path-effect
effect="powerclip"
id="path-effect923"
is_visible="true"
lpeversion="1"
inverse="true"
flatten="false"
hide_clip="false"
message="Use fill-rule evenodd on &lt;b&gt;fill and stroke&lt;/b&gt; dialog if no flatten result after convert clip to paths." />
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath913">
<g
id="g921"
style="display:none">
<path
id="path915"
style="opacity:0.996;fill:#f9f9f9;fill-opacity:1;stroke:whitesmoke;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 109.61309,67.279762 h 52.91667 c 2.51278,0 4.53571,2.022929 4.53571,4.535714 0,2.512786 -2.02293,4.535715 -4.53571,4.535715 h -52.91667 c -2.51278,0 -4.53571,-2.022929 -4.53571,-4.535715 0,-2.512785 2.02293,-4.535714 4.53571,-4.535714 z" />
<path
id="path917"
style="opacity:0.996;fill:#f9f9f9;fill-opacity:1;stroke:whitesmoke;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 109.12738,83.424988 h 52.91667 c 2.51278,0 4.53571,2.022928 4.53571,4.535714 0,2.512786 -2.02293,4.535714 -4.53571,4.535714 h -52.91667 c -2.51278,0 -4.53571,-2.022928 -4.53571,-4.535714 0,-2.512786 2.02293,-4.535714 4.53571,-4.535714 z" />
<path
id="path919"
style="opacity:0.996;fill:#f9f9f9;fill-opacity:1;stroke:whitesmoke;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 109.12738,99.375595 h 52.91667 c 2.51278,0 4.53571,2.022925 4.53571,4.535715 0,2.51278 -2.02293,4.53571 -4.53571,4.53571 h -52.91667 c -2.51278,0 -4.53571,-2.02293 -4.53571,-4.53571 0,-2.51279 2.02293,-4.535715 4.53571,-4.535715 z" />
</g>
<path
id="lpe_path-effect923"
class="powerclip"
d="M 55.205951,21.944047 H 156.46071 V 151.16905 H 55.205951 Z m 54.407139,45.335715 c -2.51278,0 -4.53571,2.022929 -4.53571,4.535714 0,2.512786 2.02293,4.535715 4.53571,4.535715 h 52.91667 c 2.51278,0 4.53571,-2.022929 4.53571,-4.535715 0,-2.512785 -2.02293,-4.535714 -4.53571,-4.535714 z m -0.48571,16.145226 c -2.51278,0 -4.53571,2.022928 -4.53571,4.535714 0,2.512786 2.02293,4.535714 4.53571,4.535714 h 52.91667 c 2.51278,0 4.53571,-2.022928 4.53571,-4.535714 0,-2.512786 -2.02293,-4.535714 -4.53571,-4.535714 z m 0,15.950607 c -2.51278,0 -4.53571,2.022925 -4.53571,4.535715 0,2.51278 2.02293,4.53571 4.53571,4.53571 h 52.91667 c 2.51278,0 4.53571,-2.02293 4.53571,-4.53571 0,-2.51279 -2.02293,-4.535715 -4.53571,-4.535715 z" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath927">
<g
id="g935"
transform="translate(0.24285867,0.02228802)"
style="display:none">
<path
id="path929"
style="opacity:0.996;fill:#f9f9f9;fill-opacity:1;stroke:whitesmoke;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 109.61309,67.279762 h 52.91667 c 2.51278,0 4.53571,2.022929 4.53571,4.535714 0,2.512786 -2.02293,4.535715 -4.53571,4.535715 h -52.91667 c -2.51278,0 -4.53571,-2.022929 -4.53571,-4.535715 0,-2.512785 2.02293,-4.535714 4.53571,-4.535714 z" />
<path
id="path931"
style="opacity:0.996;fill:#f9f9f9;fill-opacity:1;stroke:whitesmoke;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 109.12738,83.424988 h 52.91667 c 2.51278,0 4.53571,2.022928 4.53571,4.535714 0,2.512786 -2.02293,4.535714 -4.53571,4.535714 h -52.91667 c -2.51278,0 -4.53571,-2.022928 -4.53571,-4.535714 0,-2.512786 2.02293,-4.535714 4.53571,-4.535714 z" />
<path
id="path933"
style="opacity:0.996;fill:#f9f9f9;fill-opacity:1;stroke:whitesmoke;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 109.12738,99.375595 h 52.91667 c 2.51278,0 4.53571,2.022925 4.53571,4.535715 0,2.51278 -2.02293,4.53571 -4.53571,4.53571 h -52.91667 c -2.51278,0 -4.53571,-2.02293 -4.53571,-4.53571 0,-2.51279 2.02293,-4.535715 4.53571,-4.535715 z" />
</g>
<path
id="lpe_path-effect937"
class="powerclip"
d="M 84.958331,47.538688 H 186.21309 V 176.76369 H 84.958331 Z m 24.654759,19.741074 c -2.51278,0 -4.53571,2.022929 -4.53571,4.535714 0,2.512786 2.02293,4.535715 4.53571,4.535715 h 52.91667 c 2.51278,0 4.53571,-2.022929 4.53571,-4.535715 0,-2.512785 -2.02293,-4.535714 -4.53571,-4.535714 z m -0.48571,16.145226 c -2.51278,0 -4.53571,2.022928 -4.53571,4.535714 0,2.512786 2.02293,4.535714 4.53571,4.535714 h 52.91667 c 2.51278,0 4.53571,-2.022928 4.53571,-4.535714 0,-2.512786 -2.02293,-4.535714 -4.53571,-4.535714 z m 0,15.950607 c -2.51278,0 -4.53571,2.022925 -4.53571,4.535715 0,2.51278 2.02293,4.53571 4.53571,4.53571 h 52.91667 c 2.51278,0 4.53571,-2.02293 4.53571,-4.53571 0,-2.51279 -2.02293,-4.535715 -4.53571,-4.535715 z" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath941">
<g
id="g949"
style="display:none">
<rect
style="opacity:0.996;fill:whitesmoke;stroke:#a5ccd1;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
id="rect943"
width="39.498516"
height="11.483695"
x="116.32217"
y="66.357109"
d="m 116.32217,66.357109 h 39.49851 v 11.483695 h -39.49851 z" />
<rect
style="opacity:0.996;fill:whitesmoke;stroke:#a5ccd1;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
id="rect945"
width="39.498516"
height="11.483695"
x="115.93681"
y="82.326607"
d="m 115.93681,82.326607 h 39.49852 v 11.483695 h -39.49852 z" />
<rect
style="opacity:0.996;fill:whitesmoke;stroke:#a5ccd1;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
id="rect947"
width="39.498516"
height="11.483695"
x="115.91958"
y="98.25666"
d="m 115.91958,98.25666 h 39.49851 v 11.4837 h -39.49851 z" />
</g>
<path
id="lpe_path-effect951"
class="powerclip"
d="M 55.205951,21.944047 H 156.46071 V 151.16905 H 55.205951 Z m 61.116219,44.413062 v 11.483695 h 39.49851 V 66.357109 Z m -0.38536,15.969498 v 11.483695 h 39.49852 V 82.326607 Z m -0.0172,15.930053 v 11.4837 h 39.49851 v -11.4837 z" />
</clipPath>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1.4"
inkscape:cx="229.70018"
inkscape:cy="291.6465"
inkscape:document-units="mm"
inkscape:current-layer="g862"
inkscape:document-rotation="0"
showgrid="false"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:snap-bbox-midpoints="true"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:snap-global="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-60.205951,-26.944047)">
<g
id="g862">
<g
id="g870">
<path
id="rect833"
style="opacity:0.996;fill:whitesmoke;fill-opacity:0;stroke:whitesmoke;stroke-width:8.1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 77.863093,30.994047 h 55.940477 c 7.53836,0 13.60714,6.068786 13.60714,13.607143 v 83.91071 c 0,7.53836 -6.06878,13.60715 -13.60714,13.60715 H 77.863093 c -7.538357,0 -13.607142,-6.06879 -13.607142,-13.60715 V 44.60119 c 0,-7.538357 6.068785,-13.607143 13.607142,-13.607143 z"
clip-path="url(#clipPath941)"
inkscape:original-d="m 77.863093,30.994047 h 55.940477 c 7.53836,0 13.60714,6.068786 13.60714,13.607143 v 83.91071 c 0,7.53836 -6.06878,13.60715 -13.60714,13.60715 H 77.863093 c -7.538357,0 -13.607142,-6.06879 -13.607142,-13.60715 V 44.60119 c 0,-7.538357 6.068785,-13.607143 13.607142,-13.607143 z"
inkscape:path-effect="#path-effect923;#path-effect951" />
<path
id="rect833-1"
style="opacity:0.996;fill:whitesmoke;fill-opacity:1;stroke:whitesmoke;stroke-width:8.1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 107.61547,56.588688 h 55.94048 c 7.53836,0 13.60714,6.068785 13.60714,13.607142 v 83.91072 c 0,7.53835 -6.06878,13.60714 -13.60714,13.60714 h -55.94048 c -7.53835,0 -13.607139,-6.06879 -13.607139,-13.60714 V 70.19583 c 0,-7.538357 6.068789,-13.607142 13.607139,-13.607142 z"
clip-path="url(#clipPath927)"
inkscape:original-d="m 107.61547,56.588688 h 55.94048 c 7.53836,0 13.60714,6.068785 13.60714,13.607142 v 83.91072 c 0,7.53835 -6.06878,13.60714 -13.60714,13.60714 h -55.94048 c -7.53835,0 -13.607139,-6.06879 -13.607139,-13.60714 V 70.19583 c 0,-7.538357 6.068789,-13.607142 13.607139,-13.607142 z"
inkscape:path-effect="#path-effect937" />
<path
style="opacity:0.996;fill:#cfced2;fill-opacity:0;stroke:whitesmoke;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 181.02138,183.03773 c -5.53501,-2.80025 -7.14286,-5.82668 -7.14286,-13.44493 0,-16.70234 2.35705,-17.05861 112.85714,-17.05861 110.5001,0 112.85715,0.35627 112.85715,17.05861 0,16.70235 -2.35705,17.05862 -112.85715,17.05862 -73.8022,0 -100.3663,-0.90805 -105.71428,-3.61369 z"
id="path953"
transform="matrix(0.26458333,0,0,0.26458333,60.205951,26.944047)" />
<path
style="opacity:0.996;fill:#cfced2;fill-opacity:0;stroke:whitesmoke;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 174.32134,241.51069 c -5.9592,-6.58484 -6.06152,-15.89102 -0.23874,-21.7138 3.92103,-3.92103 17.98077,-4.4898 110.98575,-4.4898 99.543,0 106.80467,0.34113 111.22449,5.22497 6.28388,6.94361 5.96667,13.53689 -0.98575,20.48931 -5.5075,5.5075 -9.52381,5.71429 -110.98575,5.71429 -98.35673,0 -105.58205,-0.3432 -110,-5.22497 z"
id="path955"
transform="matrix(0.26458333,0,0,0.26458333,60.205951,26.944047)" />
<path
style="opacity:0.996;fill:#cfced2;fill-opacity:0;stroke:whitesmoke;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 174.32134,301.51069 c -6.21551,-6.86807 -5.99825,-15.59745 0.53536,-21.51029 4.74851,-4.29734 15.71793,-4.69038 112.03415,-4.01425 l 106.77025,0.74951 4.22446,7.52791 c 3.7896,6.753 3.72048,8.29708 -0.67144,15 l -4.8959,7.47209 H 285.68405 c -99.6769,0 -106.94268,-0.3409 -111.36271,-5.22497 z"
id="path957"
transform="matrix(0.26458333,0,0,0.26458333,60.205951,26.944047)" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -9,6 +9,6 @@
</g>
<g>
<title>Layer 1</title>
<path id="svg_1" d="m0.749996,50.93695l50.18695,-50.18695l53.312819,53.312382l53.312819,-53.312382l50.187422,50.18695l-53.312833,53.312819l53.312833,53.312819l-50.187422,50.187422l-53.312819,-53.312833l-53.312819,53.312833l-50.18695,-50.187422l53.312382,-53.312819l-53.312382,-53.312819z" stroke-width="1.5" stroke="none" fill="red"/>
<path id="svg_1" d="m0.749996,50.93695l50.18695,-50.18695l53.312819,53.312382l53.312819,-53.312382l50.187422,50.18695l-53.312833,53.312819l53.312833,53.312819l-50.187422,50.187422l-53.312819,-53.312833l-53.312819,53.312833l-50.18695,-50.187422l53.312382,-53.312819l-53.312382,-53.312819z" stroke-width="0" stroke="none" fill="#e73333"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 840 B

After

Width:  |  Height:  |  Size: 842 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#fff" width="24" height="24" viewBox="0 0 24 24"><path d="M12 .02c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm6.99 6.98l-6.99 5.666-6.991-5.666h13.981zm.01 10h-14v-8.505l7 5.673 7-5.672v8.504z"/></svg>

After

Width:  |  Height:  |  Size: 274 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="#d7d7d7" d="M15 12c0 1.654-1.346 3-3 3s-3-1.346-3-3 1.346-3 3-3 3 1.346 3 3zm9-.449s-4.252 8.449-11.985 8.449c-7.18 0-12.015-8.449-12.015-8.449s4.446-7.551 12.015-7.551c7.694 0 11.985 7.551 11.985 7.551zm-7 .449c0-2.757-2.243-5-5-5s-5 2.243-5 5 2.243 5 5 5 5-2.243 5-5z"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

View File

@@ -0,0 +1 @@
<?xml version="1.0"?><svg fill="#d7d7d7" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="384px" height="384px"> <path d="M 12 2 C 6.4889971 2 2 6.4889971 2 12 C 2 17.511003 6.4889971 22 12 22 C 17.511003 22 22 17.511003 22 12 C 22 6.4889971 17.511003 2 12 2 z M 12 4 C 16.430123 4 20 7.5698774 20 12 C 20 16.430123 16.430123 20 12 20 C 7.5698774 20 4 16.430123 4 12 C 4 7.5698774 7.5698774 4 12 4 z M 11 7 L 11 9 L 13 9 L 13 7 L 11 7 z M 11 11 L 11 17 L 13 17 L 13 11 L 11 11 z"/></svg>

After

Width:  |  Height:  |  Size: 505 B

View File

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View File

@@ -0,0 +1,8 @@
<svg width="263" height="271" xmlns="http://www.w3.org/2000/svg">
<g>
<title>Layer 1</title>
<ellipse stroke="#d7d7d7" ry="131" rx="126" id="svg_5" fill="none" cy="135.237498" cx="131.999999" stroke-opacity="null" stroke-width="8"/>
<rect stroke="#d7d7d7" id="svg_1" height="123.000006" width="41" y="73.737494" x="77.5" stroke-width="0" fill="#d7d7d7"/>
<rect stroke="#d7d7d7" id="svg_3" height="123.000006" width="41" y="73.737494" x="144.5" stroke-width="0" fill="#d7d7d7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 500 B

View File

@@ -9,6 +9,6 @@
</g>
<g>
<title>Layer 1</title>
<path id="svg_2" d="m124.961908,17.099698l-20.881294,-13.124489c-5.268417,-3.308101 -12.210471,-1.715912 -15.530119,3.553603l-8.230499,13.1l39.946799,25.089697l8.23699,-13.093919c3.307049,-5.275788 1.728716,-12.224161 -3.541878,-15.524891l0,0zm-115.727379,116.707156l39.948976,25.089439l65.110209,-103.65022l-39.967994,-25.095989l-65.091198,103.656764l0.000007,0.000007zm-6.102198,31.877066l-0.88233,23.566079l20.849261,-11.027851l19.374887,-10.229887l-38.540057,-24.219893l-0.801755,21.911559l0,0l-0.000007,-0.000007z" stroke-width="4.5" stroke="none" fill="black"/>
<path id="svg_2" d="m124.961908,17.099698l-20.881294,-13.124489c-5.268417,-3.308101 -12.210471,-1.715912 -15.530119,3.553603l-8.230499,13.1l39.946799,25.089697l8.23699,-13.093919c3.307049,-5.275788 1.728716,-12.224161 -3.541878,-15.524891l0,0zm-115.727379,116.707156l39.948976,25.089439l65.110209,-103.65022l-39.967994,-25.095989l-65.091198,103.656764l0.000007,0.000007zm-6.102198,31.877066l-0.88233,23.566079l20.849261,-11.027851l19.374887,-10.229887l-38.540057,-24.219893l-0.801755,21.911559l0,0l-0.000007,-0.000007z" stroke-width="4.5" stroke="none" fill="#d7d7d7"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -79,7 +79,7 @@
id="layer1"
transform="translate(-44.488903,-69.97024)">
<circle
style="opacity:0.95999995;fill:none;fill-opacity:1;stroke:whitesmoke;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
style="opacity:0.95999995;fill:none;fill-opacity:1;stroke:#d7d7d7;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="path4518"
cx="104.32143"
cy="101.96429"
@@ -93,14 +93,14 @@
inkscape:connector-curvature="0"
id="path4524"
d="m 44.60119,189.65476 c 6.047799,-10.58365 12.095417,-21.16698 21.920308,-26.58444 9.824892,-5.41745 23.437104,-5.66953 37.043972,-5.92151"
style="fill:none;stroke:whitesmoke;stroke-width:4;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
style="fill:none;stroke:#d7d7d7;stroke-width:8;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
inkscape:original-d="m 44.60119,189.65476 c 6.047883,-10.5836 12.095501,-21.16693 18.142858,-31.75 13.607002,-2.6e-4 27.214549,-0.50423 40.821422,-0.75595"
inkscape:path-effect="#path-effect4526-4"
inkscape:connector-curvature="0"
id="path4524-1"
d="m 44.60119,189.65476 c 6.047799,-10.58365 12.095417,-21.16698 21.920308,-26.58444 9.824892,-5.41745 23.437104,-5.66953 37.043972,-5.92151"
style="fill:none;stroke:whitesmoke;stroke-width:4;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
style="fill:none;stroke:#d7d7d7;stroke-width:8;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(-1,0,0,1,207.13094,0)" />
</g>
</g>

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,7 +1,7 @@
<svg width="263" height="271" xmlns="http://www.w3.org/2000/svg">
<g>
<title>Layer 1</title>
<ellipse stroke="gray" ry="131" rx="126" id="svg_5" cy="135.237498" cx="129.999999" fill-opacity="null" stroke-opacity="null" stroke-width="8" fill="none"/>
<path transform="rotate(90.18392181396484 140.08586120605474,135.38354492187497) " stroke="#7d0b0b" id="svg_7" d="m86.585877,180.883554l53.499995,-91.000007l53.499995,91.000007l-106.99999,0z" fill-opacity="null" stroke-opacity="null" stroke-width="0" fill="gray"/>
<ellipse stroke="#d7d7d7" ry="131" rx="126" id="svg_5" cy="135.237498" cx="129.999999" fill-opacity="null" stroke-opacity="null" stroke-width="8" fill="none"/>
<path transform="rotate(90.18392181396484 140.08586120605474,135.38354492187497) " stroke="#7d0b0b" id="svg_7" d="m86.585877,180.883554l53.499995,-91.000007l53.499995,91.000007l-106.99999,0z" fill-opacity="null" stroke-opacity="null" stroke-width="0" fill="#d7d7d7"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 535 B

After

Width:  |  Height:  |  Size: 541 B

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="119.66505mm"
height="109.59733mm"
viewBox="-17 0 150.66505 120.59733"
version="1.1"
id="svg8"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="host.svg">
<defs
id="defs2">
<inkscape:path-effect
effect="bspline"
id="path-effect4526"
is_visible="true"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect4526-4"
is_visible="true"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.35"
inkscape:cx="-232.43275"
inkscape:cy="116.16088"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:window-width="1920"
inkscape:window-height="1027"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-44.488903,-69.97024)">
<circle
style="opacity:0.95999995;fill:none;fill-opacity:1;stroke:#bfbfbf;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="path4518"
cx="104.32143"
cy="101.96429"
r="30.994047" />
<g
id="g4548"
transform="translate(0.75595639,-10.583334)">
<path
inkscape:original-d="m 44.60119,189.65476 c 6.047883,-10.5836 12.095501,-21.16693 18.142858,-31.75 13.607002,-2.6e-4 27.214549,-0.50423 40.821422,-0.75595"
inkscape:path-effect="#path-effect4526"
inkscape:connector-curvature="0"
id="path4524"
d="m 44.60119,189.65476 c 6.047799,-10.58365 12.095417,-21.16698 21.920308,-26.58444 9.824892,-5.41745 23.437104,-5.66953 37.043972,-5.92151"
style="fill:none;stroke:#bfbfbf;stroke-width:8;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
inkscape:original-d="m 44.60119,189.65476 c 6.047883,-10.5836 12.095501,-21.16693 18.142858,-31.75 13.607002,-2.6e-4 27.214549,-0.50423 40.821422,-0.75595"
inkscape:path-effect="#path-effect4526-4"
inkscape:connector-curvature="0"
id="path4524-1"
d="m 44.60119,189.65476 c 6.047799,-10.58365 12.095417,-21.16698 21.920308,-26.58444 9.824892,-5.41745 23.437104,-5.66953 37.043972,-5.92151"
style="fill:none;stroke:#bfbfbf;stroke-width:8;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(-1,0,0,1,207.13094,0)" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

Before

Width:  |  Height:  |  Size: 218 B

After

Width:  |  Height:  |  Size: 218 B

14
client/src/images/x.svg Normal file
View File

@@ -0,0 +1,14 @@
<svg width="209" height="209" xmlns="http://www.w3.org/2000/svg">
<!-- Created with Method Draw - http://github.com/duopixel/Method-Draw/ -->
<g>
<title>background</title>
<rect fill="none" id="canvas_background" height="172" width="172" y="-1" x="-1"/>
<g display="none" overflow="visible" y="0" x="0" height="100%" width="100%" id="canvasGrid">
<rect fill="url(#gridpattern)" stroke-width="0" y="0" x="0" height="100%" width="100%"/>
</g>
</g>
<g>
<title>Layer 1</title>
<path id="svg_1" d="m0.749996,50.93695l50.18695,-50.18695l53.312819,53.312382l53.312819,-53.312382l50.187422,50.18695l-53.312833,53.312819l53.312833,53.312819l-50.187422,50.187422l-53.312819,-53.312833l-53.312819,53.312833l-50.18695,-50.187422l53.312382,-53.312819l-53.312382,-53.312819z" stroke-width="1.5" stroke="none" fill="#d7d7d7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 844 B

9
client/src/model/Game.js Normal file
View File

@@ -0,0 +1,9 @@
export class Game {
constructor (deck, hasTimer, hasDedicatedModerator, timerParams = null) {
this.deck = deck;
this.hasTimer = hasTimer;
this.timerParams = timerParams;
this.hasDedicatedModerator = hasDedicatedModerator;
this.accessCode = null;
}
}

View File

@@ -0,0 +1,157 @@
import { globals } from '../config/globals.js';
import { toast } from './Toast.js';
import { ModalManager } from './ModalManager';
export class DeckStateManager {
constructor () {
this.deck = null;
this.customRoleOptions = [];
this.createMode = false;
this.currentlyEditingRoleName = null;
}
addToDeck (role) {
const option = this.customRoleOptions.find((option) => option.role === role);
if (option) {
option.quantity = 0;
this.deck.push(option);
this.customRoleOptions.splice(this.customRoleOptions.indexOf(option), 1);
}
}
addToCustomRoleOptions (role) {
this.customRoleOptions.push(role);
localStorage.setItem('play-werewolf-custom-roles', JSON.stringify(this.customRoleOptions.concat(this.deck.filter(card => card.custom === true))));
}
updateCustomRoleOption (option, name, description, team) {
option.role = name;
option.description = description;
option.team = team;
localStorage.setItem('play-werewolf-custom-roles', JSON.stringify(this.customRoleOptions.concat(this.deck.filter(card => card.custom === true))));
}
removeFromCustomRoleOptions (name) {
const option = this.customRoleOptions.find((option) => option.role === name);
if (option) {
this.customRoleOptions.splice(this.customRoleOptions.indexOf(option), 1);
localStorage.setItem('play-werewolf-custom-roles', JSON.stringify(this.customRoleOptions.concat(this.deck.filter(card => card.custom === true))));
toast('"' + name + '" deleted.', 'error', true, true, 3);
}
}
addCopyOfCard (role) {
const existingCard = this.deck.find((card) => card.role === role);
if (existingCard) {
existingCard.quantity += 1;
}
}
removeCopyOfCard (role) {
const existingCard = this.deck.find((card) => card.role === role);
if (existingCard && existingCard.quantity > 0) {
existingCard.quantity -= 1;
}
}
getCurrentDeck () { return this.deck; }
getCard (role) {
return this.deck.find(
(card) => card.role.toLowerCase().trim() === role.toLowerCase().trim()
);
}
getCurrentCustomRoleOptions () { return this.customRoleOptions; }
getCustomRoleOption (role) {
return this.customRoleOptions.find(
(option) => option.role.toLowerCase().trim() === role.toLowerCase().trim()
);
};
getDeckSize () {
let total = 0;
for (const role of this.deck) {
total += role.quantity;
}
return total;
}
loadCustomRolesFromCookies () {
const customRoles = localStorage.getItem('play-werewolf-custom-roles');
if (customRoles !== null && validateCustomRoleCookie(customRoles)) {
this.customRoleOptions = JSON.parse(customRoles); // we know it is valid JSON from the validate function
}
}
loadCustomRolesFromFile (file, updateRoleListFunction, loadDefaultCardsFn, showIncludedCardsFn) {
const reader = new FileReader();
reader.onerror = (e) => {
toast(reader.error.message, 'error', true, true, 5);
};
reader.onload = (e) => {
let string;
if (typeof e.target.result !== 'string') {
string = new TextDecoder('utf-8').decode(e.target.result);
} else {
string = e.target.result;
}
if (validateCustomRoleCookie(string)) {
this.customRoleOptions = JSON.parse(string); // we know it is valid JSON from the validate function
ModalManager.dispelModal('upload-custom-roles-modal', 'modal-background');
toast('Roles imported successfully', 'success', true, true, 3);
localStorage.setItem('play-werewolf-custom-roles', JSON.stringify(this.customRoleOptions));
updateRoleListFunction(this, document.getElementById('deck-select'));
// loadDefaultCardsFn(this);
// showIncludedCardsFn(this);
} else {
toast('Invalid formatting. Make sure you import the file as downloaded from this page.', 'error', true, true, 5);
}
};
reader.readAsText(file);
}
// via https://stackoverflow.com/a/18197341
downloadCustomRoles (filename, text) {
const element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
}
// this is user-supplied, so we should validate it fully
function validateCustomRoleCookie (cookie) {
const valid = false;
if (typeof cookie === 'string' && new Blob([cookie]).size <= 1000000) {
try {
const cookieJSON = JSON.parse(cookie);
if (Array.isArray(cookieJSON)) {
for (const entry of cookieJSON) {
if (typeof entry === 'object') {
if (typeof entry.role !== 'string' || entry.role.length > globals.MAX_CUSTOM_ROLE_NAME_LENGTH
|| typeof entry.team !== 'string' || (entry.team !== globals.ALIGNMENT.GOOD && entry.team !== globals.ALIGNMENT.EVIL)
|| typeof entry.description !== 'string' || entry.description.length > globals.MAX_CUSTOM_ROLE_DESCRIPTION_LENGTH
) {
return false;
}
} else {
return false;
}
}
return true;
}
} catch (e) {
return false;
}
}
return valid;
}

View File

@@ -0,0 +1,646 @@
import { Game } from '../model/Game.js';
import { cancelCurrentToast, toast } from './Toast.js';
import { ModalManager } from './ModalManager.js';
import { XHRUtility } from './XHRUtility.js';
import { globals } from '../config/globals.js';
import { templates } from './Templates.js';
import { defaultCards } from '../config/defaultCards';
export class GameCreationStepManager {
constructor (deckManager) {
loadDefaultCards(deckManager);
deckManager.loadCustomRolesFromCookies();
this.step = 1;
this.currentGame = new Game(null, null, null, null);
this.deckManager = deckManager;
this.defaultBackHandler = () => {
cancelCurrentToast();
this.removeStepElementsFromDOM(this.step);
this.decrementStep();
this.renderStep('creation-step-container', this.step);
};
this.steps = {
1: {
title: 'Select your method of moderation:',
forwardHandler: () => {
if (this.currentGame.hasDedicatedModerator !== null) {
cancelCurrentToast();
this.removeStepElementsFromDOM(this.step);
this.incrementStep();
this.renderStep('creation-step-container', this.step);
} else {
toast('You must select a moderation option.', 'error', true);
}
}
},
2: {
title: 'Create your deck of cards:',
forwardHandler: () => {
if (this.deckManager.getDeckSize() >= 5 && this.deckManager.getDeckSize() <= 50) {
this.currentGame.deck = deckManager.getCurrentDeck().filter((card) => card.quantity > 0);
cancelCurrentToast();
this.removeStepElementsFromDOM(this.step);
this.incrementStep();
this.renderStep('creation-step-container', this.step);
} else {
toast('You must have a deck for between 5 and 50 players', 'error', true);
}
},
backHandler: this.defaultBackHandler
},
3: {
title: 'Set an optional timer:',
forwardHandler: () => {
const hours = parseInt(document.getElementById('game-hours').value);
const minutes = parseInt(document.getElementById('game-minutes').value);
if ((isNaN(hours) && isNaN(minutes))
|| (isNaN(hours) && minutes > 0 && minutes < 60)
|| (isNaN(minutes) && hours > 0 && hours < 6)
|| (hours === 0 && minutes > 0 && minutes < 60)
|| (minutes === 0 && hours > 0 && hours < 6)
|| (hours > 0 && hours < 6 && minutes >= 0 && minutes < 60)
) {
if (hasTimer(hours, minutes)) {
this.currentGame.hasTimer = true;
this.currentGame.timerParams = {
hours: hours,
minutes: minutes
};
} else {
this.currentGame.hasTimer = false;
this.currentGame.timerParams = null;
}
cancelCurrentToast();
this.removeStepElementsFromDOM(this.step);
this.incrementStep();
this.renderStep('creation-step-container', this.step);
} else {
if (hours === 0 && minutes === 0) {
toast('You must enter a non-zero amount of time.', 'error', true);
} else {
toast('Invalid timer options. Hours can be a max of 5, Minutes a max of 59.', 'error', true);
}
}
},
backHandler: this.defaultBackHandler
},
4: {
title: 'Review and submit:',
backHandler: this.defaultBackHandler,
forwardHandler: (deck, hasTimer, hasDedicatedModerator, timerParams) => {
XHRUtility.xhr(
'/api/games/create',
'POST',
null,
JSON.stringify(
new Game(deck, hasTimer, hasDedicatedModerator, timerParams)
)
)
.then((res) => {
if (res
&& typeof res === 'object'
&& Object.prototype.hasOwnProperty.call(res, 'content')
&& typeof res.content === 'string'
) {
window.location = ('/game/' + res.content);
}
}).catch((e) => {
if (e.status === 429) {
toast('You\'ve sent this request too many times.', 'error', true, true, 6);
}
});
}
}
};
}
incrementStep () {
if (this.step < Object.keys(this.steps).length) {
this.step += 1;
}
}
decrementStep () {
if (this.step > 1) {
this.step -= 1;
}
}
renderStep (containerId, step) {
document.querySelectorAll('.animated-placeholder').forEach((el) => el.remove());
document.querySelectorAll('.placeholder-row').forEach((el) => el.remove());
document.getElementById('step-title').innerText = this.steps[step].title;
switch (step) {
case 1:
renderModerationTypeStep(this.currentGame, containerId, step);
showButtons(false, true, this.steps[step].forwardHandler, null);
break;
case 2:
renderRoleSelectionStep(this.currentGame, containerId, step, this.deckManager);
showButtons(true, true, this.steps[step].forwardHandler, this.steps[step].backHandler);
break;
case 3:
renderTimerStep(containerId, step, this.currentGame);
showButtons(true, true, this.steps[step].forwardHandler, this.steps[step].backHandler);
break;
case 4:
renderReviewAndCreateStep(containerId, step, this.currentGame);
showButtons(true, true, this.steps[step].forwardHandler, this.steps[step].backHandler, this.currentGame);
break;
default:
break;
}
updateTracker(step);
}
removeStepElementsFromDOM (stepNumber) {
document.getElementById('step-' + stepNumber)?.remove();
}
}
function renderModerationTypeStep (game, containerId, stepNumber) {
const stepContainer = document.createElement('div');
setAttributes(stepContainer, { id: 'step-' + stepNumber, class: 'flex-row-container step' });
stepContainer.innerHTML =
"<div id='moderation-dedicated'>I will be the <strong>dedicated mod.</strong> Don't deal me a card.</div>" +
"<div id='moderation-self'>The <strong>first person out</strong> will mod. Deal me into the game <span>(mod will be assigned automatically).</span></div>";
const dedicatedOption = stepContainer.querySelector('#moderation-dedicated');
if (game.hasDedicatedModerator) {
dedicatedOption.classList.add('option-selected');
}
const selfOption = stepContainer.querySelector('#moderation-self');
if (game.hasDedicatedModerator === false) {
selfOption.classList.add('option-selected');
}
dedicatedOption.addEventListener('click', () => {
dedicatedOption.classList.add('option-selected');
selfOption.classList.remove('option-selected');
game.hasDedicatedModerator = true;
});
selfOption.addEventListener('click', () => {
selfOption.classList.add('option-selected');
dedicatedOption.classList.remove('option-selected');
game.hasDedicatedModerator = false;
});
document.getElementById(containerId).appendChild(stepContainer);
}
function renderRoleSelectionStep (game, containerId, step, deckManager) {
const stepContainer = document.createElement('div');
setAttributes(stepContainer, { id: 'step-' + step, class: 'flex-row-container-left-align step' });
stepContainer.innerHTML = templates.CREATE_GAME_CUSTOM_ROLES;
stepContainer.innerHTML += templates.CREATE_GAME_DECK_STATUS;
stepContainer.innerHTML += templates.CREATE_GAME_DECK;
document.getElementById(containerId).appendChild(stepContainer);
document.querySelector('#custom-roles-export').addEventListener('click', (e) => {
e.preventDefault();
deckManager.downloadCustomRoles('play-werewolf-custom-roles', JSON.stringify(deckManager.getCurrentCustomRoleOptions()));
});
document.querySelector('#custom-roles-import').addEventListener('click', (e) => {
e.preventDefault();
ModalManager.displayModal('upload-custom-roles-modal', 'modal-background', 'close-upload-custom-roles-modal-button');
});
document.getElementById('upload-custom-roles-form').onsubmit = (e) => {
e.preventDefault();
const fileList = document.getElementById('upload-custom-roles').files;
if (fileList.length > 0) {
const file = fileList[0];
if (file.size > 1000000) {
toast('Your file is too large (max 1MB)', 'error', true, true, 5);
return;
}
if (file.type !== 'text/plain') {
toast('Your file must be a text file', 'error', true, true, 5);
return;
}
deckManager.loadCustomRolesFromFile(file, updateCustomRoleOptionsList, loadDefaultCards, showIncludedCards);
} else {
toast('You must upload a text file', 'error', true, true, 5);
}
};
const clickHandler = () => {
console.log('fired');
const actions = document.getElementById('custom-role-actions');
if (actions.style.display !== 'none') {
actions.style.display = 'none';
} else {
actions.style.display = 'block';
}
};
document.getElementById('custom-role-hamburger').addEventListener('click', clickHandler);
showIncludedCards(deckManager);
loadCustomRoles(deckManager);
updateDeckStatus(deckManager);
initializeRemainingEventListeners(deckManager);
}
function renderTimerStep (containerId, stepNumber, game) {
const div = document.createElement('div');
div.setAttribute('id', 'step-' + stepNumber);
div.classList.add('step');
const timeContainer = document.createElement('div');
timeContainer.setAttribute('id', 'game-time');
const hoursDiv = document.createElement('div');
const hoursLabel = document.createElement('label');
hoursLabel.setAttribute('for', 'game-hours');
hoursLabel.innerText = 'Hours (max 5)';
const hours = document.createElement('input');
setAttributes(hours, { type: 'number', id: 'game-hours', name: 'game-hours', min: '0', max: '5', value: game.timerParams?.hours });
const minutesDiv = document.createElement('div');
const minsLabel = document.createElement('label');
minsLabel.setAttribute('for', 'game-minutes');
minsLabel.innerText = 'Minutes';
const minutes = document.createElement('input');
setAttributes(minutes, { type: 'number', id: 'game-minutes', name: 'game-minutes', min: '1', max: '60', value: game.timerParams?.minutes });
hoursDiv.appendChild(hoursLabel);
hoursDiv.appendChild(hours);
minutesDiv.appendChild(minsLabel);
minutesDiv.appendChild(minutes);
timeContainer.appendChild(hoursDiv);
timeContainer.appendChild(minutesDiv);
div.appendChild(timeContainer);
document.getElementById(containerId).appendChild(div);
}
function renderReviewAndCreateStep (containerId, stepNumber, game) {
const div = document.createElement('div');
div.setAttribute('id', 'step-' + stepNumber);
div.classList.add('step');
div.innerHTML =
'<div>' +
"<label for='mod-option'>Moderation</label>" +
"<div id='mod-option' class='review-option'></div>" +
'</div>' +
'<div>' +
"<label for='timer-option'>Timer</label>" +
"<div id='timer-option' class='review-option'></div>" +
'</div>' +
'<div>' +
"<label for='roles-option'>Game Deck</label>" +
"<div id='roles-option' class='review-option'></div>" +
'</div>';
div.querySelector('#mod-option').innerText = game.hasDedicatedModerator
? "I will be the dedicated mod. Don't deal me a card."
: 'The first person out will mod. Deal me into the game.';
if (game.hasTimer) {
const formattedHours = !isNaN(game.timerParams.hours)
? game.timerParams.hours + ' Hours'
: '0 Hours';
const formattedMinutes = !isNaN(game.timerParams.minutes)
? game.timerParams.minutes + ' Minutes'
: '0 Minutes';
div.querySelector('#timer-option').innerText = formattedHours + ' ' + formattedMinutes;
} else {
div.querySelector('#timer-option').innerText = 'untimed';
}
for (const card of game.deck) {
const roleEl = document.createElement('div');
roleEl.innerText = card.quantity + 'x ' + card.role;
div.querySelector('#roles-option').appendChild(roleEl);
}
document.getElementById(containerId).appendChild(div);
}
function setAttributes (element, attributeObject) {
for (const key of Object.keys(attributeObject)) {
element.setAttribute(key, attributeObject[key]);
}
}
function updateTracker (step) {
document.querySelectorAll('.creation-step').forEach((element, i) => {
if ((i + 1) <= step) {
element.classList.add('creation-step-filled');
} else {
element.classList.remove('creation-step-filled');
}
});
}
function showButtons (back, forward, forwardHandler, backHandler, builtGame = null) {
document.querySelector('#step-back-button')?.remove();
document.querySelector('#step-forward-button')?.remove();
document.querySelector('#create-game')?.remove();
if (back) {
const backButton = document.createElement('button');
backButton.innerText = '\u25C0';
backButton.addEventListener('click', backHandler);
backButton.setAttribute('id', 'step-back-button');
backButton.classList.add('cancel');
backButton.classList.add('app-button');
document.getElementById('tracker-container').prepend(backButton);
}
if (forward && builtGame === null) {
const fwdButton = document.createElement('button');
fwdButton.innerHTML = '\u25b6';
fwdButton.addEventListener('click', forwardHandler);
fwdButton.setAttribute('id', 'step-forward-button');
fwdButton.classList.add('app-button');
document.getElementById('tracker-container').appendChild(fwdButton);
} else if (forward && builtGame !== null) {
const createButton = document.createElement('button');
createButton.innerText = 'Create';
createButton.setAttribute('id', 'create-game');
createButton.classList.add('app-button');
createButton.addEventListener('click', () => {
forwardHandler(
builtGame.deck.filter((card) => card.quantity > 0),
builtGame.hasTimer,
builtGame.hasDedicatedModerator,
builtGame.timerParams
);
});
document.getElementById('tracker-container').appendChild(createButton);
}
}
// Display a widget for each default card that allows copies of it to be added/removed. Set initial deck state.
function showIncludedCards (deckManager) {
document.querySelectorAll('.compact-card').forEach((el) => { el.remove(); });
for (let i = 0; i < deckManager.getCurrentDeck().length; i++) {
const card = deckManager.getCurrentDeck()[i];
const cardEl = constructCompactDeckBuilderElement(card, deckManager);
if (card.team === globals.ALIGNMENT.GOOD) {
document.getElementById('deck-good').appendChild(cardEl);
} else {
document.getElementById('deck-evil').appendChild(cardEl);
}
}
}
/* Display a dropdown containing all the custom roles. Adding one will add it to the game deck and
create a widget for it */
function loadCustomRoles (deckManager) {
addOptionsToList(deckManager, document.getElementById('deck-select'));
}
function loadDefaultCards (deckManager) {
defaultCards.sort((a, b) => {
if (a.team !== b.team) {
return a.team === globals.ALIGNMENT.GOOD ? 1 : -1;
}
return a.role.localeCompare(b.role);
});
const deck = [];
for (let i = 0; i < defaultCards.length; i++) {
const card = defaultCards[i];
card.quantity = 0;
deck.push(card);
}
deckManager.deck = deck;
}
function constructCompactDeckBuilderElement (card, deckManager) {
const cardContainer = document.createElement('div');
const alignmentClass = card.team === globals.ALIGNMENT.GOOD ? globals.ALIGNMENT.GOOD : globals.ALIGNMENT.EVIL;
cardContainer.setAttribute('class', 'compact-card ' + alignmentClass);
cardContainer.setAttribute('id', 'card-' + card.role.replaceAll(' ', '-'));
cardContainer.innerHTML =
"<div class='compact-card-left'>" +
'<p>-</p>' +
'</div>' +
"<div class='compact-card-header'>" +
"<p class='card-role'></p>" +
"<div class='card-quantity'></div>" +
'</div>' +
"<div class='compact-card-right'>" +
'<p>+</p>' +
'</div>';
cardContainer.querySelector('.card-role').innerText = card.role;
cardContainer.title = card.role;
cardContainer.querySelector('.card-quantity').innerText = card.quantity;
if (card.quantity > 0) {
cardContainer.classList.add('selected-card');
}
cardContainer.querySelector('.compact-card-right').addEventListener('click', () => {
deckManager.addCopyOfCard(card.role);
updateDeckStatus(deckManager);
cardContainer.querySelector('.card-quantity').innerText = deckManager.getCard(card.role).quantity;
if (deckManager.getCard(card.role).quantity > 0) {
document.getElementById('card-' + card.role.replaceAll(' ', '-')).classList.add('selected-card');
}
});
cardContainer.querySelector('.compact-card-left').addEventListener('click', () => {
deckManager.removeCopyOfCard(card.role);
updateDeckStatus(deckManager);
cardContainer.querySelector('.card-quantity').innerText = deckManager.getCard(card.role).quantity;
if (deckManager.getCard(card.role).quantity === 0) {
document.getElementById('card-' + card.role.replaceAll(' ', '-')).classList.remove('selected-card');
}
});
return cardContainer;
}
function initializeRemainingEventListeners (deckManager) {
document.getElementById('role-form').onsubmit = (e) => {
e.preventDefault();
const name = document.getElementById('role-name').value.trim();
const description = document.getElementById('role-description').value.trim();
const team = document.getElementById('role-alignment').value.toLowerCase().trim();
if (deckManager.createMode) {
if (!deckManager.getCustomRoleOption(name) && !deckManager.getCard(name)) { // confirm there is no existing custom role with the same name
processNewCustomRoleSubmission(name, description, team, deckManager, false);
} else {
toast('There is already a role with this name', 'error', true, true, 3);
}
} else {
const option = deckManager.getCustomRoleOption(deckManager.currentlyEditingRoleName);
if (name === option.role) { // did they edit the name?
processNewCustomRoleSubmission(name, description, team, deckManager, true, option);
} else {
if (!deckManager.getCustomRoleOption(name) && !deckManager.getCard(name)) {
processNewCustomRoleSubmission(name, description, team, deckManager, true, option);
} else {
toast('There is already a role with this name', 'error', true, true, 3);
}
}
}
};
document.getElementById('custom-role-btn').addEventListener(
'click', () => {
const createBtn = document.getElementById('create-role-button');
createBtn.setAttribute('value', 'Create');
deckManager.createMode = true;
deckManager.currentlyEditingRoleName = null;
document.getElementById('role-name').value = '';
document.getElementById('role-alignment').value = globals.ALIGNMENT.GOOD;
document.getElementById('role-description').value = '';
ModalManager.displayModal(
'role-modal',
'modal-background',
'close-modal-button'
);
}
);
}
function processNewCustomRoleSubmission (name, description, team, deckManager, isUpdate, option = null) {
if (name.length > 40) {
toast('Your name is too long (max 40 characters).', 'error', true);
return;
}
if (description.length > 500) {
toast('Your description is too long (max 500 characters).', 'error', true);
return;
}
if (isUpdate) {
deckManager.updateCustomRoleOption(option, name, description, team);
ModalManager.dispelModal('role-modal', 'modal-background');
toast('Role Updated', 'success', true);
} else {
deckManager.addToCustomRoleOptions({ role: name, description: description, team: team, custom: true });
ModalManager.dispelModal('role-modal', 'modal-background');
toast('Role Created', 'success', true);
}
updateCustomRoleOptionsList(deckManager, document.getElementById('deck-select'));
}
function updateCustomRoleOptionsList (deckManager, selectEl) {
document.querySelectorAll('#deck-select .deck-select-role').forEach(e => e.remove());
addOptionsToList(deckManager, selectEl);
}
function addOptionsToList (deckManager, selectEl) {
const options = deckManager.getCurrentCustomRoleOptions();
options.sort((a, b) => {
if (a.team !== b.team) {
return a.team === globals.ALIGNMENT.GOOD ? 1 : -1;
}
return a.role.localeCompare(b.role);
});
for (let i = 0; i < options.length; i++) {
const optionEl = document.createElement('div');
optionEl.innerHTML = templates.DECK_SELECT_ROLE;
optionEl.classList.add('deck-select-role');
const alignmentClass = options[i].team === globals.ALIGNMENT.GOOD ? globals.ALIGNMENT.GOOD : globals.ALIGNMENT.EVIL;
optionEl.classList.add(alignmentClass);
optionEl.querySelector('.deck-select-role-name').innerText = options[i].role;
selectEl.appendChild(optionEl);
}
addCustomRoleEventListeners(deckManager, selectEl);
}
function addCustomRoleEventListeners (deckManager, select) {
document.querySelectorAll('.deck-select-role').forEach((role) => {
const name = role.querySelector('.deck-select-role-name').innerText;
role.querySelector('.deck-select-include').addEventListener('click', (e) => {
e.preventDefault();
if (!deckManager.getCard(name)) {
deckManager.addToDeck(name);
const cardEl = constructCompactDeckBuilderElement(deckManager.getCard(name), deckManager);
toast('"' + name + '" made available below.', 'success', true, true, 4);
if (deckManager.getCard(name).team === globals.ALIGNMENT.GOOD) {
document.getElementById('deck-good').appendChild(cardEl);
} else {
document.getElementById('deck-evil').appendChild(cardEl);
}
updateCustomRoleOptionsList(deckManager, select);
} else {
toast('"' + select.value + '" already included.', 'error', true, true, 3);
}
});
role.querySelector('.deck-select-remove').addEventListener('click', (e) => {
if (confirm("Delete the role '" + name + "'?")) {
e.preventDefault();
deckManager.removeFromCustomRoleOptions(name);
updateCustomRoleOptionsList(deckManager, select);
}
});
role.querySelector('.deck-select-info').addEventListener('click', (e) => {
const alignmentEl = document.getElementById('custom-role-info-modal-alignment');
alignmentEl.classList.remove(globals.ALIGNMENT.GOOD);
alignmentEl.classList.remove(globals.ALIGNMENT.EVIL);
e.preventDefault();
const option = deckManager.getCustomRoleOption(name);
document.getElementById('custom-role-info-modal-name').innerText = name;
alignmentEl.classList.add(option.team);
document.getElementById('custom-role-info-modal-description').innerText = option.description;
alignmentEl.innerText = option.team;
ModalManager.displayModal('custom-role-info-modal', 'modal-background', 'close-custom-role-info-modal-button');
});
role.querySelector('.deck-select-edit').addEventListener('click', (e) => {
e.preventDefault();
const option = deckManager.getCustomRoleOption(name);
document.getElementById('role-name').value = option.role;
document.getElementById('role-alignment').value = option.team;
document.getElementById('role-description').value = option.description;
deckManager.createMode = false;
deckManager.currentlyEditingRoleName = option.role;
const createBtn = document.getElementById('create-role-button');
createBtn.setAttribute('value', 'Update');
ModalManager.displayModal('role-modal', 'modal-background', 'close-modal-button');
});
});
}
function updateDeckStatus (deckManager) {
document.querySelectorAll('.deck-role').forEach((el) => el.remove());
document.getElementById('deck-count').innerText = deckManager.getDeckSize() + ' Players';
if (deckManager.getDeckSize() === 0) {
const placeholder = document.createElement('div');
placeholder.setAttribute('id', 'deck-list-placeholder');
placeholder.innerText = 'Add a card from the available roles below.';
document.getElementById('deck-list').appendChild(placeholder);
} else {
if (document.getElementById('deck-list-placeholder')) {
document.getElementById('deck-list-placeholder').remove();
}
for (const card of deckManager.getCurrentDeck()) {
if (card.quantity > 0) {
const roleEl = document.createElement('div');
roleEl.classList.add('deck-role');
if (card.team === globals.ALIGNMENT.GOOD) {
roleEl.classList.add(globals.ALIGNMENT.GOOD);
} else {
roleEl.classList.add(globals.ALIGNMENT.EVIL);
}
roleEl.innerText = card.quantity + 'x ' + card.role;
document.getElementById('deck-list').appendChild(roleEl);
}
}
}
}
function hasTimer (hours, minutes) {
return (!isNaN(hours) || !isNaN(minutes));
}

View File

@@ -0,0 +1,486 @@
import { globals } from '../config/globals.js';
import { toast } from './Toast.js';
import { templates } from './Templates.js';
import { ModalManager } from './ModalManager.js';
export class GameStateRenderer {
constructor (stateBucket, socket) {
this.stateBucket = stateBucket;
this.socket = socket;
this.killPlayerHandlers = {};
this.revealRoleHandlers = {};
this.transferModHandlers = {};
this.startGameHandler = (e) => {
e.preventDefault();
if (confirm('Start the game and deal roles?')) {
socket.emit(globals.COMMANDS.START_GAME, this.stateBucket.currentGameState.accessCode);
}
};
}
renderLobbyPlayers () {
document.querySelectorAll('.lobby-player').forEach((el) => el.remove());
const lobbyPlayersContainer = document.getElementById('lobby-players');
if (this.stateBucket.currentGameState.moderator.userType === globals.USER_TYPES.MODERATOR) {
lobbyPlayersContainer.appendChild(
renderLobbyPerson(
this.stateBucket.currentGameState.moderator.name,
this.stateBucket.currentGameState.moderator.userType
)
);
}
for (const person of this.stateBucket.currentGameState.people) {
lobbyPlayersContainer.appendChild(renderLobbyPerson(person.name, person.userType));
}
const playerCount = this.stateBucket.currentGameState.people.length;
document.querySelector("label[for='lobby-players']").innerText =
'Participants (' + playerCount + '/' + getGameSize(this.stateBucket.currentGameState.deck) + ' Players)';
}
renderLobbyHeader () {
removeExistingTitle();
const title = document.createElement('h1');
title.innerText = 'Lobby';
document.getElementById('game-title').appendChild(title);
const gameLinkContainer = document.getElementById('game-link');
const linkDiv = document.createElement('div');
linkDiv.innerText = window.location;
gameLinkContainer.prepend(linkDiv);
gameLinkContainer.addEventListener('click', () => {
navigator.clipboard.writeText(gameLinkContainer.innerText).then(() => {
toast('Link copied!', 'success', true);
});
});
const copyImg = document.createElement('img');
copyImg.setAttribute('src', '../images/copy.svg');
gameLinkContainer.appendChild(copyImg);
const time = document.getElementById('game-time');
const playerCount = document.getElementById('game-player-count');
playerCount.innerText = getGameSize(this.stateBucket.currentGameState.deck) + ' Players';
if (this.stateBucket.currentGameState.timerParams) {
let timeString = '';
const hours = this.stateBucket.currentGameState.timerParams.hours;
const minutes = this.stateBucket.currentGameState.timerParams.minutes;
if (hours) {
timeString += hours > 1
? hours + ' hours '
: hours + ' hour ';
}
if (minutes) {
timeString += minutes > 1
? minutes + ' minutes '
: minutes + ' minute ';
}
time.innerText = timeString;
} else {
time.innerText = 'untimed';
}
}
renderLobbyFooter () {
for (const card of this.stateBucket.currentGameState.deck) {
const cardEl = document.createElement('div');
cardEl.innerText = card.quantity + 'x ' + card.role;
cardEl.classList.add('lobby-card');
}
}
renderGameHeader () {
removeExistingTitle();
// let title = document.createElement("h1");
// title.innerText = "Game";
// document.getElementById("game-title").appendChild(title);
}
renderModeratorView () {
createEndGamePromptComponent(this.socket, this.stateBucket);
const modTransferButton = document.getElementById('mod-transfer-button');
modTransferButton.addEventListener(
'click', () => {
this.displayAvailableModerators();
ModalManager.displayModal(
'transfer-mod-modal',
'transfer-mod-modal-background',
'close-mod-transfer-modal-button'
);
}
);
this.renderPlayersWithRoleAndAlignmentInfo();
}
renderTempModView () {
createEndGamePromptComponent(this.socket, this.stateBucket);
renderPlayerRole(this.stateBucket.currentGameState);
this.renderPlayersWithNoRoleInformationUnlessRevealed(true);
}
renderPlayerView (isKilled = false) {
if (isKilled) {
const clientUserType = document.getElementById('client-user-type');
if (clientUserType) {
clientUserType.innerText = globals.USER_TYPES.KILLED_PLAYER + ' \uD83D\uDC80';
}
}
renderPlayerRole(this.stateBucket.currentGameState);
this.renderPlayersWithNoRoleInformationUnlessRevealed(false);
}
renderSpectatorView () {
this.renderPlayersWithNoRoleInformationUnlessRevealed();
}
refreshPlayerList (isModerator) {
if (isModerator) {
this.renderPlayersWithRoleAndAlignmentInfo();
} else {
this.renderPlayersWithNoRoleInformationUnlessRevealed();
}
}
renderPlayersWithRoleAndAlignmentInfo () {
removeExistingPlayerElements(this.killPlayerHandlers, this.revealRoleHandlers);
this.stateBucket.currentGameState.people.sort((a, b) => {
return a.name >= b.name ? 1 : -1;
});
const teamGood = this.stateBucket.currentGameState.people.filter((person) => person.alignment === globals.ALIGNMENT.GOOD);
const teamEvil = this.stateBucket.currentGameState.people.filter((person) => person.alignment === globals.ALIGNMENT.EVIL);
renderGroupOfPlayers(
teamEvil,
this.killPlayerHandlers,
this.revealRoleHandlers,
this.stateBucket.currentGameState.accessCode,
globals.ALIGNMENT.EVIL,
this.stateBucket.currentGameState.moderator.userType,
this.socket
);
renderGroupOfPlayers(
teamGood,
this.killPlayerHandlers,
this.revealRoleHandlers,
this.stateBucket.currentGameState.accessCode,
globals.ALIGNMENT.GOOD,
this.stateBucket.currentGameState.moderator.userType,
this.socket
);
document.getElementById('players-alive-label').innerText =
'Players: ' + this.stateBucket.currentGameState.people.filter((person) => !person.out).length + ' / ' +
this.stateBucket.currentGameState.people.length + ' Alive';
}
renderPlayersWithNoRoleInformationUnlessRevealed (tempMod = false) {
if (tempMod) {
document.querySelectorAll('.game-player').forEach((el) => {
const pointer = el.dataset.pointer;
if (pointer && this.killPlayerHandlers[pointer]) {
el.removeEventListener('click', this.killPlayerHandlers[pointer]);
delete this.killPlayerHandlers[pointer];
}
if (pointer && this.revealRoleHandlers[pointer]) {
el.removeEventListener('click', this.revealRoleHandlers[pointer]);
delete this.revealRoleHandlers[pointer];
}
el.remove();
});
}
document.querySelectorAll('.game-player').forEach((el) => el.remove());
sortPeopleByStatus(this.stateBucket.currentGameState.people);
const modType = tempMod ? this.stateBucket.currentGameState.moderator.userType : null;
renderGroupOfPlayers(
this.stateBucket.currentGameState.people,
this.killPlayerHandlers,
this.revealRoleHandlers,
this.stateBucket.currentGameState.accessCode,
null,
modType,
this.socket
);
document.getElementById('players-alive-label').innerText =
'Players: ' + this.stateBucket.currentGameState.people.filter((person) => !person.out).length + ' / ' +
this.stateBucket.currentGameState.people.length + ' Alive';
}
updatePlayerCardToKilledState () {
document.querySelector('#role-image').classList.add('killed-card');
document.getElementById('role-image').setAttribute(
'src',
'../images/tombstone.png'
);
}
displayAvailableModerators () {
document.getElementById('transfer-mod-modal-content').innerText = '';
document.querySelectorAll('.potential-moderator').forEach((el) => {
const pointer = el.dataset.pointer;
if (pointer && this.transferModHandlers[pointer]) {
el.removeEventListener('click', this.transferModHandlers[pointer]);
delete this.transferModHandlers[pointer];
}
el.remove();
});
renderPotentialMods(
this.stateBucket.currentGameState,
this.stateBucket.currentGameState.people,
this.transferModHandlers,
this.socket
);
renderPotentialMods( // spectators can also be made mods.
this.stateBucket.currentGameState,
this.stateBucket.currentGameState.spectators,
this.transferModHandlers,
this.socket
);
if (document.querySelectorAll('.potential-moderator').length === 0) {
document.getElementById('transfer-mod-modal-content').innerText = 'There is nobody available to transfer to.';
}
}
renderEndOfGame () {
this.renderPlayersWithNoRoleInformationUnlessRevealed();
}
}
function renderPotentialMods (gameState, group, transferModHandlers, socket) {
const modalContent = document.getElementById('transfer-mod-modal-content');
for (const member of group) {
if ((member.out || member.userType === globals.USER_TYPES.SPECTATOR) && !(member.id === gameState.client.id)) {
const container = document.createElement('div');
container.classList.add('potential-moderator');
container.dataset.pointer = member.id;
container.innerText = member.name;
transferModHandlers[member.id] = () => {
if (confirm('Transfer moderator powers to ' + member.name + '?')) {
socket.emit(globals.COMMANDS.TRANSFER_MODERATOR, gameState.accessCode, member.id);
}
};
container.addEventListener('click', transferModHandlers[member.id]);
modalContent.appendChild(container);
}
}
}
function renderLobbyPerson (name, userType) {
const el = document.createElement('div');
const personNameEl = document.createElement('div');
const personTypeEl = document.createElement('div');
personNameEl.innerText = name;
personTypeEl.innerText = userType + globals.USER_TYPE_ICONS[userType];
el.classList.add('lobby-player');
el.appendChild(personNameEl);
el.appendChild(personTypeEl);
return el;
}
function sortPeopleByStatus (people) {
people.sort((a, b) => {
if (a.out !== b.out) {
return a.out ? 1 : -1;
} else {
if (a.revealed !== b.revealed) {
return a.revealed ? -1 : 1;
}
return a.name >= b.name ? 1 : -1;
}
});
}
function getGameSize (cards) {
let quantity = 0;
for (const card of cards) {
quantity += card.quantity;
}
return quantity;
}
function removeExistingTitle () {
const existingTitle = document.querySelector('#game-title h1');
if (existingTitle) {
existingTitle.remove();
}
}
// TODO: refactor to reduce the cyclomatic complexity of this function
function renderGroupOfPlayers (
people,
killPlayerHandlers,
revealRoleHandlers,
accessCode = null,
alignment = null,
moderatorType,
socket = null
) {
for (const player of people) {
const container = document.createElement('div');
container.classList.add('game-player');
if (moderatorType) {
container.dataset.pointer = player.id;
container.innerHTML = templates.MODERATOR_PLAYER;
} else {
container.innerHTML = templates.GAME_PLAYER;
}
container.querySelector('.game-player-name').innerText = player.name;
const roleElement = container.querySelector('.game-player-role');
if (moderatorType) {
roleElement.classList.add(alignment);
if (moderatorType === globals.USER_TYPES.MODERATOR) {
roleElement.innerText = player.gameRole;
document.getElementById('player-list-moderator-team-' + alignment).appendChild(container);
} else {
if (player.revealed) {
roleElement.innerText = player.gameRole;
roleElement.classList.add(player.alignment);
} else {
roleElement.innerText = 'Unknown';
}
document.getElementById('game-player-list').appendChild(container);
}
} else if (player.revealed) {
roleElement.classList.add(player.alignment);
roleElement.innerText = player.gameRole;
document.getElementById('game-player-list').appendChild(container);
} else {
roleElement.innerText = 'Unknown';
document.getElementById('game-player-list').appendChild(container);
}
if (player.out) {
container.classList.add('killed');
if (moderatorType) {
container.querySelector('.kill-player-button')?.remove();
insertPlaceholderButton(container, false, 'killed');
}
} else {
if (moderatorType) {
killPlayerHandlers[player.id] = () => {
if (confirm('KILL ' + player.name + '?')) {
socket.emit(globals.COMMANDS.KILL_PLAYER, accessCode, player.id);
}
};
container.querySelector('.kill-player-button').addEventListener('click', killPlayerHandlers[player.id]);
}
}
if (player.revealed) {
if (moderatorType) {
container.querySelector('.reveal-role-button')?.remove();
insertPlaceholderButton(container, true, 'revealed');
}
} else {
if (moderatorType) {
revealRoleHandlers[player.id] = () => {
if (confirm('REVEAL ' + player.name + '?')) {
socket.emit(globals.COMMANDS.REVEAL_PLAYER, accessCode, player.id);
}
};
container.querySelector('.reveal-role-button').addEventListener('click', revealRoleHandlers[player.id]);
}
}
}
}
function renderPlayerRole (gameState) {
const name = document.querySelector('#role-name');
name.innerText = gameState.client.gameRole;
if (gameState.client.alignment === globals.ALIGNMENT.GOOD) {
document.getElementById('game-role').classList.add('game-role-good');
name.classList.add('good');
} else {
document.getElementById('game-role').classList.add('game-role-evil');
name.classList.add('evil');
}
name.setAttribute('title', gameState.client.gameRole);
if (gameState.client.out) {
document.querySelector('#role-image').classList.add('killed-card');
document.getElementById('role-image').setAttribute(
'src',
'../images/tombstone.png'
);
} else {
if (gameState.client.gameRole.toLowerCase() === 'villager') {
document.getElementById('role-image').setAttribute(
'src',
'../images/roles/Villager' + Math.ceil(Math.random() * 2) + '.png'
);
} else {
if (gameState.client.customRole) {
document.getElementById('role-image').setAttribute(
'src',
'../images/roles/custom-role.svg'
);
} else {
document.getElementById('role-image').setAttribute(
'src',
'../images/roles/' + gameState.client.gameRole.replaceAll(' ', '') + '.png'
);
}
}
}
document.querySelector('#role-description').innerText = gameState.client.gameRoleDescription;
document.getElementById('game-role-back').addEventListener('click', () => {
document.getElementById('game-role').style.display = 'flex';
document.getElementById('game-role-back').style.display = 'none';
});
document.getElementById('game-role').addEventListener('click', () => {
document.getElementById('game-role-back').style.display = 'flex';
document.getElementById('game-role').style.display = 'none';
});
}
function insertPlaceholderButton (container, append, type) {
const button = document.createElement('div');
button.classList.add('placeholder-button');
if (type === 'killed') {
button.innerText = 'Killed';
} else {
button.innerText = 'Revealed';
}
if (append) {
container.querySelector('.player-action-buttons').appendChild(button);
} else {
container.querySelector('.player-action-buttons').prepend(button);
}
}
function removeExistingPlayerElements (killPlayerHandlers, revealRoleHandlers) {
document.querySelectorAll('.game-player').forEach((el) => {
const pointer = el.dataset.pointer;
if (pointer && killPlayerHandlers[pointer]) {
el.removeEventListener('click', killPlayerHandlers[pointer]);
delete killPlayerHandlers[pointer];
}
if (pointer && revealRoleHandlers[pointer]) {
el.removeEventListener('click', revealRoleHandlers[pointer]);
delete revealRoleHandlers[pointer];
}
el.remove();
});
}
function createEndGamePromptComponent (socket, stateBucket) {
if (document.querySelector('#end-game-prompt') === null) {
const div = document.createElement('div');
div.innerHTML = templates.END_GAME_PROMPT;
div.querySelector('#end-game-button').addEventListener('click', (e) => {
e.preventDefault();
if (confirm('End the game?')) {
socket.emit(
globals.COMMANDS.END_GAME,
stateBucket.currentGameState.accessCode
);
}
});
document.getElementById('game-content').appendChild(div);
}
}

View File

@@ -0,0 +1,178 @@
import { globals } from '../config/globals.js';
export class GameTimerManager {
constructor (stateBucket, socket) {
this.stateBucket = stateBucket;
this.playListener = () => {
socket.emit(globals.COMMANDS.RESUME_TIMER, this.stateBucket.currentGameState.accessCode);
};
this.pauseListener = () => {
socket.emit(globals.COMMANDS.PAUSE_TIMER, this.stateBucket.currentGameState.accessCode);
};
}
resumeGameTimer (totalTime, tickRate, soundManager, timerWorker) {
if (window.Worker) {
if (
this.stateBucket.currentGameState.client.userType === globals.USER_TYPES.MODERATOR
|| this.stateBucket.currentGameState.client.userType === globals.USER_TYPES.TEMPORARY_MODERATOR
) {
this.swapToPauseButton();
}
const instance = this;
const timer = document.getElementById('game-timer');
timer.classList.remove('paused');
timer.classList.remove('paused-low');
timer.classList.remove('low');
if (totalTime < 60000) {
timer.classList.add('low');
}
timer.innerText = totalTime < 60000
? returnHumanReadableTime(totalTime, true)
: returnHumanReadableTime(totalTime);
timerWorker.onmessage = function (e) {
if (e.data.hasOwnProperty('timeRemainingInMilliseconds') && e.data.timeRemainingInMilliseconds >= 0) {
if (e.data.timeRemainingInMilliseconds === 0) {
instance.displayExpiredTime();
} else if (e.data.timeRemainingInMilliseconds < 60000) {
timer.classList.add('low');
timer.innerText = e.data.displayTime;
} else {
timer.innerText = e.data.displayTime;
}
}
};
timerWorker.postMessage({ totalTime: totalTime, tickInterval: tickRate });
}
}
pauseGameTimer (timerWorker, timeRemaining) {
if (window.Worker) {
if (
this.stateBucket.currentGameState.client.userType === globals.USER_TYPES.MODERATOR
|| this.stateBucket.currentGameState.client.userType === globals.USER_TYPES.TEMPORARY_MODERATOR
) {
this.swapToPlayButton();
}
timerWorker.postMessage('stop');
const timer = document.getElementById('game-timer');
if (timeRemaining < 60000) {
timer.innerText = returnHumanReadableTime(timeRemaining, true);
timer.classList.add('paused-low');
timer.classList.add('low');
} else {
timer.innerText = returnHumanReadableTime(timeRemaining);
timer.classList.add('paused');
}
}
}
displayPausedTime (time) {
if (
this.stateBucket.currentGameState.client.userType === globals.USER_TYPES.MODERATOR
|| this.stateBucket.currentGameState.client.userType === globals.USER_TYPES.TEMPORARY_MODERATOR
) {
this.swapToPlayButton();
}
const timer = document.getElementById('game-timer');
if (time < 60000) {
timer.innerText = returnHumanReadableTime(time, true);
timer.classList.add('paused-low');
timer.classList.add('low');
} else {
timer.innerText = returnHumanReadableTime(time);
timer.classList.add('paused');
}
}
displayExpiredTime () {
const currentBtn = document.querySelector('#play-pause img');
if (currentBtn) {
currentBtn.removeEventListener('click', this.pauseListener);
currentBtn.removeEventListener('click', this.playListener);
currentBtn.remove();
}
const timer = document.getElementById('game-timer');
timer.innerText = returnHumanReadableTime(0, true);
}
attachTimerSocketListeners (socket, timerWorker, gameStateRenderer) {
if (!socket.hasListeners(globals.COMMANDS.PAUSE_TIMER)) {
socket.on(globals.COMMANDS.PAUSE_TIMER, (timeRemaining) => {
this.pauseGameTimer(timerWorker, timeRemaining);
});
}
if (!socket.hasListeners(globals.COMMANDS.RESUME_TIMER)) {
socket.on(globals.COMMANDS.RESUME_TIMER, (timeRemaining) => {
this.resumeGameTimer(timeRemaining, globals.CLOCK_TICK_INTERVAL_MILLIS, null, timerWorker);
});
}
if (!socket.hasListeners(globals.COMMANDS.GET_TIME_REMAINING)) {
socket.on(globals.COMMANDS.GET_TIME_REMAINING, (timeRemaining, paused) => {
if (paused) {
this.displayPausedTime(timeRemaining);
} else if (timeRemaining === 0) {
this.displayExpiredTime();
} else {
this.resumeGameTimer(timeRemaining, globals.CLOCK_TICK_INTERVAL_MILLIS, null, timerWorker);
}
});
}
}
swapToPlayButton () {
const currentBtn = document.querySelector('#play-pause img');
if (currentBtn) {
currentBtn.removeEventListener('click', this.pauseListener);
currentBtn.remove();
}
const playBtn = document.createElement('img');
playBtn.setAttribute('src', '../images/play-button.svg');
playBtn.addEventListener('click', this.playListener);
document.getElementById('play-pause').appendChild(playBtn);
}
swapToPauseButton () {
const currentBtn = document.querySelector('#play-pause img');
if (currentBtn) {
currentBtn.removeEventListener('click', this.playListener);
currentBtn.remove();
}
const pauseBtn = document.createElement('img');
pauseBtn.setAttribute('src', '../images/pause-button.svg');
pauseBtn.addEventListener('click', this.pauseListener);
document.getElementById('play-pause').appendChild(pauseBtn);
}
processTimeRemaining (timeRemaining, paused, timerWorker) {
if (paused) {
this.displayPausedTime(timeRemaining);
} else if (timeRemaining === 0) {
this.displayExpiredTime();
} else {
this.resumeGameTimer(timeRemaining, globals.CLOCK_TICK_INTERVAL_MILLIS, null, timerWorker);
}
}
}
function returnHumanReadableTime (milliseconds, tenthsOfSeconds = false) {
const tenths = Math.floor((milliseconds / 100) % 10);
let seconds = Math.floor((milliseconds / 1000) % 60);
let minutes = Math.floor((milliseconds / (1000 * 60)) % 60);
let hours = Math.floor((milliseconds / (1000 * 60 * 60)) % 24);
hours = hours < 10 ? '0' + hours : hours;
minutes = minutes < 10 ? '0' + minutes : minutes;
seconds = seconds < 10 ? '0' + seconds : seconds;
return tenthsOfSeconds
? hours + ':' + minutes + ':' + seconds + '.' + tenths
: hours + ':' + minutes + ':' + seconds;
}

View File

@@ -0,0 +1,33 @@
export const ModalManager = {
displayModal: displayModal,
dispelModal: dispelModal
};
function displayModal (modalId, backgroundId, closeButtonId) {
const modal = document.getElementById(modalId);
const modalOverlay = document.getElementById(backgroundId);
const closeBtn = document.getElementById(closeButtonId);
let closeModalHandler;
if (modal && modalOverlay && closeBtn) {
modal.style.display = 'flex';
modalOverlay.style.display = 'flex';
modalOverlay.removeEventListener('click', closeModalHandler);
modalOverlay.addEventListener('click', closeModalHandler = function (e) {
e.preventDefault();
dispelModal(modalId, backgroundId);
});
closeBtn.removeEventListener('click', closeModalHandler);
closeBtn.addEventListener('click', closeModalHandler);
} else {
throw new Error('One or more of the ids supplied to ModalManager.displayModal is invalid.');
}
}
function dispelModal (modalId, backgroundId) {
const modal = document.getElementById(modalId);
const modalOverlay = document.getElementById(backgroundId);
if (modal && modalOverlay) {
modal.style.display = 'none';
modalOverlay.style.display = 'none';
}
}

View File

@@ -0,0 +1,54 @@
export const injectNavbar = (page = null) => {
if (document.getElementById('navbar') !== null) {
document.getElementById('navbar').innerHTML =
"<button name='Mobile Navbar' aria-label='Mobile Navbar' id=\"navbar-hamburger\" class=\"hamburger hamburger--collapse\" type=\"button\">" +
'<span class="hamburger-box">' +
'<span class="hamburger-inner"></span>' +
'</span>' +
'</button>' +
'<div id="mobile-menu" class="hidden">' +
'<div id="mobile-links">' +
getNavbarLinks(page, 'mobile') +
'</div>' +
'</div>' +
'</a>' +
'<div id="desktop-menu">' +
'<div id="desktop-links">' +
getNavbarLinks(page, 'desktop') +
'</div>' +
'</div>' +
'<div id="navbar-profile"></div>';
}
attachHamburgerListener();
};
function flipHamburger () {
const hamburger = document.getElementById('navbar-hamburger');
if (hamburger.classList.contains('is-active')) {
hamburger.classList.remove('is-active');
document.getElementById('mobile-menu').classList.add('hidden');
document.getElementById('mobile-menu-background-overlay').classList.remove('overlay');
} else {
hamburger.classList.add('is-active');
document.getElementById('mobile-menu-background-overlay').classList.add('overlay');
document.getElementById('mobile-menu').classList.remove('hidden');
}
}
function getNavbarLinks (page = null, device) {
const linkClass = device === 'mobile' ? 'mobile-link' : 'desktop-link';
return '<a href="/" class="logo ' + linkClass + '">' +
'<img alt="logo" src="../images/Werewolf_Small.png"/>' +
'</a>' +
'<a class="' + linkClass + '" href="/">Home</a>' +
'<a class="' + linkClass + '" href="/create">Create</a>' +
'<a class="' + linkClass + '" href="/how-to-use">How to Use</a>' +
'<a class="' + linkClass + ' "href="mailto:play.werewolf.contact@gmail.com?Subject=Werewolf App" target="_top">Contact</a>' +
'<a class="' + linkClass + '" href="https://www.buymeacoffee.com/alecm33">Support the App</a>';
}
function attachHamburgerListener () {
if (document.getElementById('navbar') !== null && document.getElementById('navbar').style.display !== 'none') {
document.getElementById('navbar-hamburger').addEventListener('click', flipHamburger);
}
}

View File

@@ -0,0 +1,8 @@
/* It started getting confusing where I am reading/writing to the game state, and thus the state started to get inconsistent.
Creating a bucket to hold it so I can overwrite the gameState object whilst still preserving a reference to the containing bucket.
Now several components can read a shared game state.
*/
export const stateBucket = {
currentGameState: null,
timerWorker: null
};

View File

@@ -0,0 +1,266 @@
export const templates = {
LOBBY:
"<div id='lobby-header'>" +
'<div>' +
"<label for='game-link'>Share Link</label>" +
"<div id='game-link'></div>" +
'</div>' +
"<div id='game-parameters'>" +
'<div>' +
"<img alt='clock' src='/images/clock.svg'/>" +
"<div id='game-time'></div>" +
'</div>' +
'<div>' +
"<img alt='person' src='/images/person.svg'/>" +
"<div id='game-player-count'></div>" +
'</div>' +
'</div>' +
'<div>' +
"<button id='role-info-button' class='app-button'>View Role Info <img src='/images/info.svg'</button>" +
'</div>' +
'</div>' +
'<div>' +
"<div id='lobby-people-container'>" +
"<label for='lobby-players'>Other People</label>" +
"<div id='lobby-players'></div>" +
'</div>' +
"<div id='lobby-footer'>" +
"<div id='game-deck'></div>" +
'</div>' +
'</div>',
START_GAME_PROMPT:
"<div id='start-game-prompt'>" +
"<button id='start-game-button'>Start Game</button>" +
'</div>',
END_GAME_PROMPT:
"<div id='end-game-prompt'>" +
"<button id='end-game-button'>End Game</button>" +
'</div>',
PLAYER_GAME_VIEW:
"<div id='game-header'>" +
'<div>' +
"<label for='game-timer'>Time Remaining</label>" +
"<div id='game-timer'></div>" +
'</div>' +
'<div>' +
"<button id='role-info-button' class='app-button'>View Role Info <img src='/images/info.svg'</button>" +
'</div>' +
'</div>' +
"<div id='game-role' style='display:none'>" +
"<h4 id='role-name'></h4>" +
"<img alt='role' id='role-image'/>" +
"<p id='role-description'></p>" +
'</div>' +
"<div id='game-role-back'>" +
'<h4>Click to show your role</h4>' +
'<p>(click again to hide)</p>' +
'</div>' +
"<div id='game-people-container'>" +
"<label id='players-alive-label'></label>" +
"<div id='game-player-list'></div>" +
'</div>',
SPECTATOR_GAME_VIEW:
"<div id='game-header'>" +
'<div>' +
"<label for='game-timer'>Time Remaining</label>" +
"<div id='game-timer'></div>" +
'</div>' +
'<div>' +
"<button id='role-info-button' class='app-button'>View Role Info <img src='/images/info.svg'</button>" +
'</div>' +
'</div>' +
"<div id='game-people-container'>" +
"<label id='players-alive-label'></label>" +
"<div id='game-player-list'></div>" +
'</div>',
MODERATOR_GAME_VIEW:
"<div id='transfer-mod-modal-background' class='modal-background' style='display: none'></div>" +
"<div id='transfer-mod-modal' class='modal' style='display: none'>" +
'<h3>Transfer Mod Powers &#128081;</h3>' +
"<div id='transfer-mod-modal-content'></div>" +
"<div class='modal-button-container'>" +
"<button id='close-mod-transfer-modal-button' class='app-button cancel'>Cancel</button>" +
'</div>' +
'</div>' +
"<div id='game-header'>" +
"<div class='timer-container-moderator'>" +
'<div>' +
"<label for='game-timer'>Time Remaining</label>" +
"<div id='game-timer'></div>" +
'</div>' +
"<div id='play-pause'>" + '</div>' +
'</div>' +
"<button id='mod-transfer-button' class='moderator-player-button make-mod-button'>Transfer Mod Powers \uD83D\uDD00</button>" +
'<div>' +
"<button id='role-info-button' class='app-button'>View Role Info <img src='/images/info.svg'</button>" +
'</div>' +
'</div>' +
'<div>' +
"<label id='players-alive-label'></label>" +
"<div id='game-player-list'>" +
"<div class='evil-players'>" +
"<label class='evil'>Team Evil</label>" +
"<div id='player-list-moderator-team-evil'></div>" +
'</div>' +
"<div class='good-players'>" +
"<label class='good'>Team Good</label>" +
"<div id='player-list-moderator-team-good'></div>" +
'</div>' +
'</div>' +
'</div>',
TEMP_MOD_GAME_VIEW:
"<div id='transfer-mod-modal-background' class='modal-background' style='display: none'></div>" +
"<div id='transfer-mod-modal' class='modal' style='display: none'>" +
"<form id='transfer-mod-form'>" +
"<div id='transfer-mod-form-content'>" +
'<h3>Transfer Mod Powers &#128081;</h3>' +
'</div>' +
"<div class='modal-button-container'>" +
"<button id='close-modal-button' class='cancel app-button'>Cancel</button>" +
'</div>' +
'</form>' +
'</div>' +
"<div id='game-header'>" +
"<div class='timer-container-moderator'>" +
'<div>' +
"<label for='game-timer'>Time Remaining</label>" +
"<div id='game-timer'></div>" +
'</div>' +
"<div id='play-pause'>" + '</div>' +
'</div>' +
'<div>' +
"<button id='role-info-button' class='app-button'>View Role Info <img src='/images/info.svg'</button>" +
'</div>' +
'</div>' +
"<div id='game-role' style='display:none'>" +
"<h4 id='role-name'></h4>" +
"<img alt='role' id='role-image'/>" +
"<p id='role-description'></p>" +
'</div>' +
"<div id='game-role-back'>" +
'<h4>Click to show your role</h4>' +
'<p>(click again to hide)</p>' +
'</div>' +
"<div id='game-people-container'>" +
"<label id='players-alive-label'></label>" +
"<div id='game-player-list'></div>" +
'</div>' +
'</div>',
MODERATOR_PLAYER:
'<div>' +
"<div class='game-player-name'></div>" +
"<div class='game-player-role'></div>" +
'</div>' +
"<div class='player-action-buttons'>" +
"<button class='moderator-player-button kill-player-button'>Kill \uD83D\uDC80</button>" +
"<button class='moderator-player-button reveal-role-button'>Reveal <img src='../images/eye.svg'/></button>" +
'</div>',
GAME_PLAYER:
'<div>' +
"<div class='game-player-name'></div>" +
"<div class='game-player-role'></div>" +
'</div>',
INITIAL_GAME_DOM:
"<div id='game-title'></div>" +
"<div id='client-container'>" +
"<label for='client'>You</label>" +
"<div id='client'>" +
"<div id='client-name'></div>" +
"<div id='client-user-type'></div>" +
'</div>' +
'</div>' +
"<div id='game-state-container'></div>",
// via https://loading.io/css/
SPINNER:
'<div class="lds-spinner">' +
'<div></div>' +
'<div></div>' +
'<div></div>' +
'<div></div>' +
'<div></div>' +
'<div></div>' +
'<div></div>' +
'<div></div>' +
'<div></div>' +
'<div></div>' +
'<div></div>' +
'<div></div>' +
'</div>',
NAME_CHANGE_MODAL:
"<div id='change-name-modal-background' class='modal-background'></div>" +
"<div id='change-name-modal' class='modal'>" +
"<form id='change-name-form'>" +
"<div id='transfer-mod-form-content'>" +
"<label for='player-new-name'>Your name:</label>" +
"<input id='player-new-name' type='text'/>" +
'</div>' +
"<div class='modal-button-container'>" +
"<input type='submit' id='submit-new-name' value='Set Name'/>" +
'</div>' +
'</form>' +
'</div>',
ROLE_INFO_MODAL:
"<div id='role-info-modal-background' class='modal-background'></div>" +
"<div id='role-info-modal' class='modal'>" +
'<h2>Roles in this game:</h2>' +
"<div id='game-role-info-container'></div>" +
"<div class='modal-button-container'>" +
"<button id='close-role-info-modal-button' class='app-button'>Close</button>" +
'</div>' +
'</div>',
END_OF_GAME_VIEW:
'<h2>The moderator has ended the game. Roles are revealed.</h2>' +
"<div id='end-of-game-header'>" +
'<div>' +
"<button id='role-info-button' class='app-button'>View Role Info <img src='/images/info.svg'</button>" +
'</div>' +
'<div>' +
"<a href='/'>" +
"<button class='app-button'>Go Home \uD83C\uDFE0</button>" +
'</a>' +
'</div>' +
'</div>' +
"<div id='game-people-container'>" +
"<label id='players-alive-label'></label>" +
"<div id='game-player-list'></div>" +
'</div>',
CREATE_GAME_DECK:
"<div id='deck-container'>" +
'<div>' +
"<label for='deck-good'>Available Good Roles</label>" +
"<div id='deck-good'></div>" +
'</div>' +
'<div>' +
"<label for='deck-evil'>Available Evil Roles</label>" +
"<div id='deck-evil'></div>" +
'</div>' +
'</div>',
CREATE_GAME_CUSTOM_ROLES:
'<div id="custom-roles-container">' +
'<button id="custom-role-hamburger" class="hamburger hamburger--collapse" type="button">' +
'<span class="hamburger-box">' +
'<span class="hamburger-inner"></span>' +
'</span>' +
'</button>' +
'<div id="custom-role-actions" style="display:none">' +
'<div class="custom-role-action" id="custom-roles-export">Export</div>' +
'<div class="custom-role-action" id="custom-roles-import">Import</div>' +
'</div>' +
'<label for="add-card-to-deck-form">Custom Role Box</label>' +
'<div id="deck-select"></div>' +
'<button id="custom-role-btn" class="app-button">+ Create Custom Role</button>' +
'</div>',
CREATE_GAME_DECK_STATUS:
'<div id="deck-status-container">' +
'<div id="deck-count">0 Players</div>' +
'<div id="deck-list"></div>' +
'</div>',
DECK_SELECT_ROLE:
'<div class="deck-select-role-name"></div>' +
'<div class="deck-select-role-options">' +
'<img class="deck-select-include" src="images/add.svg" title="make available" alt="include"/>' +
'<img class="deck-select-info" src="images/info.svg" title="info" alt="info"/>' +
'<img class="deck-select-edit" src="images/pencil.svg" title="edit" alt="edit"/>' +
'<img class="deck-select-remove" src="images/delete.svg" title="remove" alt="remove"/>' +
'</div>'
};

121
client/src/modules/Timer.js Normal file
View File

@@ -0,0 +1,121 @@
/*
A timer using setTimeout that compensates for drift. Drift can happen for several reasons:
https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout#reasons_for_delays
This means the timer may very well be late in executing the next call (but never early).
This timer is accurate to within a few ms for any amount of time provided. It's meant to be utilized as a Web Worker.
See: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API
*/
const messageParameters = {
STOP: 'stop',
TICK_INTERVAL: 'tickInterval',
TOTAL_TIME: 'totalTime'
};
let timer;
onmessage = function (e) {
if (typeof e.data === 'object'
&& e.data.hasOwnProperty(messageParameters.TOTAL_TIME)
&& e.data.hasOwnProperty(messageParameters.TICK_INTERVAL)
) {
timer = new Singleton(e.data.totalTime, e.data.tickInterval);
timer.startTimer();
} else if (e.data === 'stop') {
timer.stopTimer();
}
};
function stepFn (expected, interval, start, totalTime) {
const now = Date.now();
if (now - start >= totalTime) {
postMessage({
timeRemainingInMilliseconds: 0,
displayTime: returnHumanReadableTime(0, true)
});
return;
}
const delta = now - expected;
expected += interval;
const displayTime = (totalTime - (now - start)) < 60000
? returnHumanReadableTime(totalTime - (now - start), true)
: returnHumanReadableTime(totalTime - (now - start));
postMessage({
timeRemainingInMilliseconds: totalTime - (now - start),
displayTime: displayTime
});
Singleton.setNewTimeoutReference(setTimeout(() => {
stepFn(expected, interval, start, totalTime);
}, Math.max(0, interval - delta)
)); // take into account drift - also retain a reference to this clock tick so it can be cleared later
}
class Timer {
constructor (totalTime, tickInterval) {
this.timeoutId = undefined;
this.totalTime = totalTime;
this.tickInterval = tickInterval;
}
startTimer () {
if (!isNaN(this.tickInterval)) {
const interval = this.tickInterval;
const start = Date.now();
const expected = Date.now() + this.tickInterval;
const totalTime = this.totalTime;
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
this.timeoutId = setTimeout(() => {
stepFn(expected, interval, start, totalTime);
}, this.tickInterval);
}
}
stopTimer () {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
}
}
class Singleton {
constructor (totalTime, tickInterval) {
if (!Singleton.instance) {
Singleton.instance = new Timer(totalTime, tickInterval);
} else {
// This allows the same timer to be configured to run for different intervals / at a different granularity.
Singleton.setNewTimerParameters(totalTime, tickInterval);
}
return Singleton.instance;
}
static setNewTimerParameters (totalTime, tickInterval) {
if (Singleton.instance) {
Singleton.instance.totalTime = totalTime;
Singleton.instance.tickInterval = tickInterval;
}
}
static setNewTimeoutReference (timeoutId) {
if (Singleton.instance) {
Singleton.instance.timeoutId = timeoutId;
}
}
}
function returnHumanReadableTime (milliseconds, tenthsOfSeconds = false) {
const tenths = Math.floor((milliseconds / 100) % 10);
let seconds = Math.floor((milliseconds / 1000) % 60);
let minutes = Math.floor((milliseconds / (1000 * 60)) % 60);
let hours = Math.floor((milliseconds / (1000 * 60 * 60)) % 24);
hours = hours < 10 ? '0' + hours : hours;
minutes = minutes < 10 ? '0' + minutes : minutes;
seconds = seconds < 10 ? '0' + seconds : seconds;
return tenthsOfSeconds
? hours + ':' + minutes + ':' + seconds + '.' + tenths
: hours + ':' + minutes + ':' + seconds;
}

View File

@@ -0,0 +1,48 @@
import { globals } from '../config/globals.js';
export const toast = (message, type, positionAtTop = true, dispelAutomatically = true, duration = null) => {
if (message && type) {
buildAndInsertMessageElement(message, type, positionAtTop, dispelAutomatically, duration);
}
};
function buildAndInsertMessageElement (message, type, positionAtTop, dispelAutomatically, duration) {
cancelCurrentToast();
let backgroundColor, border;
const position = positionAtTop ? 'top:2rem;' : 'bottom: 90px;';
switch (type) {
case 'warning':
backgroundColor = '#fff5b1';
border = '3px solid #c7c28a';
break;
case 'error':
backgroundColor = '#fdaeb7';
border = '3px solid #c78a8a';
break;
case 'success':
backgroundColor = '#bef5cb';
border = '3px solid #8ac78a;';
break;
}
const durationInSeconds = duration ? duration + 's' : globals.TOAST_DURATION_DEFAULT + 's';
let animation = '';
if (dispelAutomatically) {
animation = 'animation:fade-in-slide-down-then-exit ' + durationInSeconds + ' ease normal forwards';
} else {
animation = 'animation:fade-in-slide-down ' + durationInSeconds + ' ease normal forwards';
}
const messageEl = document.createElement('div');
messageEl.setAttribute('id', 'current-info-message');
messageEl.setAttribute('style', 'background-color:' + backgroundColor + ';' + 'border:' + border + ';' + position + animation);
messageEl.setAttribute('class', 'info-message');
messageEl.innerText = message;
document.body.prepend(messageEl);
}
export function cancelCurrentToast () {
const currentMessage = document.getElementById('current-info-message');
if (currentMessage !== null) {
currentMessage.remove();
}
}

Some files were not shown because too many files have changed in this diff Show More