0
0
Fork 0

Merge pull request #23 from Oliver-Akins/dev

Initial Server Version
This commit is contained in:
Oliver 2021-01-05 10:38:41 -07:00 committed by GitHub
commit 20ab8d61a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 13661 additions and 0 deletions

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
#=============================================================================#
# The files added manually by Oliver
node_modules
.vscode
server/server.toml
server/built/*
server/dist/*
#=============================================================================#
# The files that were auto-generated into a .gitignore by Vue-cli
/web/.DS_Store
/web/node_modules
/web/dist
# local env files
/web/.env.local
/web/.env.*.local
# Log files
/web/npm-debug.log*
/web/yarn-debug.log*
/web/yarn-error.log*
/web/pnpm-debug.log*
#=============================================================================#

View file

@ -0,0 +1,15 @@
# `CreateGame`:
## Description:
Triggered when a user is creating a game.
## Request Payload:
| Property | Type | Description
| -------- | ---- | -----------
| name | String | The name of the person starting the game
## Response Payload: (`GameCreated`)
| Property | Type | Description
| -------- | ---- | -----------
| id | String | The user's game ID, this should be stored to permit users to re-join the game if they get disconnected.
| game_code | String | The game's code that other players can use to connect to the game.

View file

@ -0,0 +1,6 @@
# Properties Of All Response Payloads
| Property | Type | Description
| -------- | ---- | -----------
| status | Integer | The response code of the server. This follows HTTP standards as described by [Mozilla's Documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status)
| message? | String | More information relating to the response. This is set iff `success` is a non 2XX value.
| source? | String | The event name that caused this response to be sent. This is set iff is a non 2XX value.

View file

@ -0,0 +1,14 @@
# `GetPastQuestions`:
## Description:
This event is sent from the client to the server when it has the PastQuestions component visible.
## Request Payload:
| Property | Type | Description
| -------- | ---- | -----------
| team | Integer | The team we are requesting the questions for. This can be one of `1`, or `2`, any other value will cause an error to be returned.
## Response Payload: (`PastQuestions`)
| Property | Type | Description
| -------- | ---- | -----------
| questions | String[] | All the previously chosen questions for the team.

View file

@ -0,0 +1,16 @@
# `JoinGame`:
## Description:
Triggered by the client when it attempts to connect to a game.
## Request Payload:
| Property | Type | Description
| -------- | ---- | -----------
| name | String | The user's name
| game_code | String | The game code for the game the user is trying to join.
| id? | String | The user's ID that was stored if they have already joined a game and not finished it.
## Response Payload: (`GameJoined`)
| Property | Type | Description
| -------- | ---- | -----------
| id | String | The user's game ID, if `id` is set in the payload, this is the same, otherwise it is a brand new ID.

View file

@ -0,0 +1,12 @@
# `NewHand`:
## Description:
This is the event client sends it requests a new 7 cards for the hand.
## Request Payload:
| Property | Type | Description
| -------- | ---- | -----------
| team | Integer | The team that is requesting a new hand.
## Response Payload:
This event's response comes in the form of the `UpdateHand` event with the mode set to `"replace"`

View file

@ -0,0 +1,13 @@
# `ObjectList`:
## Description:
This event is sent by the client when they are wanting to retrieve the list of
objects that the spirits can pick from.
## Request Payload:
No payload is read from for this event.
## Response Payload: (`ObjectList`)
| Property | Type | Description
| -------- | ---- | -----------
| objects | String[] | The objects that are on the chosen card.

View file

@ -0,0 +1,14 @@
# `SelectObject`:
## Description:
The event sent by the clients when they are selecting an object from the card.
## Request Payload:
| Property | Type | Description
| -------- | ---- | -----------
| object | String | The name of the object that the spirits are selecting.
## Response Payload: (`ChosenObject`)
| Property | Type | Description
| -------- | ---- | -----------
| object | String | The object that has been selected.

View file

@ -0,0 +1,15 @@
# `SendCard`:
## Description:
Sends a card to the server, the server either then either forwards the card to the spirit or the past questions pile. The action that is taken is dependant on what the `from` property is set to.
## Request Payload:
| Property | Type | Description
| -------- | ---- | -----------
| game_code | String | The game code for the player.
| text | String | The text of the card that is being sent.
| from | String | The source of where the card is being sent from. This can be one of `"writer"`, `"guesser"`, any other value will return an error.
| team | Integer | The team that is sending the card. This can be either `1` or `2`, any other value will return an error.
## Response Payload: (`NewCards`)
The response to this event comes in the form of an `UpdateHand` event with the `mode` set to `"append"`.

View file

@ -0,0 +1,18 @@
# `UpdateAnswer`:
## Description:
This event is sent to and from the server when a spirit is typing into one of the text boxes.
## Request Payload:
| Property | Type | Description
| -------- | ---- | -----------
| team | Integer | The team indicator number. This can be either `1`, or `2`, any other value will cause this event to return an error.
| answer | Integer | The answer that is being updated. This can be any number between `1` and `8` (inclusive), any other value will cause this event return an error.
| value | String | The new text for the input box.
## Response Payload: (`UpdateAnswer`)
| Property | Type | Description
| -------- | ---- | -----------
| team | Integer | The team indicator number. This can be either `1`, or `2`.
| answer | Integer | The answer that is being updated. This can be any number between `1` and `8` (inclusive).
| value | String | The new text for the input box.

View file

@ -0,0 +1,13 @@
# `UpdateHand`:
## Description:
Tells the client to update their hand, using one of the provided modes.
## Request Payload:
This event is never sent to the server
## Response Payload:
| Property | Type | Description
| -------- | ---- | -----------
| questions | String[] | The cards that the operation for the hand will use.
| mode | String | This is one of `"append"` or `"replace"`

View file

@ -0,0 +1,28 @@
# `UpdatePlayer`:
## Description:
This event is sent as a result of a player joining/changing team, this event is also fired when a new player joins the game. The client sends the event to update what team/role they are on.
## Request Payload:
| Property | Type | Description
| -------- | ---- | -----------
| action | String | The action to take on the player. This can be one of `"modify"`, or `"remove"`, any other value will throw an error.
| name | String | The player's name
| to | PlayerData | The player's new data.
| from | PlayerData | The player's old data.
### `PlayerData`:
The below table describes the properties for the player data object.
| Property | Type | Description
| -------- | ---- | -----------
| team | Integer | The team to join. Accepted values are: `1`, and `2`, any other value will throw an error.
| role | String | The role the player is assuming. This can be `"writer"` or `"guesser"`, any other value will throw an error.
## Response Payload:
| Property | Type | Description
| -------- | ---- | -----------
| action | String | The action that we are responding, this can be one of `"new"`, `"modify"`, or `"remove"`.
| name | String | The name of the player that is being updated.
| role | String | The role the user is becoming. This can be `"writer"`, or `"guesser"`, and will only be set if the `action` is set to `modify`.
| team | String | The team that the user is joining. This can be `1`, or `2`, and will only be set if the `action` is set to `modify`.

View file

@ -0,0 +1,13 @@
# ``:
## Description:
## Request Payload:
| Property | Type | Description
| -------- | ---- | -----------
## Response Payload: (``)
| Property | Type | Description
| -------- | ---- | -----------

View file

@ -0,0 +1,8 @@
## Deck<T>
This represents a stack of cards that can be drawn from and discarded to.
| Property | Type | Description
| -------- | ---- | -----------
| _discard | T[] | The used cards from the deck.
| _unknown | T[] | The cards that are neither in the deck or the discard.
| (get) size | Integer | The number of the cards that are left in the deck.

16
server/docs/types/Game.md Normal file
View file

@ -0,0 +1,16 @@
## Game:
This is a representation of the game and it's corresponding data.
| Property | Type | Description
| -------- | ---- | -----------
| id | String | The game's ID to join with.
| host | Player | The player who created the game.
| ingame | Boolean | Whether or not this game is being played or is in the lobby.
| teams | Team[] | The teams that are a part of this game.
| players | Player[] | All of the players that are in this game.
| _questons | Deck | The deck and discard of the question cards.
| _objects | Deck | The deck and discard of the object cards.
| _objectCard | String[] | The card that was drawn from the deck for the spirits to choose an object from.
| object | String | The chosen object that the spirits decided on.
| (get) questions | Deck | Returns the deck from the private attribute.
| (get) objects | String[] | Returns the objects from the card that was drawn for the game.

View file

@ -0,0 +1,9 @@
## Player:
A player's information for the game.
| Property | Type | Description
| -------- | ---- | -----------
| name | String | The player's name.
| socket | Socket | The socket object used to send events directly to the player.
| isHost | Boolean | Whether or not the player is the host of the game.
| connected | Boolean | Whether or not the socket is connected.

13
server/docs/types/Team.md Normal file
View file

@ -0,0 +1,13 @@
## Team
A representation of a team, this consists of which player is the spirit and a complete list of all the players on the team,
| Property | Type | Description
| -------- | ---- | -----------
| guessers | Player[] | All the Players that are on this team.
| writer | Player | The player that is acting as the team's spirit.
| _hand | String[] | The cards that are in the medium's hand.
| _questions | String[] | The questions that the mediums have asked the spirit. (This is not equivalent to the Spirit's hand)
| _answers | String[] | The answers that the spirit has given.
| (get) hand | String[] | Returns the medium's hand.
| (get) answers | String[] | Returns the team's answers.
| (get) questions | String[] | Returns the team's questions.

24
server/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "server",
"version": "0.1.0",
"description": "",
"main": "index.js",
"directories": {
"doc": "docs"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Oliver Akins",
"license": "UNLICENSED",
"dependencies": {
"@types/engine.io": "^3.1.4",
"@types/node": "^14.14.14",
"@types/socket.io": "^2.1.12",
"fs": "^0.0.1-security",
"neat-csv": "^6.0.0",
"socket.io": "^3.0.4",
"toml": "^3.0.0",
"tslog": "^3.0.2"
}
}

273
server/pnpm-lock.yaml generated Normal file
View file

@ -0,0 +1,273 @@
dependencies:
'@types/engine.io': 3.1.4
'@types/node': 14.14.14
'@types/socket.io': 2.1.12
fs: 0.0.1-security
neat-csv: 6.0.0
socket.io: 3.0.4
toml: 3.0.0
tslog: 3.0.2
lockfileVersion: 5.2
packages:
/@types/component-emitter/1.2.10:
dev: false
resolution:
integrity: sha512-bsjleuRKWmGqajMerkzox19aGbscQX5rmmvvXl3wlIp5gMG1HgkiwPxsN5p070fBDKTNSPgojVbuY1+HWMbFhg==
/@types/cookie/0.4.0:
dev: false
resolution:
integrity: sha512-y7mImlc/rNkvCRmg8gC3/lj87S7pTUIJ6QGjwHR9WQJcFs+ZMTOaoPrkdFA/YdbuqVEmEbb5RdhVxMkAcgOnpg==
/@types/cors/2.8.9:
dev: false
resolution:
integrity: sha512-zurD1ibz21BRlAOIKP8yhrxlqKx6L9VCwkB5kMiP6nZAhoF5MvC7qS1qPA7nRcr1GJolfkQC7/EAL4hdYejLtg==
/@types/engine.io/3.1.4:
dependencies:
'@types/node': 14.14.14
dev: false
resolution:
integrity: sha512-98rXVukLD6/ozrQ2O80NAlWDGA4INg+tqsEReWJldqyi2fulC9V7Use/n28SWgROXKm6003ycWV4gZHoF8GA6w==
/@types/node/14.14.14:
dev: false
resolution:
integrity: sha512-UHnOPWVWV1z+VV8k6L1HhG7UbGBgIdghqF3l9Ny9ApPghbjICXkUJSd/b9gOgQfjM1r+37cipdw/HJ3F6ICEnQ==
/@types/socket.io-parser/2.2.1:
dependencies:
'@types/node': 14.14.14
dev: false
resolution:
integrity: sha512-+JNb+7N7tSINyXPxAJb62+NcpC1x/fPn7z818W4xeNCdPTp6VsO/X8fCsg6+ug4a56m1v9sEiTIIUKVupcHOFQ==
/@types/socket.io/2.1.12:
dependencies:
'@types/engine.io': 3.1.4
'@types/node': 14.14.14
'@types/socket.io-parser': 2.2.1
dev: false
resolution:
integrity: sha512-oStc5VFkpb0AsjOxQUj9ztX5Iziatyla/rjZTYbFGoVrrKwd+JU2mtxk7iSl5RGYx9WunLo6UXW1fBzQok/ZyA==
/accepts/1.3.7:
dependencies:
mime-types: 2.1.28
negotiator: 0.6.2
dev: false
engines:
node: '>= 0.6'
resolution:
integrity: sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
/base64-arraybuffer/0.1.4:
dev: false
engines:
node: '>= 0.6.0'
resolution:
integrity: sha1-mBjHngWbE1X5fgQooBfIOOkLqBI=
/base64id/2.0.0:
dev: false
engines:
node: ^4.5.0 || >= 5.9
resolution:
integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
/buffer-from/1.1.1:
dev: false
resolution:
integrity: sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
/component-emitter/1.3.0:
dev: false
resolution:
integrity: sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
/cookie/0.4.1:
dev: false
engines:
node: '>= 0.6'
resolution:
integrity: sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
/cors/2.8.5:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
dev: false
engines:
node: '>= 0.10'
resolution:
integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
/csv-parser/3.0.0:
dependencies:
minimist: 1.2.5
dev: false
engines:
node: '>= 10'
hasBin: true
resolution:
integrity: sha512-s6OYSXAK3IdKqYO33y09jhypG/bSDHPuyCme/IdEHfWpLf/jKcpitVFyOC6UemgGk8v7Q5u2XE0vvwmanxhGlQ==
/debug/4.1.1:
dependencies:
ms: 2.1.3
deprecated: 'Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)'
dev: false
resolution:
integrity: sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
/engine.io-parser/4.0.2:
dependencies:
base64-arraybuffer: 0.1.4
dev: false
engines:
node: '>=8.0.0'
resolution:
integrity: sha512-sHfEQv6nmtJrq6TKuIz5kyEKH/qSdK56H/A+7DnAuUPWosnIZAS2NHNcPLmyjtY3cGS/MqJdZbUjW97JU72iYg==
/engine.io/4.0.5:
dependencies:
accepts: 1.3.7
base64id: 2.0.0
cookie: 0.4.1
cors: 2.8.5
debug: 4.1.1
engine.io-parser: 4.0.2
ws: 7.4.1
dev: false
engines:
node: '>=10.0.0'
resolution:
integrity: sha512-Ri+whTNr2PKklxQkfbGjwEo+kCBUM4Qxk4wtLqLrhH+b1up2NFL9g9pjYWiCV/oazwB0rArnvF/ZmZN2ab5Hpg==
/fs/0.0.1-security:
dev: false
resolution:
integrity: sha1-invTcYa23d84E/I4WLV+yq9eQdQ=
/get-stream/6.0.0:
dev: false
engines:
node: '>=10'
resolution:
integrity: sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg==
/mime-db/1.45.0:
dev: false
engines:
node: '>= 0.6'
resolution:
integrity: sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w==
/mime-types/2.1.28:
dependencies:
mime-db: 1.45.0
dev: false
engines:
node: '>= 0.6'
resolution:
integrity: sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ==
/minimist/1.2.5:
dev: false
resolution:
integrity: sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
/ms/2.1.3:
dev: false
resolution:
integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
/neat-csv/6.0.0:
dependencies:
csv-parser: 3.0.0
get-stream: 6.0.0
to-readable-stream: 2.1.0
dev: false
engines:
node: '>=10'
resolution:
integrity: sha512-Nouw+x6hJzKAOvAevtYQ9QsG0EYHoKBz1H2rKxBj+vxiR9Ya27MXjjHKJ4txXF1T0x3CXuH5A9/5VDnd8MdJmg==
/negotiator/0.6.2:
dev: false
engines:
node: '>= 0.6'
resolution:
integrity: sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
/object-assign/4.1.1:
dev: false
engines:
node: '>=0.10.0'
resolution:
integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
/socket.io-adapter/2.0.3:
dev: false
resolution:
integrity: sha512-2wo4EXgxOGSFueqvHAdnmi5JLZzWqMArjuP4nqC26AtLh5PoCPsaRbRdah2xhcwTAMooZfjYiNVNkkmmSMaxOQ==
/socket.io-parser/4.0.2:
dependencies:
'@types/component-emitter': 1.2.10
component-emitter: 1.3.0
debug: 4.1.1
dev: false
engines:
node: '>=10.0.0'
resolution:
integrity: sha512-Bs3IYHDivwf+bAAuW/8xwJgIiBNtlvnjYRc4PbXgniLmcP1BrakBoq/QhO24rgtgW7VZ7uAaswRGxutUnlAK7g==
/socket.io/3.0.4:
dependencies:
'@types/cookie': 0.4.0
'@types/cors': 2.8.9
'@types/node': 14.14.14
accepts: 1.3.7
base64id: 2.0.0
debug: 4.1.1
engine.io: 4.0.5
socket.io-adapter: 2.0.3
socket.io-parser: 4.0.2
dev: false
engines:
node: '>=10.0.0'
resolution:
integrity: sha512-Vj1jUoO75WGc9txWd311ZJJqS9Dr8QtNJJ7gk2r7dcM/yGe9sit7qOijQl3GAwhpBOz/W8CwkD7R6yob07nLbA==
/source-map-support/0.5.19:
dependencies:
buffer-from: 1.1.1
source-map: 0.6.1
dev: false
resolution:
integrity: sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
/source-map/0.6.1:
dev: false
engines:
node: '>=0.10.0'
resolution:
integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
/to-readable-stream/2.1.0:
dev: false
engines:
node: '>=8'
resolution:
integrity: sha512-o3Qa6DGg1CEXshSdvWNX2sN4QHqg03SPq7U6jPXRahlQdl5dK8oXjkU/2/sGrnOZKeGV1zLSO8qPwyKklPPE7w==
/toml/3.0.0:
dev: false
resolution:
integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==
/tslog/3.0.2:
dependencies:
source-map-support: 0.5.19
dev: false
engines:
node: '>=10'
resolution:
integrity: sha512-cXGmeiVkqI/uUK+4C6ZZAUTfXzKWDXRmrOqFjzwpiO/VnPUNUvOAbmebCc6AqJIlSfKPG139ayMEV/nOuCwCHw==
/vary/1.1.2:
dev: false
engines:
node: '>= 0.8'
resolution:
integrity: sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
/ws/7.4.1:
dev: false
engines:
node: '>=8.3.0'
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ^5.0.2
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
resolution:
integrity: sha512-pTsP8UAfhy3sk1lSk/O/s4tjD0CRwvMnzvwr4OKGX7ZvqZtUyx4KIJB5JWbkykPoc55tixMGgTNoh3k4FkNGFQ==
specifiers:
'@types/engine.io': ^3.1.4
'@types/node': ^14.14.14
'@types/socket.io': ^2.1.12
fs: ^0.0.1-security
neat-csv: ^6.0.0
socket.io: ^3.0.4
toml: ^3.0.0
tslog: ^3.0.2

View file

@ -0,0 +1,21 @@
Answer 1,Answer 2,Answer 3,Answer 4,Answer 5
microphone,lamp,chair,crown,brain
screwdriver,chainsaw,sunglasses,dinosaur,tree
flag,ship,pie,pineapple,helmet
ruler,pen,guitar,skateboard,tomato
bathtub,computer,whale,telescope,submarine
flower,blanket,pacifier,harp,acorn
window,doll,stove,skeleton,vacuum
printer,shoe,bed,fork,shield
catapult,apple,envelope,broom,windmill
flashlight,pyramid,brick,bicycle,truck
train,horse,knife,sponge,shirt
newspaper,clock,basketball,mirror,backpack
phone,car,dragon,platypus,scissors
broccoli,shovel,scorpion,sandwich,piano
pillow,pencil,ladder,laptop,headphones
tent,toothbrush,hammer,coin,peanut
bottle,key,pants,fish,pizza
bowl,bread,wheelchair,rope,giraffe
toilet,house,cloak,soap,banana
painting,tractor,sword,book,umbrella
1 Answer 1 Answer 2 Answer 3 Answer 4 Answer 5
2 microphone lamp chair crown brain
3 screwdriver chainsaw sunglasses dinosaur tree
4 flag ship pie pineapple helmet
5 ruler pen guitar skateboard tomato
6 bathtub computer whale telescope submarine
7 flower blanket pacifier harp acorn
8 window doll stove skeleton vacuum
9 printer shoe bed fork shield
10 catapult apple envelope broom windmill
11 flashlight pyramid brick bicycle truck
12 train horse knife sponge shirt
13 newspaper clock basketball mirror backpack
14 phone car dragon platypus scissors
15 broccoli shovel scorpion sandwich piano
16 pillow pencil ladder laptop headphones
17 tent toothbrush hammer coin peanut
18 bottle key pants fish pizza
19 bowl bread wheelchair rope giraffe
20 toilet house cloak soap banana
21 painting tractor sword book umbrella

View file

@ -0,0 +1,82 @@
Question Text
Where would I be most likely to find it?
What continent/region would I find the most of these?
What country am I most likely to find it in?
What city am I most likely to find it in?
Where in my house am I most likely to find it?
What kind of store am I most likely to find it in?
What era did it first appear in?
What's something that's about as dangerous as it?
What's a variety it comes in?
What subcategory does it fall under?
What's something that's like it?
What color is it most commonly?
What material is it made of?
What does it feel like if I touch it?
How is it made?
What is it used for?
What's a common brand of it?
What's it about the same size as?
What's it about the same weight as?
What does it cost about the same as?
What would happen if I ate it?
What's your favorite version/variety of it?
Who's a fictional character that has/uses it?
Who's a celebrity that has/uses it?
What kind of people commonly have or use it?
What abstract concept is associated with it?
What does it smell like?
What does it taste like?
What noise does it make when dropped?
Who at this table likes it the most?
"If it was an animal, what animal would it be?"
What's a book or movie/TV that it appears prominently in?
What age group likes it the most?
How do you feel about it?
Who dislikes it?
What time of day is it used?
What do I wear when I use it?
Why do people use it?
With what frequency do most people use it?
What part of the body do I use it with?
What do I clean it with?
How is it transported?
Where is it stored?
Where do you use it?
What holiday is it most associated with?
What would I use to write on it?
What's a game that it appears in?
What climate is it most associated with?
What website can I find it on?
How long will it last if left alone?
How does it affect the things around it?
Where would I get one?
How would it react to being pushed down a hill?
What happens if I light it on fire?
What happens if I put it under water?
What superpower would this thing want?
How would I use it as a weapon?
How do I feel after using it?
What would happen if I bent it?
What would I use to destroy it?
What's inside it?
What's its opposite?
What profession works with it?
"If it were a musical instrument, what instrument would it be?"
"If it was a social media website, what site would it be?"
What genre of movie/book is most likely to feature it?
"If it had a favorite food, what would it be?"
What's something you can make using it?
What powers it?
What container would I keep it in?
What's the secondary material it's made of? (not its main material)
Who or what can lift it?
What ancient Greek/Roman god is it most associated with?
What habitat would I be most like to find it in?
What is a group of them called?
Who or what makes it?
What field of science studies it?
"Where does it go when it dies, breaks, or is no longer useful?"
What superhero is it most related to?
What month would I find it or use it in?
"If it was an alcoholic drink, what would it be?"
1 Question Text
2 Where would I be most likely to find it?
3 What continent/region would I find the most of these?
4 What country am I most likely to find it in?
5 What city am I most likely to find it in?
6 Where in my house am I most likely to find it?
7 What kind of store am I most likely to find it in?
8 What era did it first appear in?
9 What's something that's about as dangerous as it?
10 What's a variety it comes in?
11 What subcategory does it fall under?
12 What's something that's like it?
13 What color is it most commonly?
14 What material is it made of?
15 What does it feel like if I touch it?
16 How is it made?
17 What is it used for?
18 What's a common brand of it?
19 What's it about the same size as?
20 What's it about the same weight as?
21 What does it cost about the same as?
22 What would happen if I ate it?
23 What's your favorite version/variety of it?
24 Who's a fictional character that has/uses it?
25 Who's a celebrity that has/uses it?
26 What kind of people commonly have or use it?
27 What abstract concept is associated with it?
28 What does it smell like?
29 What does it taste like?
30 What noise does it make when dropped?
31 Who at this table likes it the most?
32 If it was an animal, what animal would it be?
33 What's a book or movie/TV that it appears prominently in?
34 What age group likes it the most?
35 How do you feel about it?
36 Who dislikes it?
37 What time of day is it used?
38 What do I wear when I use it?
39 Why do people use it?
40 With what frequency do most people use it?
41 What part of the body do I use it with?
42 What do I clean it with?
43 How is it transported?
44 Where is it stored?
45 Where do you use it?
46 What holiday is it most associated with?
47 What would I use to write on it?
48 What's a game that it appears in?
49 What climate is it most associated with?
50 What website can I find it on?
51 How long will it last if left alone?
52 How does it affect the things around it?
53 Where would I get one?
54 How would it react to being pushed down a hill?
55 What happens if I light it on fire?
56 What happens if I put it under water?
57 What superpower would this thing want?
58 How would I use it as a weapon?
59 How do I feel after using it?
60 What would happen if I bent it?
61 What would I use to destroy it?
62 What's inside it?
63 What's its opposite?
64 What profession works with it?
65 If it were a musical instrument, what instrument would it be?
66 If it was a social media website, what site would it be?
67 What genre of movie/book is most likely to feature it?
68 If it had a favorite food, what would it be?
69 What's something you can make using it?
70 What powers it?
71 What container would I keep it in?
72 What's the secondary material it's made of? (not its main material)
73 Who or what can lift it?
74 What ancient Greek/Roman god is it most associated with?
75 What habitat would I be most like to find it in?
76 What is a group of them called?
77 Who or what makes it?
78 What field of science studies it?
79 Where does it go when it dies, breaks, or is no longer useful?
80 What superhero is it most related to?
81 What month would I find it or use it in?
82 If it was an alcoholic drink, what would it be?

View file

@ -0,0 +1,34 @@
import { Game } from '../objects/Game';
import { Player } from '../objects/Player';
import { Server, Socket } from 'socket.io';
import { conf, games, log } from '../main';
export default (io: Server, socket: Socket, data: CreateGame) => {
try {
let host = new Player(data.name, socket, true);
// Create the game object to save
let game = new Game(conf, host);
games[game.id] = game;
game.players.push(host);
game.log = log.getChildLogger({
displayLoggerName: true,
name: game.id,
})
game.log.info(`New game created (host=${host.name})`);
socket.join(game.id);
socket.emit(`GameCreated`, {
status: 200,
game_code: game.id,
players: game.playerData,
});
}
catch (err) {
socket.emit(`GameCreated`, {
status: 500,
message: err.message,
source: `CreateGame`,
});
}
};

View file

@ -0,0 +1,45 @@
import { games, log } from '../main';
import { Server, Socket } from 'socket.io';
export default (io: Server, socket: Socket, data: DeleteGame) => {
try {
// Ensure game exists
if (!games[data.game_code]) {
log.debug(`Can't delete game that doesn't exist: ${data.game_code}`);
socket.emit(`GameDeleted`, {
status: 404,
message: `Game with code ${data.game_code} could not be found`,
source: `DeleteGame`
});
return;
};
let game = games[data.game_code];
// Ensure user is the host
let player = game.players.find(x => x.isHost);
if (player != null && player.socket !== socket) {
game.log.warn(`${player.name} attempted to delete game.`);
socket.emit(`GameDeleted`, {
status: 403,
message: `Not allowed to delete a game that you are not the host of.`,
source: `DeleteGame`
});
return;
};
// Delete game
game.log.debug(`Game deleted.`)
delete games[data.game_code];
io.to(game.id).emit(`GameDeleted`, { status: 200 });
}
catch (err) {
socket.emit(`GameDeleted`, {
status: 500,
message: err.message,
source: `DeleteGame`,
});
};
};

View file

@ -0,0 +1,32 @@
import { Server, Socket } from 'socket.io';
import { games, log } from '../main';
export default (io: Server, socket: Socket, data: GetHand) => {
try {
if (!games[data.game_code]) {
log.debug(`Can't find game with code: ${data.game_code}`);
socket.emit(`UpdateHand`, {
status: 404,
message: `Can't find game with code: ${data.game_code}`,
source: `GetHand`
});
return;
};
let game = games[data.game_code];
let hand = game.teams[data.team - 1].hand;
game.log.silly(`Client requested guesser hand`);
socket.emit(`UpdateHand`, {
status: 200,
mode: "replace",
questions: hand
});
}
catch (err) {
socket.emit(`QuestionList`, {
status: 500,
message: `${err.name}: ${err.message}`,
source: `GetQuestions`,
});
}
};

View file

@ -0,0 +1,33 @@
import { games, log } from '../main';
import { Server, Socket } from 'socket.io';
export default (io: Server, socket: Socket, data: GetPastQuestions) => {
try {
// Assert game exists
if (!games[data.game_code]) {
log.debug(`Can't find game with code: ${data.game_code}`);
socket.emit(`PastQuestions`, {
status: 404,
message: `Game with code ${data.game_code} could not be found`,
source: `GetPastQuestions`
});
return;
};
let game = games[data.game_code];
let team = game.teams[data.team - 1];
game.log.silly(`Past questions retrieved for team ${data.team}`);
socket.emit(`PastQuestions`, {
status: 200,
questions: team.questions
});
}
catch (err) {
socket.emit(`PastQuestions`, {
status: 500,
message: `${err.name}: ${err.message}`,
source: `GetPastQuestions`,
});
}
};

View file

@ -0,0 +1,86 @@
import { games, log } from '../main';
import { Player } from '../objects/Player';
import { Server, Socket } from 'socket.io';
export default (io: Server, socket: Socket, data: JoinGame) => {
try {
// Assert game exists
if (!games[data.game_code]) {
log.debug(`Can't join game that doesn't exist: ${data.game_code}`);
socket.emit(`GameJoined`, {
status: 404,
message: `Game with code "${data.game_code}" could not be found`,
source: `JoinGame`
});
return;
};
let game = games[data.game_code];
// Ensure no one has the same name as the player that is joining
let sameName = game.players.find(x => x.name == data.name);
if (sameName != null) {
if (!game.ingame) {
game.log.info(`Client attempted to connect using name already in use.`);
socket.emit(`GameJoined`, {
status: 400,
message: `A player already has that name in the game.`,
source: `JoinGame`
});
return;
};
// Player has the same name but is allowed to rejoin if they
// disconnect in the middle of the game
if (!sameName.socket.connected) {
game.log.info(`Player Reconnected to the game (name=${data.name})`);
socket.emit(`GameRejoined`, { status: 200 });
return;
} else {
game.log.debug(`${socket.id} attempted to claim ${sameName.socket.id}'s game spot.`);
socket.emit(`GameJoined`, {
status: 403,
message: `Can't connect to an already connected client`,
source: `JoinGame`
});
return;
};
};
// Assert game is not in-progess
if (game.ingame) {
game.log.debug(`${data.name} tried to connect in the middle of a game.`);
socket.emit(`GameJoined`, {
status: 403,
message: `Cannot connect to a game that's in progress.`,
source: `JoinGame`
});
return;
};
let player = new Player(data.name, socket);
game.players.push(player);
game.log.debug(`${data.name} joined the game`);
socket.join(game.id);
socket.emit(`GameJoined`, {
status: 200,
players: game.playerData,
});
socket.to(game.id).emit(`PlayerUpdate`, {
status: 200,
action: "new",
name: player.name,
players: game.playerData,
});
}
catch (err) {
socket.emit(`GameJoined`, {
status: 500,
message: `${err.name}: ${err.message}`,
source: `JoinGame`,
});
}
};

View file

@ -0,0 +1,73 @@
import { games, log } from '../main';
import { Server, Socket } from 'socket.io';
export default (io: Server, socket: Socket, data: LeaveGame) => {
try {
// Assert game exists
if (!games[data.game_code]) {
log.debug(`Could not find a game with ID ${data.game_code} to leave`);
socket.emit(`GameJoined`, {
status: 404,
message: `Game with code "${data.game_code}" could not be found`,
source: `JoinGame`,
});
return;
};
let game = games[data.game_code];
// Ensure it's not the host trying to leave so that the game can
// actually start
if (game.host.socket == socket) {
game.log.debug(`Host attempted to leave game. (name=${game.host.name})`);
socket.emit(`GameLeft`, {
status: 303,
message: `Can't leave the game as the host, use "DeleteGame".`,
source: `LeaveGame`,
});
return;
};
let player = game.players.find(p => p.socket === socket);
// Ensure we found a player
if (player != null) {
let role = player.role;
let teamID = player.team;
// Ensure the team is defined
if (teamID) {
let team = game.teams[teamID - 1];
switch (role) {
case "guesser":
game.log.silly(`Removed ${player.name} from guesser role.`);
team.guessers = team.guessers.filter(p => p.socket !== socket);
break;
case "writer":
game.log.silly(`Removed ${player.name} from writer role.`);
team.writer = null;
break;
};
};
game.players = game.players.filter(p => p.socket != socket);
game.log.debug(`${player.name} left the game.`);
socket.to(game.id).emit(`PlayerUpdate`, {
status: 200,
action: `remove`,
players: game.playerData,
});
socket.emit(`GameLeft`, { status: 200 });
};
}
catch (err) {
socket.emit(`GameLeft`, {
status: 500,
message: `${err.name}: ${err.message}`,
source: ``,
});
}
};

View file

@ -0,0 +1,45 @@
import { conf, games, log } from '../main';
import { Server, Socket } from 'socket.io';
export default (io: Server, socket: Socket, data: NewHand) => {
try {
// Assert game exists
if (!games[data.game_code]) {
log.debug(`Can't find game with code: ${data.game_code}`);
socket.emit(`UpdateHand`, {
status: 404,
message: `Game with code ${data.game_code} could not be found`,
source: `NewHand`
});
return;
};
let game = games[data.game_code];
let team = game.teams[data.team - 1];
let deck = game.questions;
// Empty the medium's hand to the discard pile so we know where the
// cards are.
for (var card of team.hand) {
deck.discard(card);
team.removeCard(card);
game.log.silly(`Removing card: '${card}' from team ${data.team}'s hand.`);
};
// Add the questions and then alert the game clients about the changes
team.addCardsToHand(deck.draw(conf.game.hand_size));
game.log.silly(`Drew a new hand of cards for team ${data.team}.`);
io.to(game.id).emit(`UpdateHand`, {
status: 200,
mode: `replace`,
questions: team.hand,
});
}
catch (err) {
socket.emit(`UpdateHand`, {
status: 500,
message: `${err.name}: ${err.message}`,
source: `NewHand`,
});
}
};

View file

@ -0,0 +1,31 @@
import { games, log } from '../main';
import { Server, Socket } from 'socket.io';
export default (io: Server, socket: Socket, data: ObjectList) => {
try {
// Assert game exists
if (!games[data.game_code]) {
log.debug(`Can't get objects for game that doesn't exist: ${data.game_code}`);
socket.emit(`Error`, {
status: 404,
message: `Game with code ${data.game_code} could not be found`,
source: `ObjectList`
});
return;
};
let game = games[data.game_code];
game.log.silly(`Sent client object card`);
socket.emit(`ObjectList`, {
objects: game.objects
});
}
catch (err) {
socket.emit(`Error`, {
status: 500,
message: `${err.name}: ${err.message}`,
source: `ObjectList`,
});
}
};

View file

@ -0,0 +1,44 @@
import { games, log } from '../main';
import { Server, Socket } from 'socket.io';
export default (io: Server, socket: Socket, data: SelectObject) => {
try {
// Assert game exists
if (!games[data.game_code]) {
log.debug(`Can't choose an object for a game that doesn't exist: ${data.game_code}`);
socket.emit(`ChosenObject`, {
status: 404,
message: `Game with code ${data.game_code} could not be found`,
source: `SelectObject`
});
return;
};
let game = games[data.game_code];
// Assert that the object is actually a valid choice
if (!game.objects.includes(data.choice)) {
game.log.warn(`Someone tried selecting an object that doesn't exist: ${data.choice}`);
socket.emit(`ChosenObject`, {
status: 409,
message: `That object isn't on the card.`,
source: `SelectObject`
});
return;
};
game.log.debug(`Object has been chosen: ${data.choice}`);
game.object = data.choice;
io.to(`${game.id}:*:writer`).emit(`ChosenObject`, {
status: 200,
choice: data.choice,
});
}
catch (err) {
socket.emit(`ChosenObject`, {
status: 500,
message: `${err.name}: ${err.message}`,
source: `SelectObject`,
});
}
};

View file

@ -0,0 +1,76 @@
import { Server, Socket } from 'socket.io';
import { conf, games, log } from '../main';
export default (io: Server, socket: Socket, data: SendCard) => {
try {
// Assert game exists
if (!games[data.game_code]) {
log.debug(`Can't send a card in a game that doesn't exist: ${data.game_code}`);
socket.emit(`UpdateHand`, {
status: 404,
message: `Game with code ${data.game_code} could not be found`,
source: `SendCard`
});
return;
};
let game = games[data.game_code];
let team = game.teams[data.team - 1];
let deck = game.questions;
// The writer is answering
if (data.from === "writer") {
game.log.debug(` Writer selected question to answer.`);
deck.discard(data.text);
team.selectQuestion(data.text);
socket.emit(`UpdateHand`, {
status: 200,
mode: "replace",
questions: []
});
return;
}
// The writer is sending the card to the writer
else if (data.from === "guesser") {
game.log.debug(`Guesser is sending the card to the writer.`);
// Update the team's hand
team.removeCard(data.text);
team.addCardsToHand(game.questions.draw(conf.game.hand_size - team.hand.length));
// send the question text to the writer player
io.to(`${game.id}:${team.id}:writer`).emit(`UpdateHand`, {
status: 200,
mode: "append",
questions: [data.text]
});
// Alert all the guessers of the team
io.to(`${game.id}:${team.id}:guesser`).emit(`UpdateHand`, {
status: 200,
mode: "replace",
questions: team.hand
});
return;
}
else {
game.log.warn(`Unknown role in the "from" property: ${data.from}`);
socket.emit(`UpdateHand`, {
status: 400,
message: `Unknown role in the "from" property: ${data.from}`,
source: `SendCard`
});
return;
};
}
catch (err) {
socket.emit(`UpdateHand`, {
status: 500,
message: `${err.name}: ${err.message}`,
source: `SendCard`,
});
}
};

View file

@ -0,0 +1,74 @@
import { Server, Socket } from 'socket.io';
import { conf, games, log } from '../main';
export default (io: Server, socket: Socket, data: StartGame) => {
try {
// Assert game exists
if (!games[data.game_code]) {
log.debug(`Could not find a game with ID ${data.game_code} to start`);
socket.emit(`GameJoined`, {
status: 404,
message: `Game with code "${data.game_code}" could not be found`,
source: `StartGame`,
});
return;
};
let game = games[data.game_code];
// Make sure we can't start a game that is already started
if (game.ingame) {
socket.emit(`GameStarted`, {
status: 405,
message: `Can't start a game that is already started`,
source: `StartGame`
});
return;
};
// Ensure the questions deck got populated
if (game.questions.size <= 0) {
game.log.error(`Questions deck has no cards before the game started.`);
socket.emit(`GameStarted`, {
status: 507,
message: `Questions deck failed to parse, try again in a few seconds or start a new game.`,
source: `StartGame`
});
return;
};
for (var team of game.teams) {
if (team.writer == null) {
game.log.info(`No writer on team ${team.id}, aborting start.`);
socket.emit(`GameStarted`, {
status: 418,
message: `A team doesn't have a ${conf.game.writer_name}.`,
source: `StartGame`
});
return;
} else if (team.guessers.length <= 0) {
game.log.info(`No guessers on team ${team.id}, aborting start.`);
socket.emit(`GameStarted`, {
status: 418,
message: `A team does not have any ${conf.game.guesser_name}s.`,
source: `StartGame`
});
return;
};
};
// Start the game
game.ingame = true;
game.teams[0].addCardsToHand(game.questions.draw(conf.game.hand_size));
game.teams[1].addCardsToHand(game.questions.draw(conf.game.hand_size));
io.to(game.id).emit(`GameStarted`, { status: 200 });
}
catch (err) {
socket.emit(`GameStarted`, {
status: 500,
message: `${err.name}: ${err.message}`,
source: ``,
});
}
};

View file

@ -0,0 +1,35 @@
import { games, log } from '../main';
import { Server, Socket } from 'socket.io';
export default (io: Server, socket: Socket, data: UpdateAnswer) => {
try {
// Assert game exists
if (!games[data.game_code]) {
log.debug(`Can't update answer in a game that doesn't exist: ${data.game_code}`);
socket.emit(`UpdateAnswer`, {
status: 404,
message: `Game with code ${data.game_code} could not be found`,
source: `UpdateAnswer`
});
return;
};
let game = games[data.game_code];
let team = game.teams[data.team - 1];
// Update the answers for the other players to keep them in sync
team.modifyAnswer(data.answer, data.value);
socket.to(game.id).emit(`UpdateAnswer`, {
answer: data.answer,
value: data.value,
team: data.team
});
}
catch (err) {
socket.emit(`UpdateAnswer`, {
status: 500,
message: `${err.name}: ${err.message}`,
source: `UpdateAnswer`,
});
}
};

View file

@ -0,0 +1,302 @@
import { conf, games, log } from '../main';
import { Server, Socket } from 'socket.io';
export default (io: Server, socket: Socket, data: UpdatePlayer) => {
try {
// Assert game exists
if (!games[data.game_code]) {
log.debug(`Can't modify player in a game that doesn't exist: ${data.game_code}`);
socket.emit(`PlayerUpdate`, {
status: 404,
message: `Game with code ${data.game_code} could not be found`,
source: `UpdatePlayer`
});
return;
};
// Execute the corresponding action code
switch (data.action) {
case "modify":
modifyPlayer(io, socket, data);
break;
case "remove":
removePlayer(io, socket, data);
break;
default:
socket.emit(`PlayerUpdate`, {
status: 400,
message: `Unknown player action: ${data.action}`,
source: `UpdatePlayer`,
});
};
}
catch (err) {
socket.emit(`PlayerUpdate`, {
status: 500,
message: `${err.name}: ${err.message}`,
source: `UpdatePlayer`,
});
}
};
const modifyPlayer = (io: Server, socket: Socket, data: UpdatePlayer): void => {
let game = games[data.game_code];
let player = game.players.find(x => x.name === data.name);
// Ensure that the player was found correctly so it is not undefined
if (player == null) {
game.log.debug(`Can't modify a player that doesn't exist. (name=${data.name})`);
socket.emit(`PlayerUpdate`, {
status: 404,
message: `Cannot find player with the name: ${data.name}`,
source: `UpdatePlayer.Modify`
});
return;
};
// Assert the player is modifying themselves
if (player.socket !== socket) {
game.log.debug(`${socket.id} is trying to modify a different player: ${data.name}`);
socket.emit(`PlayerUpdate`, {
status: 403,
message: `Cannot modify other players`,
source: `UpdatePlayer.Modify`
});
return;
};
if (!data.to) {
game.log.debug(`Client did not include a "to" object in request.`)
socket.emit(`PlayerUpdate`, {
status: 400,
message: `The "to" property must to be specified in the request.`,
source: `UpdatePlayer.Modify`
});
return;
};
// The player is joining a team for the first time
if (!data.from) {
game.log.silly(`${data.name} included a "to" but not a "from" property`);
let team = game.teams[data.to.team - 1];
switch (data.to.role) {
case "guesser":
if (team.guessers.length >= 7) {
game.log.debug(`Game cannot have more than 7 guessers`);
socket.emit(`PlayerUpdate`, {
status: 403,
message: `A team can't have 8 or more ${conf.game.guesser_name}`,
source: `UpdatePlayer.Modify`
});
return;
};
team.guessers.push(player);
game.log.silly(`${player.name} became a guesser`);
// Move the rooms the player is in
player.socket.join(`${game.id}:${data.to.team}:guesser`);
player.socket.join(`${game.id}:*:guesser`);
break;
case "writer":
if (team.writer) {
game.log.debug(`Game cannot have more than 1 writer`);
socket.emit(`PlayerUpdate`, {
status: 403,
message: `Someone on that team is already the ${conf.game.writer_name}`,
source: `UpdatePlayer.Modify`
});
return;
};
// Change team object
team.writer = player;
game.log.silly(`${player.name} became a writer`);
// Move the rooms the player is in
player.socket.join(`${game.id}:${data.to.team}:writer`);
player.socket.join(`${game.id}:*:writer`);
break;
}
}
// Check if the player is just swapping roles on the same team
else if (data.from.team === data.to.team) {
game.log.silly(`${data.name} provided "to" and "from" objects for the same team.`)
let team = game.teams[data.to.team - 1];
switch (data.to.role) {
case "guesser":
if (team.guessers.length >= 7) {
game.log.debug(`Game cannot have more than 7 guessers`);
socket.emit(`PlayerUpdate`, {
status: 403,
message: `A team can't have 8 or more ${conf.game.guesser_name}`,
source: `UpdatePlayer.Modify`
});
return;
}
team.guessers.push(player);
team.writer = null;
game.log.silly(`${data.name} became a guesser`);
// Move the rooms the player is in
player.socket.join(`${game.id}:${data.to.team}:guesser`);
player.socket.join(`${game.id}:*:guesser`);
player.socket.leave(`${game.id}:${data.from.team}:writer`);
player.socket.leave(`${game.id}:*:writer`);
break;
case "writer":
if (team.writer) {
game.log.debug(`Game cannot have more than 1 ${conf.game.writer_name}`);
socket.emit(`PlayerUpdate`, {
status: 403,
message: `Someone on that team is already the ${conf.game.writer_name}`,
source: `UpdatePlayer.Modify`
});
return;
};
// Change team object
team.writer = player;
team.guessers = team.guessers.filter(x => x.socket !== socket);
game.log.silly(`${data.name} became the writer`);
// Move the rooms the player is in
player.socket.join(`${game.id}:${data.to.team}:writer`);
player.socket.join(`${game.id}:*:writer`);
player.socket.leave(`${game.id}:${data.from.team}:guesser`);
player.socket.leave(`${game.id}:*:guesser`);
break;
};
}
// The player is swapping roles and teams
else {
game.log.silly(`${data.name} provided both "to" and "from" for different teams.`);
let oldTeam = game.teams[data.from.team - 1];
let newTeam = game.teams[data.to.team - 1];
// Add the player to the new team to make sure that it's a valid move
switch (data.to.role) {
case "guesser":
// Ensure we don't get 8 guessers
if (newTeam.guessers.length >= 7) {
game.log.debug(`Game cannot have 8 or more guessers on a team.`);
socket.emit(`PlayerUpdate`, {
status: 403,
message: `Cannot have 8 players as ${conf.game.guesser_name}s on a single team.`,
source: `UpdatePlayer.Modify`
});
return;
};
game.log.silly(`${data.name} became a guesser`);
newTeam.guessers.push(player);
player.socket.join(`${game.id}:${data.to.team}:guesser`)
player.socket.join(`${game.id}:*:guesser`)
break;
case "writer":
// Ensure we don't already have a writer
if (newTeam.writer) {
game.log.debug(`Game cannot have more than 1 writer on a team.`);
socket.emit(`PlayerUpdate`, {
status: 403,
message: `Someone on that team is already the ${conf.game.writer_name}`,
source: `UpdatePlayer.Modify`
});
return;
};
newTeam.writer = player;
player.socket.join(`${game.id}:${data.to.team}:writer`);
player.socket.join(`${game.id}:*:writer`);
break;
};
// Remove the player from their old team since we've added them to the
// new team
switch (data.from.role) {
case "guesser":
game.log.debug(`${player.name} left the guessers`);
oldTeam.guessers = oldTeam.guessers.filter(x => x.socket !== socket);
player.socket.leave(`${game.id}:${data.from.team}:guesser`);
// Ensure we don't remove the general role room if the player
// is taking the same role, but on the other team.
if (data.from.role !== data.to.role) {
player.socket.leave(`${game.id}:*:guesser`);
};
break;
case "writer":
game.log.debug(`${player.name} stopped being a writer`);
oldTeam.writer = null;
player.socket.leave(`${game.id}:${data.from.team}:writer`);
// Ensure we don't remove the general role room when the player
// is taking the same role, but on the other team.
if (data.from.role !== data.to.role) {
player.socket.leave(`${game.id}:*:writer`);
};
break;
};
};
player.role = data.to.role;
player.team = data.to.team;
io.to(game.id).emit(`PlayerUpdate`, {
status: 200,
action: `modify`,
name: data.name,
role: data.to.role,
team: data.to.team,
});
};
const removePlayer = (io: Server, socket: Socket, data: UpdatePlayer): void => {
let game = games[data.game_code];
let player = game.players.find(x => x.name === data.name);
let host = game.host;
// Ensure that the player was found correctly so it is not undefined
if (player == null) {
game.log.debug(`Can't delete a player that doesn't exist. (name=${data.name})`);
socket.emit(`PlayerUpdate`, {
status: 404,
message: `Cannot find player with the name: ${data.name}`,
source: `UpdatePlayer.Remove`
});
return;
};
// Ensure that the player who is removing the player is the host
if (host.socket !== socket) {
socket.emit(`PlayerUpdate`, {
status: 403,
message: `Cannot kick a player when you are not the host`,
source: `UpdatePlayer.Remove`
});
return;
};
// Remove the player from the team object so it doesn't interfere with
// other players changing their team data
for (var team of game.teams) {
if (team.writer == player) {
team.writer = null;
game.log.silly(`Removed ${player.name} from the writer role.`);
} else if (team.guessers.includes(player)) {
team.guessers = team.guessers.filter(x => x !== player);
game.log.silly(`Removed ${player.name} from the guesser role`);
};
};
game.players = game.players.filter(x => x !== player);
game.log.info(`${host.name} kicked ${player.name} from game`);
io.to(game.id).emit(`PlayerUpdate`, {
action: "remove",
name: player.name,
});
};

View file

@ -0,0 +1,17 @@
import { Server, Socket } from 'socket.io';
export default (io: Server, socket: Socket, data: any) => {
try {
socket.emit(`Error`, {
status: 501,
message: `: Not Implemented Yet`,
source: ``,
});
} catch (err) {
socket.emit(`Error`, {
status: 500,
message: `${err.name}: ${err.message}`,
source: ``,
});
}
};

23
server/src/main.ts Normal file
View file

@ -0,0 +1,23 @@
import * as toml from "toml";
import { Logger } from "tslog";
import { readFileSync } from "fs";
import { Game } from "./objects/Game";
import startWebsocket from "./websocket";
import { Validate } from "./utils/validate";
export const conf: config = toml.parse(readFileSync(`server.toml`, `utf-8`));
export var games: {[index: string]: Game} = {};
export const log: Logger = new Logger({
displayFunctionName: false,
displayLoggerName: true,
displayFilePath: `hidden`,
displayLogLevel: true,
minLevel: conf.log.level,
name: `GLOBAL`,
});
if (Validate.config(conf)) {
startWebsocket(conf);
}

View file

@ -0,0 +1,59 @@
export class Deck<T> {
private _discard: T[];
private _unknown: T[];
private _deck: T[];
constructor(cards: T[]) {
this._deck = cards;
this._discard = [];
this._unknown = [];
};
get size(): number { return this._deck.length; }
public draw(quantity: number): T[] {
/**
* Draws X cards from the deck
*
* @param quantity -> The number of cards to draw
* @throws Error -> If quantity is <= 0
* @throws Error -> If quantity > size
*/
if (quantity <= 0) {
throw new Error(`Cannot get ${quantity} cards.`);
} else if (quantity > this.size) {
throw new Error(`Cannot draw more cards than there are in the deck.`);
};
let cards: T[] = [];
// Draw the cards for the player and move them into the unknown group
for (var i = 0; i < quantity; i++) {
// Determine the card for the player(s)
let index = Math.floor(Math.random() * this.size);
let card = this._deck[index];
// Move it from the arrays
cards.push(card);
this._deck.splice(index, 1);
this._unknown.push(card);
};
return cards;
};
public discard(card: T) {
/**
* Adds the specific card to the discard pile
*
* @param card -> The card to add to the discard pile
*/
this._unknown = this._unknown.filter(x => x != card);
this._discard.push(card);
};
};

128
server/src/objects/Game.ts Normal file
View file

@ -0,0 +1,128 @@
import { Team } from "./Team";
import { Deck } from "./Deck";
import neatCSV from "neat-csv";
import { Logger } from "tslog";
import { games } from "../main";
import { Player } from "./Player";
import { readFile } from "fs";
export class Game {
readonly id: string;
readonly host: Player;
public log: Logger;
public ingame: boolean;
public teams: [Team, Team];
public players: Player[];
private _questions: Deck<question_deck>;
private _objects: Deck<object_deck>;
private _objectCard: string[];
public object: string;
constructor(conf: config, host: Player) {
this.id = Game.generateID(conf.game.code_length);
this.host = host;
this.ingame = false;
this.players = [];
// Get the decks based on what type of data they are.
switch (conf.game.cards.type) {
case "csv":
this.parseDeckCSV(conf);
break;
case "sheets":
this.parseDeckGoogleSheets(conf);
break;
};
// Instantiate everything for the teams
this.teams = [ new Team(1), new Team(2) ];
};
get questions() { return this._questions; };
get objects() {
/**
* Return the objects that the spirits can choose from for the game.
*/
if (!this._objectCard) {
this._objectCard = this._objects.draw(1)[0];
};
return this._objectCard;
};
get playerData() {
let players: player[] = [];
for (var player of this.players) {
players.push({
name: player.name,
role: player.role,
team: player.team,
});
};
return players
}
private parseDeckCSV(conf: config): any {
/**
* Parses out the CSV files and creates the decks for the game to run on
*
* @param path -> The filepath of the CSV file
*/
// parse the questions from the CSV
readFile(conf.game.cards.questions.fingerprint, `utf-8`, (err, filebuffer) => {
if (err) throw err;
neatCSV(filebuffer)
.then((data) => {
let questions: question_deck[] = [];
for (var entry of data) {
questions.push(Object.values(entry)[conf.game.cards.questions.column]);
};
this._questions = new Deck(questions);
});
});
// Parse the object deck from CSV
readFile(conf.game.cards.objects.fingerprint, `utf-8`, (err, filebuffer) => {
if (err) throw err;
neatCSV(filebuffer)
.then((data) => {
let objects: object_deck[] = [];
for (var line of data) {
objects.push(Object.values(line));
};
this._objects = new Deck(objects);
})
});
};
private parseDeckGoogleSheets(conf: config): void {
/**
* Fetches and parses the CSV data from Google Sheets instead of local
* CSV files.
*
* @param conf -> The config object
*/
};
public static generateID(length: number): string {
/**
* Generates a game code with the given length
*
* @param length -> The length of the code we want to generate
*/
let code: string;
// Generate a code until we don't have a collision
do {
code = ``;
for (var i = 0; i < length; i++) {
code += `${Math.floor(Math.random() * 9)}`;
};
} while (games[code]);
return code;
};
};

View file

@ -0,0 +1,15 @@
import { Socket } from "socket.io";
export class Player {
readonly name: string;
public team: team|null = null;
public role: role|null = null;
public socket: Socket;
readonly isHost: boolean;
constructor(name: string, socket: Socket, isHost=false) {
this.name = name;
this.socket = socket;
this.isHost = isHost;
};
};

View file

@ -0,0 +1,72 @@
import { Player } from "./Player";
export class Team {
readonly id: team;
public guessers: Player[];
public writer: Player | null;
private _hand: string[];
private _questions: string[];
private _answers: string[];
constructor(id: team) {
this.id = id;
this._answers = new Array<string>(8).fill(``);
this._questions = [];
this._hand = [];
this.guessers = [];
};
/*
* The getters for the various class properties
*/
get hand(): string[] { return this._hand; };
get answers(): string[] { return this._answers; };
get questions(): string[] { return this._questions; };
public addCardsToHand(questions: string[]): void {
/**
* Adds the question(s) to the medium's hand
*
* @param questions -> The array of question text to add the medium's
* hand.
*/
this._hand.push(...questions);
};
public removeCard(question: string) {
/**
* Removes the given question from the medium's hand
*
* @param question -> The card text to remove from the hand.
*/
this._hand = this._hand.filter(x => x != question);
};
public selectQuestion(question: string) {
/**
* Adds the given question to the history of the questions.
*
* @param question -> The question the spirit is answering
*/
this._questions.push(question);
};
public modifyAnswer(answerIndex: answer, answer: string) {
/**
* Takes the value of an answer and modifies in the storage.
*
* @param answerIndex -> The value of the answer between 1 and 8 (inclusive)
* @param answer -> The new answer for that index
* @throws Error -> If the answerIndex is not in range
*/
if (answerIndex > this._answers.length || answerIndex <= 0) {
throw new Error(`Cannot set answer at index ${answerIndex}.`)
};
this._answers[answerIndex - 1] = answer;
};
};

30
server/src/types/config.d.ts vendored Normal file
View file

@ -0,0 +1,30 @@
interface config {
log: {
level: `silly` | `debug` | `info` | `error` | `warn` | `fatal` | `trace`;
};
websocket: {
port: number;
permitted_hosts: string | string[];
};
webserver: {
enabled: boolean;
port: number;
};
game: {
hand_size: number;
code_length: number;
writer_name: string;
guesser_name: string;
cards: {
type: `csv` | `sheets`;
key?: string;
questions: {
fingerprint: string;
column: number;
};
objects: {
fingerprint: string;
};
};
};
}

136
server/src/types/data.d.ts vendored Normal file
View file

@ -0,0 +1,136 @@
interface response {
status: number;
source?: string;
message?: string;
}
type team = 1 | 2;
type role = "writer" | "guesser";
type answer = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
// Player specific data
interface player {
name: string;
role: role | null;
team: team | null;
}
interface CreateGame {
name: string;
}
interface GameCreated extends response {
game_code?: string;
players?: player[]
}
interface DeleteGame {
game_code: string;
}
interface GameDeleted extends response {}
interface LeaveGame {
game_code: string;
}
interface GameLeft extends response {}
interface StartGame {
game_code: string;
}
interface GameStarted extends response {}
interface GetPastQuestions {
game_code: string;
team: team;
}
interface PastQuestions extends response {
questions?: string[];
}
interface JoinGame {
game_code: string;
name: string;
}
interface GameJoined extends response {
players?: player[];
}
interface GameRejoined extends response {
players?: player[];
}
interface GetHand {
game_code: string;
team: team;
}
interface NewHand {
game_code: string;
team: team;
}
interface SendCard {
game_code: string;
text: string;
from: role;
team: team;
}
interface UpdateHand extends response{
mode?: "append" | "replace";
questions?: string[];
}
interface ObjectList {
game_code: string;
}
interface ObjectListResponse extends response {
objects?: string[];
}
interface SelectObject {
game_code: string;
choice: string;
}
interface ChosenObject extends response {
choice?: string;
}
interface UpdateAnswer {
game_code: string;
answer: answer;
value: string;
team: team;
}
interface UpdateAnswerResponse extends response {
answer: answer;
value: string;
team: team;
}
interface UpdatePlayer {
action: "modify" | "remove";
game_code: string;
name: string;
from?: {
team: team;
role: role;
};
to?: {
team: team;
role: role;
};
}
interface PlayerUpdate extends response {
action?: "modify" | "new" | "remove";
name?: string; // action: all
team?: team; // action: "modify"
role?: role; // action: "modify"
}

2
server/src/types/deck_types.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
type question_deck = string;
type object_deck = string[];

View file

@ -0,0 +1,45 @@
import { log } from "../main";
export class Validate {
public static config(conf: config) {
let valid = true;
// Assert data in log object:
if (![`silly`,`debug`,`info`,`error`,`fatal`,`warn`,`trace`].includes(conf.log.level)) {
log.error(`Unknown log level: ${conf.log.level}`);
valid = false;
};
// Assert data in the game object
if (![`csv`].includes(conf.game.cards.type)) {
log.error(`Unsupported cards type: ${conf.game.cards.type}`);
valid = false;
};
if (conf.game.cards.type == `sheets` && !conf.game.cards.key) {
log.error(`Cannot have game.cards.type set to "sheets" and not have the game.cards.key set.`);
valid = false;
};
if (conf.game.code_length <= 1) {
log.error(`Game codes can't have <= 1 characters: ${conf.game.code_length}`);
valid = false;
}
// Assert data in the web server object
if (conf.webserver.enabled) {
if (!conf.webserver.port) {
log.error(`Invalid webserver port value: ${conf.webserver.port}`);
valid = false;
};
};
if (!conf.websocket.permitted_hosts) {
log.error(`Can't have a blank or null webserver.hostname`);
valid = false;
};
// Config is valid
return valid;
};
};

56
server/src/websocket.ts Normal file
View file

@ -0,0 +1,56 @@
import { log } from "./main";
import { Server, Socket } from "socket.io";
import GetHand from "./events/GetHand";
import NewHand from "./events/NewHand";
import JoinGame from "./events/JoinGame";
import SendCard from "./events/SendCard";
import LeaveGame from "./events/LeaveGame";
import StartGame from "./events/StartGame";
import CreateGame from "./events/CreateGame";
import DeleteGame from "./events/DeleteGame";
import ObjectList from "./events/ObjectList";
import UpdatePlayer from "./events/UpdatePlayer";
import SelectObject from "./events/SelectObject";
import UpdateAnswer from "./events/UpdateAnswer";
import GetPastQuestions from "./events/GetPastQuestions";
export default async (conf: config) => {
const io = new Server();
io.listen(conf.websocket.port, {
cors: {
origin: conf.websocket.permitted_hosts,
credentials: true,
}
});
io.on(`connection`, (socket: Socket) => {
log.debug(`Client connected with ID: ${socket.id}`);
// Game Management
socket.on(`CreateGame`, (data: CreateGame) => CreateGame(io, socket, data));
socket.on(`StartGame`, (data: StartGame) => StartGame(io, socket, data));
socket.on(`DeleteGame`, (data: DeleteGame) => DeleteGame(io, socket, data));
// Player Management
socket.on(`JoinGame`, (data: JoinGame) => JoinGame(io, socket, data));
socket.on(`UpdatePlayer`, (data: UpdatePlayer) => UpdatePlayer(io, socket, data));
socket.on(`LeaveGame`, (data: LeaveGame) => LeaveGame(io, socket, data));
// Game Mechanisms
socket.on(`ObjectList`, (data: ObjectList) => ObjectList(io, socket, data));
socket.on(`SelectObject`, (data: SelectObject) => SelectObject(io, socket, data));
socket.on(`GetHand`, (data: GetHand) => GetHand(io, socket, data));
socket.on(`NewHand`, (data: NewHand) => NewHand(io, socket, data));
socket.on(`SendCard`, (data: SendCard) => SendCard(io, socket, data));
socket.on(`UpdateAnswer`, (data: UpdateAnswer) => UpdateAnswer(io, socket, data));
socket.on(`GetPastQuestions`, (data: GetPastQuestions) => GetPastQuestions(io, socket, data));
});
log.info(`Server started on port ${conf.websocket.port}`);
}

83
server/template.toml Normal file
View file

@ -0,0 +1,83 @@
[game]
# The hand size for the guessers
hand_size = 7
# The length of the game codes that are randomly generated to allow others
code_length = 6
# The names that the server uses for the writer/guessers, these are really only
# used in error responses.
writer_name = "Spirit"
guesser_name = "Medium"
[game.cards]
# The type of data we are fetching for using in the cards. Accepted values are:
# - "csv"
# - "sheets"
# Any other value will prevent the server from starting.
type = "csv"
# The Google Sheets document ID, this is used to specify the document as a whole,
# not the specific sheet inside, for that see the "fingerprint" property. This
# will be ignored if the type is set to "csv"
key = ''
[game.cards.questions]
# The identifier for the questions cards.
# - If "type" is "csv", then this is a filepath relative to the working
# directory of the CLI instantiating the server. (or an absolute path)
# - If "type" is "sheets", then this is a sheet identifier used by Google
# Sheets to indicate which sheet to use when downloading the content.
fingerprint = ''
# The zero-indexed column number to use when getting the question text.
column = 0
[game.cards.objects]
# The identifier for the object cards.
# - If "type" is "csv", then this is a filepath relative to the working
# directory of the CLI instantiating the server. (or an absolute path)
# - If "type" is "sheets", then this is a sheet identifier used by Google
# Sheets to indicate which sheet to use when downloading the content.
fingerprint = ''
[webserver]
# whether or not to enable the integrated webserver.
enabled = false
# The port the web server should run on
port = 8080
[websocket]
# The port the websocket server should run on. This must also be duplicated
# into the `main.js` file within the `web` folder so that the client attempts
# to connect to the correct port on the server.
port = 8081
# The hostnames (and protocols) that are allowed to connect to the server. For
# CORS protection, this should not be set to "*", an array of hostnames is
# allowed, an example can be seen commented out below:
# permitted_hosts = [
# "http://localhost:8080",
# "http://127.0.0.1:8080"
# ]
permitted_hosts = "http://localhost:8080"
[log]
# The log level to output to the CLI, this can be one of the following:
# - "silly"
# - "debug"
# - "info"
# - "warn"
# - "error"
# - "trace"
# any other value will prevent the server from starting at runtime.
level = "silly"

66
server/tsconfig.json Normal file
View file

@ -0,0 +1,66 @@
{
"compilerOptions": {
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
"sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./dist", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
"removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
// "strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"strictNullChecks": true, /* Enable strict null checks. */
"strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
"noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
"noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
}
}

1
web/.npmrc Normal file
View file

@ -0,0 +1 @@
shamefully-hoist=true

19
web/README.md Normal file
View file

@ -0,0 +1,19 @@
## Project setup
```
pnpm install
```
### Compiles and hot-reloads for development
```
pnpm run serve
```
### Compiles and minifies for production
```
pnpm run build
```
### Lints and fixes files
```
pnpm run lint
```

5
web/Z-indexes.txt Normal file
View file

@ -0,0 +1,5 @@
0 -> Main components (default)
1 -> Eyes + the multiplier
3 -> Past Questions popup
10 -> Modal background
11 -> Modal foreground

5
web/babel.config.js Normal file
View file

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

52
web/package.json Normal file
View file

@ -0,0 +1,52 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --copy",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"core-js": "^3.6.5",
"socket.io-client": "^3.0.4",
"vue": "^2.6.11",
"vue-clipboard2": "^0.3.1",
"vue-socket.io-extended": "^4.0.5",
"vuex": "^3.4.0",
"vuex-persist": "^3.1.3"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"vue-template-compiler": "^2.6.11"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {
"vue/no-unused-vars": "warn",
"no-unused-vars": "warn",
"no-extra-semi": "off"
}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

9164
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 49.63 37.19"><defs><style>.cls-1{fill: #000F3D}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_3" data-name="Layer 3"><g id="Layer_2-2" data-name="Layer 2-2"><path class="cls-1" d="M24.71,30.59C14,30.59,5,23.32,2.42,21.09a1.71,1.71,0,0,1-.16-2.41l.16-.16C5,16.29,14,9,24.71,9S44.46,16.3,47,18.52A1.74,1.74,0,0,1,47.14,21a1.21,1.21,0,0,1-.16.16C43.73,24,35,30.59,24.71,30.59Zm0-20.4c-10.29,0-19.08,7-21.52,9.2a.57.57,0,0,0,0,.83c2.46,2.16,11.26,9.21,21.53,9.21,9.94,0,18.35-6.41,21.51-9.17a.6.6,0,0,0,0-.87h0C43.77,17.23,35,10.19,24.71,10.19Z"/><path class="cls-1" d="M24.71,28a9.29,9.29,0,1,1,9.08-9.29A9.2,9.2,0,0,1,24.71,28Zm0-18.13a8.85,8.85,0,1,0,8.64,8.84A8.76,8.76,0,0,0,24.71,9.83Z"/><path class="cls-1" d="M24.73,29.65a11.41,11.41,0,0,1-1.16-.06,11,11,0,0,1-5.81-19.54L18,9.94A26.5,26.5,0,0,1,24.71,9a26.44,26.44,0,0,1,6.74.91l.21.11a11,11,0,0,1-6.93,19.6ZM18.4,11A9.87,9.87,0,0,0,17,24.84a9.91,9.91,0,0,0,6.65,3.6,9.88,9.88,0,0,0,10.89-9.82h0A9.77,9.77,0,0,0,31,11,24.12,24.12,0,0,0,18.4,11Z"/><rect class="cls-1" x="41.02" y="11.75" width="9.61" height="1.16" transform="translate(4.7 36.01) rotate(-45)"/><rect class="cls-1" x="32.19" y="6.72" width="9.61" height="1.16" transform="translate(17.62 39.65) rotate(-70.24)"/><rect class="cls-1" x="24.13" width="1.16" height="9.61"/><rect class="cls-1" x="3.23" y="7.52" width="1.16" height="9.61" transform="translate(-7.6 6.3) rotate(-45)"/><rect class="cls-1" x="12.05" y="2.49" width="1.16" height="9.61" transform="translate(-1.72 4.69) rotate(-19.73)"/><circle class="cls-1" cx="24.71" cy="18.67" r="2.33"/><path class="cls-1" d="M24.71,21.38a2.71,2.71,0,1,1,2.71-2.71A2.7,2.7,0,0,1,24.71,21.38Zm0-4.66a2,2,0,1,0,1.95,2A2,2,0,0,0,24.71,16.72Z"/><rect class="cls-1" x="44.64" y="22.58" width="1.16" height="6.99" transform="translate(-2.76 46.63) rotate(-53.15)"/><rect class="cls-1" x="36.42" y="27.37" width="1.16" height="7.52" transform="translate(-9.82 19.05) rotate(-25.61)"/><rect class="cls-1" x="24.13" y="29.99" width="1.16" height="7.2"/><rect class="cls-1" x="0.9" y="25.5" width="7" height="1.16" transform="matrix(0.8, -0.6, 0.6, 0.8, -14.74, 7.83)"/><rect class="cls-1" x="8.88" y="30.55" width="7.51" height="1.16" transform="translate(-20.9 29.09) rotate(-64.43)"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 34.84 40.33"><defs><style>.cls-1{fill:#231f20;}.cls-2{fill:#ede9c3;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_3" data-name="Layer 3"><path class="cls-1" d="M5.9,23.37c-.12.91-.16,1.12-.16,1.12s0-.18-.16-1.13a8.09,8.09,0,0,0-3-5l-.35-.22L2.57,18a7.12,7.12,0,0,0,3-4.48c.07-.35.15-.69.15-.69s.11.36.18.69a7.16,7.16,0,0,0,3,4.48l.37.19-.36.22A8.25,8.25,0,0,0,5.9,23.37ZM3.27,18.2a6.55,6.55,0,0,1,2.47,3.44A6.55,6.55,0,0,1,8.21,18.2a6.54,6.54,0,0,1-2.47-3.52A6.48,6.48,0,0,1,3.27,18.2Z"/><path class="cls-2" d="M5.78,26.13,5.4,24.57s-.05-.24-.17-1.17a7.77,7.77,0,0,0-2.88-4.73l-.85-.53.9-.48a6.69,6.69,0,0,0,2.84-4.25l.16-.69.31-1.26.37,1.24s.11.38.18.71a7,7,0,0,0,2.82,4.25l.91.48-.87.54a7.88,7.88,0,0,0-2.87,4.74h0c-.12.92-.16,1.13-.16,1.13ZM3.85,18.21a6.86,6.86,0,0,1,1.89,2.43,6.89,6.89,0,0,1,1.9-2.43,6.81,6.81,0,0,1-1.9-2.53A6.79,6.79,0,0,1,3.85,18.21Zm3.27,1.52a4.84,4.84,0,0,0-.33.46C6.9,20,7,19.87,7.12,19.73Z"/><path class="cls-2" d="M14.68,40.33A20.62,20.62,0,0,1,11,40a20.17,20.17,0,0,1-9.1-4.26L0,34.21l2.33.56.81.17a16.53,16.53,0,0,0,12.47-2.62,16.55,16.55,0,0,0,7-10.67A16.67,16.67,0,0,0,9.28,2.22l-.82-.14L6.09,1.76,8.36,1a20.18,20.18,0,0,1,10-.66h0a20.16,20.16,0,0,1-3.71,40ZM4.43,36.24A19.06,19.06,0,1,0,18.18,1.43h0a19.11,19.11,0,0,0-7.23,0A17.77,17.77,0,0,1,23.66,21.85a17.64,17.64,0,0,1-7.42,11.38A17.63,17.63,0,0,1,4.43,36.24Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.87 46.6"><defs><style>.cls-1{fill:#ede9c3;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_3" data-name="Layer 3"><path class="cls-1" d="M23,38a14.81,14.81,0,1,1,14.8-14.81A14.82,14.82,0,0,1,23,38ZM23,9.46A13.77,13.77,0,1,0,36.72,23.23,13.78,13.78,0,0,0,23,9.46Z"/><path class="cls-1" d="M21.69,9.3c-.81-1.61-.15-2.33,1-3.33.95-.79.52-2.46.52-2.47l-1-3.5,1.91,3.08c.25.4,1.46,2.44,1,3.62a1.63,1.63,0,0,0,0,1.46c.06.22.14.48.21.8l-1,.25c-.07-.3-.14-.54-.2-.75a2.63,2.63,0,0,1,0-2.16,1.23,1.23,0,0,0,0-.66,2.72,2.72,0,0,1-.79,1.13c-1.19,1-1.21,1.21-.78,2.06Z"/><path class="cls-1" d="M15.66,11.31c-1.47-1-1.22-2-.63-3.43.47-1.15-.69-2.42-.7-2.43L11.84,2.81,15,4.64c.41.24,2.43,1.48,2.54,2.75a1.7,1.7,0,0,0,.69,1.29c.16.17.35.36.57.61L18,10c-.2-.23-.38-.42-.53-.57a2.65,2.65,0,0,1-1-1.92,1.29,1.29,0,0,0-.32-.59A2.55,2.55,0,0,1,16,8.27c-.59,1.45-.51,1.64.27,2.19Z"/><path class="cls-1" d="M11,15.78c-1.76-.39-1.9-1.36-1.92-2.91,0-1.25-1.59-2-1.6-2L4.23,9.46l3.52.45c.47.06,2.81.41,3.42,1.54.3.56.48.64,1.14.91l.75.34-.44.94c-.28-.14-.51-.23-.71-.32a2.61,2.61,0,0,1-1.66-1.38,1.14,1.14,0,0,0-.52-.41,2.56,2.56,0,0,1,.38,1.32c0,1.56.18,1.71,1.11,1.91Z"/><path class="cls-1" d="M8,21.83c-1.09,0-1.57-.74-2.16-1.86s-2.29-1-2.31-1L0,19.13l3.35-1.19c.44-.16,2.68-.91,3.74-.18a1.67,1.67,0,0,0,1.43.3l.82,0,0,1c-.31,0-.56,0-.78,0a2.68,2.68,0,0,1-2.09-.48,1.1,1.1,0,0,0-.66-.14,2.71,2.71,0,0,1,.94,1c.73,1.38.93,1.44,1.85,1.2l.27,1A3.46,3.46,0,0,1,8,21.83Z"/><path class="cls-1" d="M.37,29l2.53-2.5c.33-.33,2.06-2,3.31-1.74a1.71,1.71,0,0,0,1.43-.33c.2-.12.43-.25.72-.39l.47.93c-.28.13-.5.26-.69.36a2.62,2.62,0,0,1-2.1.45,1.19,1.19,0,0,0-.65.16,2.68,2.68,0,0,1,1.28.51c1.24.95,1.45.92,2.18.31l.66.8c-1.38,1.15-2.23.66-3.47-.28-1-.76-2.52,0-2.53.05Z"/><path class="cls-1" d="M5.24,38.3,6.47,35c.17-.44,1-2.65,2.27-3,.62-.17.73-.33,1.15-.91.14-.18.29-.4.5-.66l.81.65c-.19.24-.34.45-.47.62A2.65,2.65,0,0,1,9,33a1.1,1.1,0,0,0-.51.42,2.51,2.51,0,0,1,1.37-.08c1.53.34,1.7.22,2.11-.64l.94.44c-.77,1.63-1.75,1.55-3.27,1.22-1.23-.27-2.26,1.1-2.27,1.11Z"/><path class="cls-1" d="M13.55,44.54,13.27,41c0-.47-.18-2.84.79-3.66A1.68,1.68,0,0,0,14.72,36c0-.22.1-.49.17-.8l1,.24c-.07.3-.11.54-.16.76a2.64,2.64,0,0,1-1,1.9,1.24,1.24,0,0,0-.29.6,2.58,2.58,0,0,1,1.21-.65c1.53-.35,1.64-.52,1.64-1.48h1c0,1.8-.93,2.14-2.45,2.48-1.22.28-1.59,1.95-1.59,2Z"/><path class="cls-1" d="M23.81,46.6l-1.76-3.09c-.23-.41-1.36-2.49-.83-3.65a1.69,1.69,0,0,0,.05-1.46c-.06-.23-.12-.49-.19-.81l1-.21c.06.31.12.55.18.76a2.67,2.67,0,0,1-.11,2.15,1.21,1.21,0,0,0,0,.67,2.62,2.62,0,0,1,.83-1.1c1.23-1,1.26-1.17.86-2l.94-.44c.76,1.64.06,2.34-1.17,3.29-1,.76-.61,2.44-.61,2.46Z"/><path class="cls-1" d="M33.61,44.07l-3-2.1c-.38-.27-2.28-1.69-2.29-3a1.66,1.66,0,0,0-.57-1.35c-.15-.18-.32-.39-.51-.65l.83-.62c.18.25.34.45.48.62a2.65,2.65,0,0,1,.81,2,1.13,1.13,0,0,0,.27.61A2.69,2.69,0,0,1,30,38.26c.72-1.38.65-1.58-.07-2.2l.67-.79c1.37,1.16,1,2.09.32,3.47-.56,1.1.48,2.47.49,2.48Z"/><path class="cls-1" d="M41.33,37.55l-3.5-.64c-.46-.08-2.78-.56-3.33-1.71-.27-.59-.44-.67-1.08-1l-.74-.37.49-.92c.27.15.5.26.7.35a2.67,2.67,0,0,1,1.57,1.47,1.23,1.23,0,0,0,.5.44,2.63,2.63,0,0,1-.31-1.34c.06-1.56-.08-1.71-1-2l.28-1c1.73.48,1.82,1.46,1.76,3-.05,1.25,1.47,2,1.49,2Z"/><path class="cls-1" d="M40.18,29.5a2.68,2.68,0,0,1-1.66-.44,1.66,1.66,0,0,0-1.39-.43c-.23,0-.5,0-.83,0l.06-1c.31,0,.56,0,.78,0a2.68,2.68,0,0,1,2,.67,1.24,1.24,0,0,0,.64.19A2.68,2.68,0,0,1,39,27.36c-.6-1.44-.79-1.52-1.74-1.36l-.17-1c1.77-.3,2.26.55,2.87,2,.48,1.15,2.19,1.22,2.21,1.23l3.56.12-3.44.9A9.1,9.1,0,0,1,40.18,29.5Z"/><path class="cls-1" d="M37.47,23.71l-.38-1c.28-.11.52-.22.71-.31a2.67,2.67,0,0,1,2.14-.26,1.22,1.22,0,0,0,.66-.1,2.55,2.55,0,0,1-1.23-.63c-1.15-1-1.36-1-2.15-.49l-.59-.86c1.48-1,2.29-.46,3.44.59.92.84,2.51.18,2.53.17l3.27-1.39-2.74,2.27c-.36.3-2.21,1.77-3.45,1.44a1.7,1.7,0,0,0-1.45.21Z"/><path class="cls-1" d="M36.25,17.25l-.76-.71A7.67,7.67,0,0,0,36,16a2.64,2.64,0,0,1,1.83-1.14,1.23,1.23,0,0,0,.55-.37,2.57,2.57,0,0,1-1.37,0c-1.5-.46-1.68-.36-2.16.46l-.9-.52c.91-1.56,1.88-1.39,3.37-.93,1.19.37,2.34-.9,2.36-.91l2.38-2.65-1.53,3.22c-.2.42-1.26,2.54-2.52,2.77a1.68,1.68,0,0,0-1.22.8C36.65,16.81,36.47,17,36.25,17.25Z"/><path class="cls-1" d="M32.12,12l-1-.33c.09-.29.16-.53.22-.74a2.66,2.66,0,0,1,1.17-1.81,1.14,1.14,0,0,0,.35-.57,2.58,2.58,0,0,1-1.27.54c-1.54.21-1.67.38-1.76,1.33l-1-.1C29,8.5,29.93,8.24,31.47,8c1.23-.17,1.75-1.8,1.76-1.82l1-3.47,0,3.62c0,.47-.07,2.84-1.12,3.58-.52.37-.57.55-.77,1.24C32.3,11.4,32.23,11.66,32.12,12Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1,8 @@
<svg width="72" height="72" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" data-reactroot="">
<path stroke-linejoin="round" stroke-linecap="round" stroke-miterlimit="10" stroke-width="1" stroke="#ede9c3" fill="none" d="M16.13 22H7.87C7.37 22 6.95 21.63 6.88 21.14L5 8H19L17.12 21.14C17.05 21.63 16.63 22 16.13 22Z"></path>
<path stroke-linejoin="round" stroke-linecap="round" stroke-miterlimit="10" stroke-width="1" stroke="#ede9c3" d="M3.5 8H20.5"></path>
<path stroke-linejoin="round" stroke-linecap="round" stroke-miterlimit="10" stroke-width="1" stroke="#ede9c3" d="M10 12V18"></path>
<path stroke-linejoin="round" stroke-linecap="round" stroke-miterlimit="10" stroke-width="1" stroke="#ede9c3" d="M14 12V18"></path>
<path stroke-linejoin="round" stroke-linecap="round" stroke-miterlimit="10" stroke-width="1" stroke="#ede9c3" fill="none" d="M16 5H8L9.7 2.45C9.89 2.17 10.2 2 10.54 2H13.47C13.8 2 14.12 2.17 14.3 2.45L16 5Z"></path>
<path stroke-linejoin="round" stroke-linecap="round" stroke-miterlimit="10" stroke-width="1" stroke="#ede9c3" d="M3 5H21"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

17
web/public/index.html Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>Ghost Writer Online</title>
</head>
<body>
<noscript>
<strong>We're sorry but Ghost Writer Online doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

154
web/src/App.vue Normal file
View file

@ -0,0 +1,154 @@
<template>
<div id="app" class="maximize">
<div v-if="!isMobile" class="maximize">
<transition name="top-slide">
<div
v-if="alert.message"
class="alert-bar"
:class="alert.type"
>
{{ alert.message }}
</div>
</transition>
<CreateJoin
v-if="gameState == `login`"
@error="handleError($event)"
/>
<GameLobby
v-else-if="gameState == `lobby`"
@error="handleError($event)"
/>
<InGame
v-else-if="gameState == `in-game`"
@error="handleError($event)"
/>
<Attributions />
</div>
<div
v-else
class="device-error"
>
<h1 class="centre">Ghost Writer Online</h1>
<p class="centre">
To use this site you must be using a laptop, desktop, or iPad.
If you are on one of those devices and you still see this message,
please contact "oliver {at} akins.me" with the following information:
<br><br>
{{ userAgent }}
</p>
</div>
</div>
</template>
<script>
import AttributionsBar from "./components/Attributions";
import CreateJoinGame from "./views/CreateJoin";
import GameLobby from "./views/Lobby";
import InGame from "./views/InGame";
export default {
name: `App`,
components: {
"Attributions": AttributionsBar,
"CreateJoin": CreateJoinGame,
"GameLobby": GameLobby,
"InGame": InGame,
},
data() {return {
alert: {
message: null,
type: null,
},
}},
computed: {
gameState() {
return this.$store.state.view;
},
userAgent() {
if (navigator == null) {
return "Navigator Undefined";
};
return navigator.userAgent;
},
isMobile () {
return this.userAgent.match(/(Navigator Undefined)|iPhone|iPod|Android/) != null;
},
},
methods: {
handleError(data) {
this.alert = {
message: `${data.status}${data.source ? ` @ ${data.source}` : ''} : ${data.message}`,
type: `error`,
};
console.error(`${this.alert.message}${data.extra ? ` ${data.extra}` : ''}`);
setTimeout(() => {
this.alert = {
message: null,
type: null,
};
}, 3000);
},
},
sockets: {
GameDeleted(data) {
if (data.status < 200 || 300 <= data.status) {
this.handleError(data);
} else {
this.alert = {
message: `The game has been ended by the host.`,
type: `info`,
};
this.$store.commit(`resetState`);
setTimeout(() => {
this.alert = {
message: null,
type: null,
};
}, 2000)
};
},
},
}
</script>
<style>
@import "css/theme.css";
@import "css/style.css";
html, body {
background-color: var(--background1);
color: var(--light-font-colour);
font-family: var(--fonts);
overflow-x: hidden;
height: 100vh;
width: 100vw;
padding: 0;
margin: 0;
}
.alert-bar {
justify-content: center;
align-items: center;
position: fixed;
display: flex;
height: 35px;
width: 100vw;
left: 0;
top: 0;
}
.alert-bar.error {
background-color: #bb0000;
color: #FFFFFF;
}
.alert-bar.info {
background-color: #7289da;
color: #000000;
}
.device-error {
border: solid 2px red;
margin: 50% auto;
width: 90%;
}
</style>

View file

@ -0,0 +1,84 @@
<template>
<div id="attributions-bar">
<div
class="bottom-bar clickable"
@click.stop="modal = true"
>
Made By: Oliver Akins (Alkali Metal)
</div>
<ModalAnimation
:show="modal"
@closed="modal = false"
>
<h2 class="centre">Attributions:</h2>
<p class="centre">
Made By: Oliver Akins
</p>
<hr>
<p>
Tooling:
<ul>
<li
v-for="(link, name) in tooling"
:key="name"
>
<a :href="link">{{ name }}</a>
</li>
</ul>
</p>
</ModalAnimation>
</div>
</template>
<script>
import Modal from "./Modal";
export default {
name: `AttributionsBar`,
components: {
"ModalAnimation": Modal,
},
data() {return {
modal: false,
tooling: {
"Vue.JS (With VueX)": "https://vuejs.org",
"VueX-Persist": "https://www.npmjs.com/package/vuex-persist",
"Vue-Socket.io": "https://github.com/MetinSeylan/Vue-Socket.io",
"Vue-Clipboard2": "https://www.npmjs.com/package/vue-clipboard2",
"Toml": "https://www.npmjs.com/package/toml",
"tslog": "https://www.npmjs.com/package/tslog",
"Socket.io": "https://socket.io",
"neat-csv": "https://github.com/sindresorhus/neat-csv",
}
}},
computed: {},
methods: {},
}
</script>
<style scoped>
@import "../css/theme.css";
@import "../css/style.css";
#attributions-bar {
left: calc(50% - 20%);
position: absolute;
height: 35px;
width: 40%;
bottom: 0;
}
.bottom-bar {
background-color: var(--background2);
color: var(--background2-text);
border-radius: 15px 15px 0 0;
justify-content: center;
align-items: center;
display: flex;
height: 100%;
}
a, a:visited {
color: var(--light-font-colour);
}
</style>

View file

@ -0,0 +1,128 @@
<template>
<div id="ObjectBoard">
<div
class="object"
v-for="objectIndex in objects.length"
:key="`object-${objectIndex}`"
>
<span class="text">
{{ objectIndex }}. {{ objects[objectIndex - 1] }}
</span>
<button
class="clickable"
@click.stop="selectObject(objectIndex)"
>
{{ buttonLabel }}
</button>
</div>
</div>
</template>
<script>
export default {
name: `ObjectSelector`,
components: {},
data() {return {
objects: [],
}},
computed: {
buttonLabel() {
return this.$store.state.writer_object_choose_button;
},
gameCode() {
return this.$store.state.game_code;
},
},
methods: {
selectObject(objectIndex) {
/**
* Sends the chosen object to the server so that the game can begin properly.
*/
let data = {
game_code: this.gameCode,
choice: this.objects[objectIndex - 1],
};
this.$socket.client.emit(`SelectObject`, data);
},
getObjects() {
/**
* Gets the objects on the card from the server. This method will
* return the same values for all spirit.
*/
this.$socket.client.emit(`ObjectList`, {
game_code: this.$store.state.game_code,
});
},
},
sockets: {
ObjectList(data) {
/**
* The response event from the server for the list of objects that
* are on the card which we have drawn for the round.
*/
this.objects = data.objects;
},
ChosenObject(data) {
/**
* Sent to all clients so that they can set their store data and in
* turn stay synchronized on what object they are trying to get
* their teammate to guess.
*/
if (data.status < 200 || 300 <= data.status) {
this.$emit(`error`, data);
};
this.$store.commit(`setObject`, data.choice);
},
},
mounted() {
this.getObjects();
},
}
</script>
<style scoped>
@import "../css/theme.css";
@import "../css/style.css";
#ObjectBoard {
background-color: var(--board-background);
color: var(--board-background-text);
justify-content: space-evenly;
padding-bottom: 10px;
flex-direction: row;
border-radius: 20px;
margin: 15px auto;
flex-wrap: wrap;
display: flex;
width: 95%;
}
.object {
background-color: var(--board-background-alt);
color: var(--board-background-alt-text);
justify-content: center;
flex-direction: column;
border-radius: 10px;
display: flex;
padding: 15px;
margin: 10px;
width: 40%;
}
.text {
justify-content: center;
align-items: center;
display: flex;
flex-grow: 1;
}
button {
background: var(--card-button);
border-radius: 7px;
font-size: larger;
padding: 7px;
margin: 10px;
}
button:hover { background-color: var(--card-button-darken); }
button:focus { background-color: var(--board-background-alt-lighten); }
</style>

View file

@ -0,0 +1,136 @@
<template>
<div id="DiscardHandButton">
<div style="width: 100%; height: 100%;">
<button
class="discard-hand clickable"
@click.stop="confirmVisible = true"
>
<img
:src="`/assets/${buttonIcon}`"
alt="Discard entire hand"
class="icon"
>
</button>
</div>
<ModalAnimation
:show="confirmVisible"
:closable="false"
@closed="confirmVisible = false"
>
<h2 class="centre">Discard Hand?</h2>
<p class="centre">
Are you sure you want to discard your team's
<strong>entire</strong> hand?
</p>
<div class="buttons">
<button
class="confirm modal-button clickable"
@click.stop="discardHand()"
>
Discard Hand
</button>
<button
class="cancel modal-button clickable"
@click.stop="confirmVisible = false"
>
Don't Discard Hand
</button>
</div>
</ModalAnimation>
</div>
</template>
<script>
import Modal from "./Modal";
export default {
name: `DiscardHand`,
components: {
"ModalAnimation": Modal
},
data() {return {
confirmVisible: false,
}},
computed: {
buttonIcon() {
return this.$store.state.discard_hand_icon;
},
},
methods: {
discardHand() {
/**
* Tells the server to discard the hand of the mediums
*/
this.confirmVisible = false;
console.debug(`Telling server to discard team's hand.`);
this.$socket.client.emit(`NewHand`, {
game_code: this.$store.state.game_code,
team: this.$store.state.team
});
},
},
}
</script>
<style scoped>
@import "../css/theme.css";
@import "../css/style.css";
#DiscardHandButton {
height: var(--size);
width: var(--size);
position: absolute;
--size: 120px;
bottom: 0;
right: 0;
}
button {
outline: none;
}
.discard-hand {
background-color: var(--background3);
border-radius: 100% 0 0 0;
position: relative;
border-style: none;
height: 100%;
width: 100%;
padding: 0;
}
.icon {
position: absolute;
bottom: 15px;
right: 15px;
width: 60px;
}
.buttons {
justify-content: space-evenly;
display: flex;
}
.modal-button {
border-radius: 7px;
border-style: none;
font-size: larger;
padding: 7px;
margin: 5px;
}
.confirm {
background-color: var(--confirm-background);
color: var(--confirm-text);
}
.confirm:hover { background-color: var(--confirm-background-darken); }
.confirm:focus { background-color: var(--confirm-background-lighten); }
.cancel {
background-color: var(--cancel-background);
color: var(--cancel-text);
}
.cancel:hover { background-color: var(--cancel-background-darken); }
.cancel:focus { background-color: var(--cancel-background-lighten); }
</style>

View file

@ -0,0 +1,253 @@
<template>
<div id="GameBoard">
<div id="other-team-answers" class="team-container">
<h2 class="centre">{{ $store.getters.otherTeamName }} Answers</h2>
<div class="answer-container">
<!--
Repeats to create the number of team answers that we need,
these text inputs are always disabled for the player as these
inputs are only ever for the opposing team. They still have
a model attribute to keep them synced correctly.
-->
<div
class="answer"
v-for="answerIndex in 8"
:key="`${otherTeamID}-answer-container-${answerIndex}`"
>
<input
type="text"
class="other-team-answer"
:id="`${otherTeamID}-answer-${answerIndex}`"
v-model="answers[`team_${3 - $store.state.team}`][answerIndex-1]"
disabled
>
<!-- Display the number of eyes for the slot on the board -->
<span
class="other-team eye-container"
v-if="$store.state[`team_${3 - $store.state.team}`].eyes[answerIndex] > 0"
>
<span
v-if="$store.state[`team_${3 - $store.state.team}`].eyes[answerIndex] > 1"
class="eye-multiplier"
>
{{ $store.state[`team_${3 - $store.state.team}`].eyes[answerIndex] }} x
</span>
<img
class="eye"
:src="`/assets/${$store.state.eye_icon}`"
alt="reveal another letter"
>
</span>
</div>
</div>
</div>
<div id="team-answers" class="team-container">
<h2 class="centre">{{ $store.getters.teamName }} Answers</h2>
<div class="answer-container">
<!--
This repeats to create the volume oftext inputs that we need,
only allowing the text inputs to be used by the spirit players
and having them be disabled for all other players
-->
<div
class="answer"
v-for="answerIndex in 8"
:key="`${teamID}-answer-container-${answerIndex}`"
>
<input
type="text"
class="team-answer"
:id="`${teamID}-answer-${answerIndex}`"
@input.stop="answerInputHandler(answerIndex)"
v-model="answers[`team_${$store.state.team}`][answerIndex-1]"
>
<!-- Display the number of eyes for the slot on the board -->
<span
class="team eye-container"
v-if="$store.state[`team_${$store.state.team}`].eyes[answerIndex] > 0"
>
<img
class="eye"
:src="`/assets/${$store.state.eye_icon}`"
alt="reveal another letter"
>
<span
v-if="$store.state[`team_${$store.state.team}`].eyes[answerIndex] > 1"
class="eye-multiplier"
>
x {{ $store.state[`team_${$store.state.team}`].eyes[answerIndex] }}
</span>
</span>
</div>
</div>
</div>
<button
class="past-questions-toggle clickable"
@click.self="visible = !visible"
>
{{ visible ? `Hide` : `Show` }} Past Questions
</button>
<transition name="expand-from-left">
<past-questions v-if="visible" @error="$emit(`error`, $event)" />
</transition>
</div>
</template>
<script>
import PastQuestions from './PastQuestions';
export default {
name: `GameBoard`,
components: {
"past-questions": PastQuestions
},
data() {return {
visible: false,
answers: {
team_1: [ ``, ``, ``, ``, ``, ``, ``, `` ],
team_2: [ ``, ``, ``, ``, ``, ``, ``, `` ],
},
}},
computed: {
teamID() {
return this.$store.getters.teamName.replace(/\s/g, `-`).toLowerCase();
},
otherTeamID() {
return this.$store.getters.otherTeamName.replace(/\s/g, `-`).toLowerCase();
},
},
methods: {
answerInputHandler(answerIndex) {
/**
* Sends input data updates to the server when they occur, indicating
* the data as necessary
*/
let team = this.$store.state.team;
let data = {
game_code: this.$store.state.game_code,
team: team,
answer: answerIndex,
value: this.answers[`team_${team}`][answerIndex - 1]
};
this.$socket.client.emit(`UpdateAnswer`, data);
},
},
sockets: {
UpdateAnswer(data) {
/**
* Receives the updates for the answer for both teams, updating the
* data for the text inputs to be displayed dynamically.
*
* data -> {
* team: 1 | 2,
* answer: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8,
* value: string
* }
*/
this.answers[`team_${data.team}`].splice(data.answer - 1, 1, data.value);
},
},
}
</script>
<style scoped>
@import "../css/theme.css";
@import "../css/style.css";
#GameBoard {
background-color: var(--board-background);
color: var(--board-background-text);
justify-content: space-evenly;
padding-bottom: 10px;
flex-direction: row;
border-radius: 20px;
position: relative;
margin: 15px auto;
display: flex;
width: 95%;
}
h2 {
margin: 12px 0;
}
.team-container {
grid-template-rows: 50px 1fr;
display: grid;
width: 45%;
}
.answer-container {
justify-content: space-evenly;
flex-direction: column;
align-items: center;
display: flex;
}
.answer {
position: relative;
width: 100%;
}
.eye-container {
position: absolute;
width: 70px;
z-index: 1;
}
.team {
right: -40px;
top: 25%;
}
.other-team {
text-align: right;
left: -50px;
top: 25%;
}
.eye {
height: 25px;
vertical-align: bottom;
}
.eye-multiplier {
display: inline-block;
}
input[type="text"] {
font-family: var(--fonts);
background-color: var(--board-background-alt);
color: var(--board-background-alt-text);
border-color: transparent;
border-style: solid;
border-radius: 7px;
border-width: 2px;
font-size: larger;
outline: none;
margin: 7px 0;
padding: 7px;
width: 90%;
}
input[type="text"]:focus {
border-color: var(--board-background-text);
}
input[type="text"].team-answer {
padding-right: 5%;
}
input[type="text"].other-team-answer {
padding-left: 5%;
}
button {
background-color: var(--board-background-alt);
border-radius: 0 20px 0 7px;
position: absolute;
padding: 10px;
right: 0;
top: 0;
}
button:hover { background-color: var(--board-background-alt-darken); }
button:focus { background-color: var(--board-background-alt-lighten); }
</style>

185
web/src/components/Hand.vue Normal file
View file

@ -0,0 +1,185 @@
<template>
<div id="PlayerHand">
<div class="recentQuestion" v-if="mostRecentQuestion">
{{ mostRecentQuestion }}
</div>
<div class="hand" v-else>
<div
class="card"
v-for="cardIndex in questions.length"
:key="`card_${cardIndex}`"
@click.self="handleCardClick(cardIndex)"
>
<p class="card-text centre">
{{ questions[cardIndex - 1] }}
</p>
<button
class="card-button clickable"
@click.stop="sendCard(cardIndex)"
>
{{ buttonLabel }}
</button>
</div>
</div>
</div>
</template>
<script>
export default {
name: `PlayerHand`,
components: {},
data() {return {
mostRecentQuestion: null,
}},
computed: {
userRole() {
return this.$store.state.role;
},
isGuesser() {
return this.userRole === `guesser`;
},
isWriter() {
return this.userRole === `writer`;
},
buttonLabel() {
if (this.isGuesser) {
return this.$store.state.guesser_card_button
} else if (this.isWriter) {
return this.$store.state.writer_card_button
} else {
return `Unknown Role`
}
},
questions() {
return this.$store.state.questions;
}
},
methods: {
sendCard(cardIndex) {
// Create the data object for the server to receive
let data = {
game_code: this.$store.state.game_code,
text: this.questions[cardIndex - 1],
from: this.userRole,
team: this.$store.state.team,
};
this.questions.splice(cardIndex - 1, 1);
// Discard the rest of the writer's hand
if (this.isWriter) {
this.mostRecentQuestion = data.text;
this.$store.commit(`replaceHand`, []);
};
this.$socket.client.emit(`SendCard`, data);
}
},
mounted() {
if (this.isGuesser) {
console.debug(`Getting hand from server`);
this.$socket.client.emit(`GetHand`, {
game_code: this.$store.state.game_code,
team: this.$store.state.team
});
};
},
sockets: {
UpdateHand(data) {
/**
* Triggered when the client gets a new card for their hand, if the
* "from" property is set to either of the
*
* data = {
* questions: String[],
* mode: "append"|"replace",
* }
*/
console.debug(`Updating hand.`);
switch (data.mode) {
case `append`:
if (this.isWriter && this.mostRecentQuestion) {
this.mostRecentQuestion = null;
};
this.$store.commit(`appendToHand`, data.questions);
break;
case `replace`:
this.$store.commit(`replaceHand`, data.questions);
break;
default:
console.error(`Server returned an unsupported mode: ${data.mode}`);
};
},
},
}
</script>
<style scoped>
@import "../css/theme.css";
@import "../css/style.css";
#PlayerHand {
background-color: var(--background2);
border-radius: 20px;
margin: 0 auto;
padding: 0px;
width: 95%;
}
.recentQuestion {
justify-content: center;
align-items: center;
display: flex;
height: 100%;
width: 100%;
}
.hand {
justify-content: space-evenly;
flex-direction: row;
align-items: center;
flex-wrap: nowrap;
overflow-x: auto;
display: flex;
height: 100%;
width: 100%;
}
.card {
background-color: var(--card-background);
color: var(--card-text);
flex-direction: column;
width: calc(100% / 9);
border-radius: 10px;
padding: 10px;
display: flex;
height: 80%;
}
.card-text {
flex-grow: 1;
}
.question-quote {
font-size: large;
}
.button-container {
justify-content: space-evenly;
display: flex;
}
button {
border-radius: 7px;
font-size: larger;
padding: 7px;
margin: 5px;
}
.card-button {
background: var(--card-button);
font-size: medium;
}
.card-button:hover { background-color: var(--card-button-darken); }
</style>

View file

@ -0,0 +1,85 @@
<template>
<transition name="fade" @after-enter="content = true">
<div
v-if="show"
class="modal-container"
:class="closable ? 'clickable' : 'unclickable'"
@click.self.stop="handleBackgroundClick"
>
<transition name="burst" @after-leave="$emit('closed')">
<div v-if="content" class="modal unclickable">
<slot />
</div>
</transition>
</div>
</transition>
</template>
<script>
export default {
name: `ModalAnimation`,
props: {
show: {
required: true,
type: Boolean,
},
closable: {
required: false,
type: Boolean,
default: true,
}
},
data() {return {
content: false,
}},
methods: {
handleBackgroundClick() {
if (this.closable) {
this.content = false;
};
},
},
watch: {
show(newVal) {
/**
* This method is used to re-set the animation for the modal when
* the modal is closed through changing the `show` property rather
* than having a `closed` event emitted.
*/
if (!newVal) {
this.content = false;
}
},
},
}
</script>
<style>
@import "../css/theme.css";
@import "../css/style.css";
@import "../css/transitions.css";
.modal-container {
background-color: var(--modal-background-blur);
justify-content: center;
align-items: center;
position: fixed;
display: flex;
height: 100vh;
width: 100vw;
z-index: 10;
left: 0;
top: 0;
}
.modal {
background-color: var(--modal-content-background);
color: var(--modal-content-text);
border-radius: 20px;
overflow-y: auto;
max-height: 75%;
padding: 15px;
z-index: 11;
width: 40%
}
</style>

View file

@ -0,0 +1,47 @@
<template>
<div id="ObjectReminder">
<span class="text">
Object:
<br>
{{ targetObject }}
</span>
</div>
</template>
<script>
export default {
name: `ObjectReminder`,
components: {},
computed: {
targetObject() {
return this.$store.state.chosen_object;
},
},
methods: {},
}
</script>
<style scoped>
@import "../css/theme.css";
@import "../css/style.css";
#ObjectReminder {
background-color: var(--background3);
color: var(--background3-text);
border-radius: 100% 0 0 0;
height: var(--size);
width: var(--size);
position: fixed;
--size: 120px;
bottom: 0;
right: 0;
}
.text {
text-align: center;
position: absolute;
font-size: large;
bottom: 30px;
right: 20px;
}
</style>

View file

@ -0,0 +1,96 @@
<template>
<div id="PastQuestions">
<h2 class="centre">{{ $store.getters.teamName }} Questions</h2>
<div class="questions-container">
<div
class="question"
v-for="questionIndex in 8"
:key="`question_${questionIndex}`"
>
{{ questions[questionIndex - 1] }}
</div>
</div>
</div>
</template>
<script>
export default {
name: `PastQuestions`,
components: {},
data() {return {
intervalID: null,
questions: [],
}},
computed: {},
methods: {
requestQuestions() {
console.debug(`Requesting questions for team ${this.$store.state.team}`);
this.$socket.client.emit(`GetPastQuestions`, {
game_code: this.$store.state.game_code,
team: this.$store.state.team
});
},
},
sockets: {
PastQuestions(data) {
if (data.status < 200 || 300 <= data.status) {
this.$emit(`error`, data);
};
this.questions = data.questions;
},
},
mounted() {
this.requestQuestions();
this.intervalID = setInterval(
this.requestQuestions,
5000
);
},
beforeDestroy() {
if (this.intervalID != null) {
clearInterval(this.intervalID);
console.debug(`Cleared interval with ID: ${this.intervalID}`);
};
},
}
</script>
<style scoped>
@import "../css/theme.css";
@import "../css/style.css";
#PastQuestions {
background-color: var(--board-background-alt);
color: var(--board-background-alt-text);
border-radius: 20px 0 0 20px;
height: calc(100% - 10px);
flex-direction: column;
padding-bottom: 10px;
position: absolute;
display: flex;
z-index: 3;
width: 50%;
left: 0;
top: 0;
}
.questions-container {
flex-grow: 1;
justify-content: space-evenly;
flex-direction: row;
flex-wrap: wrap;
display: flex;
}
.question {
background-color: var(--board-background);
color: var(--board-background-text);
padding: 10px 10px 0 10px;
justify-content: center;
align-items: center;
border-radius: 7px;
display: flex;
margin: 5px;
width: 40%;
}
</style>

View file

@ -0,0 +1,89 @@
<template>
<div id="player_list" class="centre">
<h2>Players:</h2>
<div
v-for="player in $store.state.players"
:key="player.name"
>
{{ player.name }}
<span v-if="player.role" class="player-role">
( {{ teamName(player.team) }} {{ roleName(player.role) }} )
</span>
</div>
</div>
</template>
<script>
export default {
name: `PlayerList`,
components: {},
computed: {
isHost() {
return this.$store.state.is_host;
},
players() {
return this.$store.state.players;
},
gameCode() {
return this.$store.state.game_code;
},
},
methods: {
teamName(teamID) {
return this.$store.state[`team_${teamID}`].name;
},
roleName(role) {
return this.$store.state[`${role}_name`]
},
},
sockets: {
PlayerUpdate(data) {
/**
* data = {
* status: integer,
* mode: "modify" | "new" | "remove",
* name: string,
* team: integer,
* role: string
* }
*/
if (!(200 <= data.status && data.status < 300)) {
this.$emit(`error`, data);
return;
};
switch (data.action) {
case "modify":
if (this.$store.state.name === data.name) {
this.$store.commit(`player`, {
role: data.role,
team: data.team,
});
};
this.$store.commit(`updatePlayer`, data)
break;
case "new":
case "remove":
this.$store.commit(`playerList`, data.players);
break;
default:
console.warn(`Unknown response type from "PlayerUpdate": ${data.mode}`);
};
},
},
}
</script>
<style scoped>
@import "../css/theme.css";
@import "../css/style.css";
#player_list {
background-color: var(--background2);
color: var(--background2-text);
padding: 0 15px 15px;
border-radius: 20px;
margin: 5px 10px;
width: 15%;
}
</style>

View file

@ -0,0 +1,58 @@
<template>
<div id="TeamReminder">
<img
:class="`team_${teamNumber}`"
:src="`/assets/${teamIcon}`"
:alt="`${teamName} Team Icon`"
>
</div>
</template>
<script>
export default {
name: `TeamReminder`,
components: {},
computed: {
teamNumber() {
return this.$store.state.team;
},
teamName() {
return this.$store.state[`team_${this.teamNumber}`].name;
},
teamIcon() {
return this.$store.state[`team_${this.teamNumber}`].icon;
},
},
methods: {},
}
</script>
<style scoped>
@import "../css/theme.css";
@import "../css/style.css";
#TeamReminder {
background-color: var(--background3);
border-radius: 0 100% 0 0;
height: var(--size);
width: var(--size);
position: fixed;
--size: 120px;
bottom: 0;
left: 0;
}
img.team_1 {
width: calc(var(--size) / 1.5);
position: absolute;
bottom: 7px;
left: 7px;;
}
img.team_2 {
width: calc(var(--size) / 2);
bottom: calc(var(--size) / 8);
left: calc(var(--size) / 8);
position: absolute;
}
</style>

View file

@ -0,0 +1,95 @@
<template>
<div :id="`${teamName}-role-select`" class="team-select">
<h2 class="centre">{{ teamName }} Team</h2>
<button
class="clickable"
@click.stop="joinRole(`writer`)"
>
{{ $store.state.writer_name }}
</button>
<button
class="clickable"
@click.stop="joinRole(`guesser`)"
>
{{ $store.state.guesser_name }}
</button>
</div>
</template>
<script>
export default {
name: `TeamRoleSelection`,
components: {},
props: {
teamID: {
type: Number,
required: true,
},
},
computed: {
teamName() {
return this.$store.state[`team_${this.teamID}`].name;
}
},
methods: {
joinRole(role) {
if (this.teamID == this.$store.state.team && this.$store.state.role == role) {
this.$emit(`error`, {
status: 403,
message: `You are already that role on that team.`,
});
return;
};
let response = {
action: `modify`,
game_code: this.$store.state.game_code,
name: this.$store.state.name,
to: {
team: this.teamID,
role: role,
},
}
if (this.$store.state.team != null) {
response.from = {
team: this.$store.state.team,
role: this.$store.state.role,
};
};
this.$socket.client.emit(`UpdatePlayer`, response);
},
},
}
</script>
<style scoped>
@import "../css/theme.css";
@import "../css/style.css";
.team-select {
background-color: var(--background2);
color: var(--background2-text);
flex-direction: column;
border-radius: 20px;
align-items: center;
padding: 0 0 10px 0;
display: flex;
margin: 5px;
width: 25%;
}
button {
background-color: var(--background3);
color: var(--background3-text);
border-radius: 50px;
font-size: larger;
padding: 10px;
margin: 10px;
width: 85%;
}
button:hover { background-color: var(--background3-darken); }
button:focus { background-color: var(--background3-lighten); }
</style>

17
web/src/css/style.css Normal file
View file

@ -0,0 +1,17 @@
.maximize {
width: 100%;
height: 100%;
}
.clickable { cursor: pointer; }
.unclickable { cursor: default; }
.centre {
text-align: center;
}
button {
border-style: none;
outline: none;
}

77
web/src/css/theme.css Normal file
View file

@ -0,0 +1,77 @@
:root {
/*
The fonts and font colours the site will use
*/
--fonts: "Roboto", "Open Sans", sans-serif;
--light-font-colour: #ECE3BB;
--dark-font-colour: #000F3D;
/*
The darkest colour in the trio for backgrounds, this is used for the
site-wide background and modal content.
*/
--background1: #001233;
--background1-darken: #000c24;
--background1-lighten: #001a49;
--background1-text: var(--light-font-colour);
/*
The middle colour in the trio for backgrounds.
Used for hand background, buttons, regions, etc.
*/
--background2: #24356E;
--background2-darken: #19295e;
--background2-lighten: #364a8d;
--background2-text: var(--light-font-colour);
/* The colours used for the lightest shades of the background regions. */
--background3: #4A5081;
--background3-darken: #454b7e;
--background3-lighten: #5a6192;
--background3-text: var(--light-font-colour);
/*
The colours for the cards in the hands of the players
*/
--card-background: #E1D098;
--card-text: var(--dark-font-colour);
--card-button: #ACA885;
--card-button-darken: #88845e;
--card-button-lighten: #d1ceaf;
/*
The colours used for the main game board. While playing, the alt colour
is used for the text input backgrounds, as well as the question toggle
button, then used as the main background of the past questions insert,
with the primary background being the text input background.
The text colours are also used for the SVG eyes, if you change the
"--board-background-text" variable, also change the fill value in the
/public/assets/eye.svg file to match it
*/
--board-background: #ACA885;
--board-background-alt: #E1D098;
--board-background-alt-darken: #cab981;
--board-background-alt-lighten: #f1e4b7;
--board-background-text: var(--dark-font-colour);
--board-background-alt-text: var(--dark-font-colour);
/* The fill colour of the eyes on the game board */
--eye-colour: #000000;
/* The colours for the modals */
--modal-background-blur: #000000a6;
--modal-content-background: var(--background1);
--modal-content-text: var(--background1-text);
/* The colours used for the buttons in the modal that are for confirming actions */
--confirm-background: #018501;
--confirm-background-darken: #006600;
--confirm-background-lighten: #00aa00;
--confirm-text: white;
--cancel-background: #aa0000;
--cancel-background-darken: #830101;
--cancel-background-lighten: #e71111;
--cancel-text: white;
}

115
web/src/css/transitions.css Normal file
View file

@ -0,0 +1,115 @@
/* Transition for modal background appearing/disappearing */
.fade-enter-active, .fade-leave-active {
transition: opacity .5s;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
/* Transition for modal card appearing disappearing */
.top-slide-enter-active {
animation: top-slide-in 1s;
}
.top-slide-leave-active { animation: top-slide-out 1s; }
@keyframes top-slide-in {
0% {
transform: translateY(-100%);
}
100% {
transform: translateY(0);
}
}
@keyframes top-slide-out {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-100%);
}
}
/* Transition for past questions expanding in */
.expand-from-left-enter-active {
animation: expand-from-left 1s;
transform-origin: left;
}
.expand-from-left-leave-active {
animation: shrink-to-left 1s;
transform-origin: left;
}
@keyframes expand-from-left {
0% {
transform: scaleX(0);
}
100% {
transform: scaleX(1);
}
}
@keyframes shrink-to-left {
0% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
/* Transition for modal card appearing disappearing */
.burst-enter-active {
animation: burst-in .5s;
}
.burst-leave-active { animation: burst-out .5s; }
@keyframes burst-in {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes burst-out {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@media only screen and (min-width: 768px) {
@keyframes burst-in {
0% {
transform: scale(0);
}
50% {
transform: scale(1.25);
}
100% {
transform: scale(1);
}
}
@keyframes burst-out {
0% {
transform: scale(1);
}
50% {
transform: scale(1.25);
}
100% {
transform: scale(0);
}
}
}

16
web/src/main.js Normal file
View file

@ -0,0 +1,16 @@
import Vue from 'vue';
import App from './App.vue';
import store from './store';
import io from 'socket.io-client';
import clipboard from "vue-clipboard2";
import VueSocketIOExt from 'vue-socket.io-extended';
Vue.config.productionTip = false;
Vue.use(clipboard);
Vue.use(VueSocketIOExt, io(`http://${window.location.hostname}:8081`));
new Vue({
store,
render: h => h(App)
}).$mount('#app');

125
web/src/store/index.js Normal file
View file

@ -0,0 +1,125 @@
import Vue from 'vue';
import Vuex from 'vuex';
import VuexPersistence from 'vuex-persist';
Vue.use(Vuex);
export default new Vuex.Store({
state: {
team_1: {
name: `Sun`,
icon: `sun.svg`,
eyes: {
1: 0, 2: 0,
3: 0, 4: 0,
5: 0, 6: 0,
7: 0, 8: 0,
},
},
team_2: {
name: `Moon`,
icon: `moon.svg`,
eyes: {
1: 0, 2: 0,
3: 0, 4: 0,
5: 0, 6: 0,
7: 0, 8: 0,
},
},
writer_name: `Spirit`,
writer_card_button: `Answer Question`,
writer_object_choose_button: `Choose Object`,
guesser_name: `Medium`,
guesser_card_button: `Ask Spirit`,
eye_icon: `eye.svg`,
discard_hand_icon: `trash.svg`,
//===========================================================================//
// DO NOT EDIT ANYTHING BELOW THIS COMMENT
view: `login`,
role: null,
team: null,
name: ``,
is_host: false,
chosen_object: null,
questions: [],
game_code: null,
players: [],
},
getters: {
teamName(state) {
if (state.team > 0) {
return state[`team_${state.team}`].name;
};
return ``;
},
otherTeamName(state) {
if (state.team > 0) {
return state[`team_${3 - state.team}`].name;
};
return ``;
},
},
mutations: {
resetState(state) {
state.view = `login`;
state.role = null;
state.team = null;
state.name = ``;
state.is_host = false;
state.chosen_object = null;
state.questions = [];
state.game_code = null;
state.players = [];
},
player(state, data) {
if (data.name)
state.name = data.name
if (data.role)
state.role = data.role
if (data.team)
state.team = data.team
if (data.host)
state.is_host = data.host
},
game_code(state, game_code) {
state.game_code = game_code;
},
view(state, target) {
state.view = target;
},
playerList(state, players) {
state.players = players;
},
updatePlayer(state, data) {
for (var player of state.players) {
if (player.name == data.name) {
player.role = data.role;
player.team = data.team;
};
};
},
newPlayer(state, player) {
state.players.push(player);
},
setObject(state, chosenObject) {
state.chosen_object = chosenObject;
},
replaceHand(state, questions) {
state.questions = questions;
},
appendToHand(state, questions) {
state.questions.push(...questions);
},
},
actions: {
},
modules: {
},
plugins:
process.env.NODE_ENV === `production`
? [new VuexPersistence({ key: `ghost-writer-save` }).plugin]
: []
});

View file

@ -0,0 +1,132 @@
<template>
<div id="CreateJoinGame" class="maximize view">
<h1>Ghost Writer Online</h1>
<button
@click.stop="createGame()"
>Create Game</button>
<button
@click.stop="joinGame()"
>Join Game</button>
</div>
</template>
<script>
export default {
name: `CreateJoinGame`,
components: {},
data() {return {
name: null,
game_code: null,
}},
computed: {},
methods: {
createGame() {
this.name = prompt(`What is your name?`);
// Assert that the user entered a name and didn't cancel
if (this.name) {
this.$socket.client.emit(`CreateGame`, {
name: this.name,
});
};
},
joinGame() {
// Get the user's name
this.name = prompt(`Enter a username:`);
if (!this.name) {
this.$emit(`error`, {
status: 406,
message: `Can't join a game without a name`,
extra: `(provided a false-y name = ${this.name})`
});
return;
};
let qs = new URLSearchParams(window.location.search);
// Get the game code
if (qs.has(`game`)) {
this.game_code = qs.get(`game`);
} else {
this.game_code = prompt(`What is the game code?`);
};
this.$socket.client.emit(`JoinGame`, {
name: this.name,
game_code: this.game_code
});
},
},
sockets: {
GameJoined(data) {
// Check for errors
if (!(200 <= data.status && data.status < 300)) {
this.$emit(`error`, data);
return;
};
history.replaceState(null, ``, `?game=${this.game_code}`);
// Save the data in the store
this.$store.commit(`playerList`, data.players);
this.$store.commit(`game_code`, this.game_code);
this.$store.commit(`player`, {
name: this.name,
host: false,
});
this.$store.commit(`view`, `lobby`);
},
GameCreated(data) {
if (!(200 <= data.status && data.status < 300)) {
this.$emit(`error`, data);
return;
};
history.replaceState(null, ``, `?game=${data.game_code}`);
// Update storage
this.$store.commit(`playerList`, data.players);
this.$store.commit(`game_code`, data.game_code);
this.$store.commit(`player`, {
name: this.name,
host: true,
});
this.$store.commit(`view`, `lobby`);
},
GameRejoined(data) {
if (!(200 <= data.status && data.status < 300)) {
this.$emit(`error`, data);
return;
};
// TODO -> Update all data that is received from the server
},
},
mounted() {},
}
</script>
<style scoped>
@import "../css/theme.css";
@import "../css/style.css";
#CreateJoinGame {
display: flex;
flex-direction: column;
align-items: center;
}
button {
background-color: var(--background2);
color: var(--background2-text);
border-radius: 50px;
font-size: larger;
padding: 10px;
margin: 10px;
width: 25%;
}
button:hover { background-color: var(--background2-darken); }
button:focus { background-color: var(--background2-lighten); }
</style>

62
web/src/views/InGame.vue Normal file
View file

@ -0,0 +1,62 @@
<template>
<div id="GameView" class="maximize">
<game-board
v-if="isGuesser || objectChosen"
@error="$emit(`error`, $event)"
/>
<object-selector
v-else
@error="$emit(`error`, $event)"
/>
<player-hand @error="$emit(`error`, $event)" />
<team-reminder @error="$emit(`error`, $event)" />
<discard-hand
v-if="isGuesser"
@error="$emit(`error`, $event)"
/>
<object-reminder v-else-if="isWriter && objectChosen" />
</div>
</template>
<script>
import ObjectReminder from "../components/ObjectReminder";
import DiscardHand from "../components/DiscardHandButton";
import ObjectSelector from "../components/ChooseObject";
import TeamReminder from "../components/TeamReminder";
import GameBoard from "../components/GameBoard";
import PlayerHand from "../components/Hand";
export default {
name: `InGame`,
components: {
"object-selector": ObjectSelector,
"team-reminder": TeamReminder,
"discard-hand": DiscardHand,
"player-hand": PlayerHand,
"game-board": GameBoard,
"object-reminder": ObjectReminder
},
computed: {
isGuesser() {
return this.$store.state.role === `guesser`;
},
isWriter() {
return this.$store.state.role === `writer`;
},
objectChosen() {
return this.$store.state.chosen_object != null;
},
},
methods: {},
}
</script>
<style scoped>
@import "../css/theme.css";
@import "../css/style.css";
#GameView {
grid-template-rows: 70% 1fr 50px;
display: grid;
}
</style>

142
web/src/views/Lobby.vue Normal file
View file

@ -0,0 +1,142 @@
<template>
<div id="GameLobby" class="maximize view">
<h1 class="centre">Ghost Writer Online</h1>
<div class="flex-row">
<button
class="clickable"
v-clipboard:copy="gameURL"
v-clipboard:success="copySuccess"
v-clipboard:error="copyError"
>
{{ copyURLButtonText }}
</button>
</div>
<div class="flex-row">
<role-select
:teamID="1"
@error="$emit(`error`, $event)"
/>
<player-list @error="$emit(`error`, $event)" />
<role-select
:teamID="2"
@error="$emit(`error`, $event)"
/>
</div>
<div class="flex-row">
<button
@click.stop="exitGame()"
>
{{ $store.state.is_host ? `Delete` : `Leave`}} Game
</button>
<button
class="clickable"
@click.stop="startGame()"
>
Click to Start the Game
</button>
</div>
</div>
</template>
<script>
import TeamRoleSelect from "../components/TeamRoleSelect";
import PlayerList from "../components/PlayerList";
export default {
name: `GameLobby`,
components: {
"RoleSelect": TeamRoleSelect,
"PlayerList": PlayerList,
},
data() {return {
copyURLButtonText: `Click to Copy Game Link`,
}},
computed: {
playerName() {
return this.$store.state.name;
},
gameURL() {
return `${window.location.protocol}//${window.location.host}/?game=${this.gameCode}`;
},
gameCode() {
return this.$store.state.game_code;
},
},
methods: {
copySuccess() {
this.copyURLButtonText = `Game Link Copied!`;
setTimeout(() => { this.copyURLButtonText = `Click to Copy Game Link`; }, 1000)
},
copyError() {
this.$emit(`error`, {
status: 418,
message: `Failed to copy game URL`,
});
},
exitGame() {
// The user is the host, they can't leave the game, so kick
// everyone from the game.
if (this.$store.state.is_host) {
this.$socket.client.emit(`DeleteGame`, {
game_code: this.gameCode
});
}
// Just a normal user, they can leave the game just fine
else {
this.$socket.client.emit(`LeaveGame`, {
game_code: this.gameCode
});
};
},
startGame() {
this.$socket.client.emit(`StartGame`, {
game_code: this.gameCode
})
},
},
sockets: {
GameLeft(data) {
if (data.status < 200 || 300 <= data.status) {
return this.$emit(`error`, data);
};
this.$store.commit(`resetState`);
},
GameStarted(data) {
if (data.status < 200 || 300 <= data.status) {
return this.$emit(`error`, data);
};
this.$store.commit(`view`, `in-game`);
},
},
}
</script>
<style scoped>
@import "../css/theme.css";
@import "../css/style.css";
#GameLobby {
flex-direction: column;
align-items: stretch;
display: flex;
}
.flex-row {
justify-content: center;
align-items: stretch;
display: flex;
}
button {
background-color: var(--background2);
color: var(--background2-text);
border-radius: 50px;
font-size: larger;
padding: 10px;
margin: 10px;
width: 30%;
}
button:hover { background-color: var(--background2-darken); }
button:focus { background-color: var(--background2-lighten); }
</style>

1
web/vue.config.js Normal file
View file

@ -0,0 +1 @@
module.exports = {}