Merge branch 'main' into main

This commit is contained in:
Rafael Caetano 2021-01-09 00:58:54 +09:00 committed by GitHub
commit 72a211cb8d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 3465 additions and 2088 deletions

67
.github/workflows/codeql-analysis.yml vendored Normal file
View file

@ -0,0 +1,67 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '27 22 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View file

@ -18,6 +18,9 @@ on:
push:
branches-ignore:
- 'gh-pages'
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
###############
# Set the Job #

96
CHANGELOG.md Normal file
View file

@ -0,0 +1,96 @@
# Release Notes
## Version 2.3.0
- added spoiler role (Lycanthrope!)
- fixed copy to clipboard in Firefox
- fixed non-countdown votes still playing countdown sound for a split second
---
## Version 2.2.1
- clearing players / roles now also clears Fabled (closes #85)
- fix list of locked votes showing unlocked votes sometimes
---
## Version 2.2.0
- added [V] hotkey to open nomination history (thanks @lilserf)
- updated roles according to official Wiki changes
- adjusted roles night order
---
## Version 2.1.1
- show vote results at the end of a vote
- fixed global reminders not showing up anymore when the associated role is assigned to a player
- adjusted backend metrics
---
## Version 2.1.0
- reduced countdown volume by 10db
- added a mute toggle to the Grimoire menu (currently only silences the countdown)
- pressing [J] while in a session will now leave the session
- always show reminder add button when on a mobile device that doesn't support hovering
- removed screenshot feature as it is no longer useful
---
## Version 2.0.4
- fix bug with live sessions that contain travelers from a different set
- fix server channel cleanup
---
## Version 2.0.3
- load roles that belong to different editions (like travelers) from gamestate
- close session when missing custom roles and open edition modal
- added a few more metrics
---
## Version 2.0.2
- fix nomination history type not detecting travelers
- fix live session domain whitelist
- fix build path
- fix changelog version numbering
---
## Version 2.0.1
- clearing the nomination history as the Storyteller clears it for the players too
- vote buttons should work in all situations correctly now
- fixed some minor styling and live session issues
---
## Version 2.0.0
- The project is now available under its own domain: [clocktower.online](https://clocktower.online)
- Added a feature that allows a live session Storyteller to automatically (and safely) distribute assigned
characters to all players that have claimed a seat, eliminating the need to manually tell every player their role
- Visible "night phase" that can be toggled by the Storyteller
- Voting history added with nomination and vote results
- Optional, audible voting countdown added (featuring an actual clock tower bell!)
- Fabled show up on the Night Order sheet and affect Grimoire night order counters
- Current game state can now be easily exported and imported in the form of a JSON text code
- Voting can be paused and sped up / slowed down in 0.5 second increments by the Storyteller
- Voting terminology changed to "Hand UP" / "Hand DOWN" and iconography updated
- Added meta-data support for custom scripts, that currently supports `name`, `author` and a custom `logo` through a
`_meta` role (note that a customized logo will not be synced to players in a live session)
- Players can no longer claim seats that are already occupied and only the Storyteller can vacate seats of other players
(players can still vacate their own seat)
- Characters selected in the bluff window now also show up in the list of reminder tokens
- Homebrew scripts / custom characters no longer automatically load in live sessions, for 2 reasons:
- the players in a live session have no control over the script that the storyteller loads,
so a malicious storyteller could load a custom script that contains harmful / inappropriate images
- some homebrew scripts are quite big JSON files and synching these through the live session
server can cause traffic / performance issue easily
- this change may be reverted in the future when I figure out a way to sync custom characters safely and without
such a big impact on performance constraints
- Buggy (spamming) live session connections will now be terminated on the server side and display an error message
- Balloonist reminder tokens adjusted
- Live session URLs shortened
- Deus Ex Fiasco and Stormcatcher Fabled added / updated
- Custom Reminder text looks better when there is a lot of text
- added a README for the backend server

1
CNAME Normal file
View file

@ -0,0 +1 @@
clocktower.online

View file

@ -1,25 +1,53 @@
# Blood on the Clocktower Grimoire & Town Square
![image](https://user-images.githubusercontent.com/325521/80531231-8f473980-899a-11ea-96d6-edbf79337cb5.png)
![social](https://user-images.githubusercontent.com/325521/102897760-d1147b00-4468-11eb-9d7b-63a204bc9fc1.png)
This is an unofficial online tool to run Blood on the Clocktower games through Discord or other digital means.
It is supposed to aid storytellers and allow them to quickly set up and capture game states for their players.
[You can try it online!](https://bra1n.github.io/townsquare)
[You can try it online!](https://clocktower.online)
To set up a game as the host, check out this tutorial video:
[![Tutorial video](https://img.youtube.com/vi/-MyizvdRbVw/0.jpg)](https://www.youtube.com/watch?v=-MyizvdRbVw)
If you want to learn more about how to use the website as a player, [JayBotC](https://www.youtube.com/channel/UCNZy-4Rp877XtTHaIZdWYFQ) kindly created two tutorial videos.
### Features
### How to host a game
[![How to host a game](https://img.youtube.com/vi/lVRJPBXfqxg/0.jpg)](https://www.youtube.com/watch?v=lVRJPBXfqxg)
### How to play a game
[![How to play a game](https://img.youtube.com/vi/VCpFnJFiCbk/0.jpg)](https://www.youtube.com/watch?v=VCpFnJFiCbk)
## Features
- Public Town Square and Storyteller Grimoire (toggle with **shortcut \[G\]**)
- Supports custom script JSON generated by the [Script Tool](https://bloodontheclocktower.com/script)
- Live Session for Storyteller / Players including live voting!
- Includes all 3 base editions and travelers
- Live Session for Storyteller / Players including live voting and character distribution!
- Includes all 3 base editions, Travelers and Fabled
- Night sheet and reminder text for each character ability to help storytellers
- Many other customization options!
### Custom Characters
### Custom Script Support
Any custom script generated by the official [Script Tool](https://bloodontheclocktower.com/script) is supported out of
the box and you only need to upload it to get the selected set of characters into your grimoire. If you want to customize
your script further, there is an additional `"_meta"` object that you can add to the script like you would add a normal
character:
```json
[
{
"id": "_meta",
"name": "Deadly Penance Day",
"author": "TPI",
"logo": "https://url.to/your/logo.png"
}
]
```
This will provide your local Grimoire (and those of your live session players) with more information to show about
your custom script - instead of "Custom Script" it would show "Deadly Penance Day" on the character reference sheet,
for example. The logo is shown only locally, if you want your players to see it as well, they will have to upload the
same JSON file that you used.
### Custom Character Support
In order to add custom characters to your local Grimoire, you need to create a JSON definition for them,
similar to what is provided in the [`roles.json`](https://github.com/bra1n/townsquare/blob/main/src/roles.json) for the 3 base editions. Here's an example of how such a character
@ -69,7 +97,8 @@ For base game characters, it is sufficient to only provide the ID, similar to wh
- **team**: the team of the character, has to be one of `townsfolk`, `outsider`, `minion`, `demon` or `traveler`
- **ability**: the displayed ability text of the character
_Note:_ custom characters are currently not supported in live sessions and will not be synchronised to other players.
_Note:_ in order to use custom characters in live sessions, your players have to load the same JSON file that the storyteller
has loaded before joining the live session.
## [Code of Conduct](CODE_OF_CONDUCT.md)

1309
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "townsquare",
"version": "1.9.1",
"version": "2.3.0",
"description": "Blood on the Clocktower Town Square",
"author": "Steffen Baumgart",
"scripts": {
@ -10,23 +10,24 @@
},
"main": "App.vue",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.29",
"@fortawesome/free-brands-svg-icons": "^5.13.1",
"@fortawesome/free-solid-svg-icons": "^5.13.1",
"@fortawesome/fontawesome-svg-core": "^1.2.32",
"@fortawesome/free-brands-svg-icons": "^5.15.1",
"@fortawesome/free-solid-svg-icons": "^5.15.1",
"@fortawesome/vue-fontawesome": "^0.1.10",
"@vue/cli-service": "^4.4.6",
"sass": "^1.26.9",
"@vue/cli-service": "^4.5.9",
"prom-client": "^13.0.0",
"sass": "^1.30.0",
"sass-loader": "^8.0.2",
"vue": "^2.3.3",
"vue-template-compiler": "^2.6.11",
"vuex": "^3.5.1",
"ws": "^7.3.0"
"vue": "^2.6.12",
"vue-template-compiler": "^2.6.12",
"vuex": "^3.6.0",
"ws": "^7.4.1"
},
"devDependencies": {
"@vue/cli-plugin-eslint": "^4.4.6",
"@vue/cli-plugin-eslint": "^4.5.9",
"@vue/eslint-config-prettier": "^6.0.0",
"eslint": "^6.7.2",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-prettier": "^3.2.0",
"eslint-plugin-vue": "^6.2.2",
"prettier": "^1.19.1"
},

View file

@ -25,7 +25,7 @@
<meta name="theme-color" content="#ff0000">
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed&display=swap" rel="stylesheet">
<meta name="description" content="A free, virtual Blood on the Clocktower Town Square and Grimoire to help you run (online) games both as a storyteller and player. Batteries included!">
<meta name="keywords" content="botc, blood, clocktower, storyteller, townsquare, grimoire, screenshot,town square, english, blood on the clocktower, character, tokens, reminder, free">
<meta name="keywords" content="botc, blood, clocktower, storyteller, townsquare, grimoire, voting,town square, english, blood on the clocktower, character, tokens, reminder, free">
<meta name="robots" content="index, follow">
<meta name="language" content="English">
<meta name="author" content="Steffen Baumgart">

53
server/README.md Normal file
View file

@ -0,0 +1,53 @@
## Live session server
This is the home of the NodeJS live session backend.
It allows a Storyteller and their player to communicate through
a Websocket interface with each other.
In order to run it, you need a recent NodeJS version (v12+) and a set
of SSL Certificate and Key files in order to run the socket through
a secured connection.
### Local setup
To run the backend locally, use the following commands from the project root:
```shell
npm install
cd server/
NODE_ENV=development node index.js
```
This will open the backend server on `localhost:8081` and you
need to adjust your `/src/store/socket.js` file to connect to
the localhost backend.
### Live setup
Generate a `cert.pem` and `key.pem` file for the domain that your
live session backend will be available under, for example with [Let's Encrypt](https://letsencrypt.org/).
Copy or symlink these 2 files into your `server/` folder and then run
the following commands from the project root:
```shell
npm install
cd server/
node index.js
```
This will make the backend server available at your domain on port 8080.
If you want to have it automatically recover on crash or server restart,
you could use [pm2](https://pm2.keymetrics.io/) with the provided `ecosystem.config.js`
### Allowing access from different domains
Currently the backend server only accepts connections coming from
pages hosted on github.io or localhost. If you want to use your own
domain for the page, make sure to adjust the domain whitelist pattern
around line 15 in the `index.js`:
```ecmascript 6
verifyClient: info =>
!!info.origin.match(
/^https?:\/\/([^.]+\.github\.io|localhost|live\.clocktower\.online|eddbra1nprivatetownsquare\.xyz)/i
)
```

View file

@ -1,6 +1,16 @@
const fs = require("fs");
const https = require("https");
const WebSocket = require("ws");
const client = require("prom-client");
// Create a Registry which registers the metrics
const register = new client.Registry();
// Add a default label which is added to all metrics
register.setDefaultLabels({
app: "clocktower-online"
});
const PING_INTERVAL = 30000; // 30 seconds
const server = https.createServer({
cert: fs.readFileSync("cert.pem"),
@ -9,80 +19,215 @@ const server = https.createServer({
const wss = new WebSocket.Server({
...(process.env.NODE_ENV === "development" ? { port: 8081 } : { server }),
verifyClient: info =>
info.origin &&
!!info.origin.match(
/^https?:\/\/(kamekura\.github\.io|localhost|eddbra1nprivatetownsquare\.xyz)/i
/^https?:\/\/([^.]+\.github\.io|localhost|clocktower\.online|eddbra1nprivatetownsquare\.xyz)/i
)
});
function noop() {}
// calculate latency on heartbeat
function heartbeat() {
this.latency = Math.round((new Date().getTime() - this.pingStart) / 2);
this.counter = 0;
this.isAlive = true;
}
// map of channels currently in use
const channels = {};
// metrics
const metrics = {
players_concurrent: new client.Gauge({
name: "players_concurrent",
help: "Concurrent Players",
collect() {
this.set(wss.clients.size);
}
}),
channels_concurrent: new client.Gauge({
name: "channels_concurrent",
help: "Concurrent Channels",
collect() {
this.set(Object.keys(channels).length);
}
}),
channels_list: new client.Gauge({
name: "channel_players",
help: "Players in each channel",
labelNames: ["name"],
collect() {
for (let channel in channels) {
this.set(
{ name: channel },
channels[channel].filter(
ws =>
ws &&
(ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING)
).length
);
}
}
}),
messages_incoming: new client.Counter({
name: "messages_incoming",
help: "Incoming messages"
}),
messages_outgoing: new client.Counter({
name: "messages_outgoing",
help: "Outgoing messages"
}),
connection_terminated_host: new client.Counter({
name: "connection_terminated_host",
help: "Terminated connection due to host already present"
}),
connection_terminated_spam: new client.Counter({
name: "connection_terminated_spam",
help: "Terminated connection due to message spam"
}),
connection_terminated_timeout: new client.Counter({
name: "connection_terminated_timeout",
help: "Terminated connection due to timeout"
})
};
// register metrics
for (let metric in metrics) {
register.registerMetric(metrics[metric]);
}
// a new client connects
wss.on("connection", function connection(ws, req) {
ws.channel = req.url
.split("/")
.pop()
.toLocaleLowerCase();
if (ws.channel.match(/-host$/i)) {
ws.isHost = true;
ws.channel = ws.channel.substr(0, ws.channel.length - 5);
// url pattern: clocktower.online/<channel>/<playerId|host>
const url = req.url.toLocaleLowerCase().split("/");
ws.playerId = url.pop();
ws.channel = url.pop();
// check for another host on this channel
if (
Array.from(wss.clients).some(
ws.playerId === "host" &&
channels[ws.channel] &&
channels[ws.channel].some(
client =>
client !== ws &&
client.readyState === WebSocket.OPEN &&
client.channel === ws.channel &&
client.isHost
client.playerId === "host"
)
) {
console.log(ws.channel, "duplicate host");
ws.close(1000, `The channel "${ws.channel}" already has a host`);
metrics.connection_terminated_host.inc();
return;
}
}
ws.isAlive = true;
ws.pingStart = new Date().getTime();
ws.counter = 0;
// add channel to list
if (!channels[ws.channel]) {
channels[ws.channel] = [];
}
channels[ws.channel].push(ws);
// start ping pong
ws.ping(noop);
ws.on("pong", heartbeat);
// handle message
ws.on("message", function incoming(data) {
const isPing = data.match(/^\["ping/i);
if (!isPing) {
console.log(new Date(), wss.clients.size, ws.channel, data);
metrics.messages_incoming.inc();
// check rate limit (max 5msg/second)
ws.counter++;
if (ws.counter > (5 * PING_INTERVAL) / 1000) {
console.log(ws.channel, "disconnecting user due to spam");
ws.close(
1000,
"Your app seems to be malfunctioning, please clear your browser cache."
);
metrics.connection_terminated_spam.inc();
return;
}
wss.clients.forEach(function each(client) {
const messageType = data
.toLocaleLowerCase()
.substr(1)
.split(",", 1)
.pop();
// don't log ping messages
if (messageType !== '"ping"') {
console.log(new Date(), wss.clients.size, ws.channel, ws.playerId, data);
}
// handle "direct" messages differently
if (messageType === '"direct"') {
try {
const dataToPlayer = JSON.parse(data)[1];
channels[ws.channel].forEach(function each(client) {
if (
client !== ws &&
client.readyState === WebSocket.OPEN &&
client.channel === ws.channel
dataToPlayer[client.playerId]
) {
client.send(JSON.stringify(dataToPlayer[client.playerId]));
metrics.messages_outgoing.inc();
}
});
} catch (e) {
console.log("error parsing direct message JSON", e);
}
} else {
// all other messages
channels[ws.channel].forEach(function each(client) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
// inject latency between both clients if ping message
if (isPing && client.latency && ws.latency) {
if (messageType === '"ping"' && client.latency && ws.latency) {
client.send(data.replace(/latency/, client.latency + ws.latency));
} else {
client.send(data);
}
metrics.messages_outgoing.inc();
}
});
}
});
});
// start ping interval timer
const interval = setInterval(function ping() {
// ping each client
wss.clients.forEach(function each(ws) {
if (ws.isAlive === false) return ws.terminate();
if (ws.isAlive === false) {
metrics.connection_terminated_timeout.inc();
return ws.terminate();
}
ws.isAlive = false;
ws.pingStart = new Date().getTime();
ws.ping(noop);
});
}, 30000);
// clean up empty channels
for (let channel in channels) {
if (
!channels[channel].length ||
!channels[channel].some(
ws =>
ws &&
(ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING)
)
) {
metrics.channels_list.remove([channel]);
delete channels[channel];
}
}
}, PING_INTERVAL);
// handle server shutdown
wss.on("close", function close() {
clearInterval(interval);
});
// prod mode with stats API
if (process.env.NODE_ENV !== "development") {
console.log("server starting");
server.listen(8080);
server.on("request", (req, res) => {
res.setHeader("Content-Type", register.contentType);
register.metrics().then(out => res.end(out));
});
}

View file

@ -3,25 +3,28 @@
id="app"
@keyup="keyup"
tabindex="-1"
v-bind:class="{ screenshot: grimoire.isScreenshot }"
v-bind:style="{
:class="{ night: grimoire.isNight }"
:style="{
backgroundImage: grimoire.background
? `url('${grimoire.background}')`
: ''
}"
>
<div class="backdrop"></div>
<transition name="blur">
<Intro v-if="!players.length"></Intro>
<TownInfo v-if="players.length && !session.nomination"></TownInfo>
<Vote v-if="session.nomination"></Vote>
</transition>
<TownSquare @screenshot="takeScreenshot"></TownSquare>
<TownSquare></TownSquare>
<Menu ref="menu"></Menu>
<EditionModal />
<FabledModal />
<RolesModal />
<ReferenceModal />
<NightOrderModal />
<VoteHistoryModal />
<GameStateModal />
<Gradients />
<span id="version">v{{ version }}</span>
</div>
@ -41,9 +44,13 @@ import Vote from "./components/Vote";
import Gradients from "./components/Gradients";
import NightOrderModal from "./components/modals/NightOrderModal";
import FabledModal from "@/components/modals/FabledModal";
import VoteHistoryModal from "@/components/modals/VoteHistoryModal";
import GameStateModal from "@/components/modals/GameStateModal";
export default {
components: {
GameStateModal,
VoteHistoryModal,
FabledModal,
NightOrderModal,
Vote,
@ -66,9 +73,6 @@ export default {
};
},
methods: {
takeScreenshot(dimensions) {
this.$refs.menu.takeScreenshot(dimensions);
},
keyup({ key, ctrlKey, metaKey }) {
if (ctrlKey || metaKey) return;
switch (key.toLocaleLowerCase()) {
@ -98,6 +102,11 @@ export default {
if (this.session.isSpectator) return;
this.$store.commit("toggleModal", "roles");
break;
case "v":
if (this.session.voteHistory.length) {
this.$store.commit("toggleModal", "voteHistory");
}
break;
case "escape":
this.$store.commit("toggleModal");
}
@ -142,76 +151,7 @@ body {
overflow: hidden;
}
// Large devices (desktops, less than 1200px)
@media screen and (max-width: 1199.98px) {
html,
body {
font-size: 1.1em;
}
.player .night em {
width: 30px;
height: 30px;
}
}
// Medium devices (tablets, less than 992px)
@media screen and (max-width: 991.98px) {
html,
body {
font-size: 1em;
}
#controls svg {
font-size: 20px;
}
.player .night em {
width: 20px;
height: 20px;
}
#townsquare {
padding: 10px;
}
}
// Small devices (landscape phones, less than 768px)
@media screen and (max-width: 767.98px) {
html,
body {
font-size: 0.9em;
}
.player > .name {
top: 0;
}
}
// Old phones
@media screen and (max-width: 575.98px) {
html,
body {
font-size: 0.8em;
}
}
// odd aspect ratio
@media (max-aspect-ratio: 11/7) {
.bluffs,
.fabled {
h3 {
max-width: 14vh;
}
ul {
flex-direction: column;
}
}
}
// Firefox doesn't support screenshot mode yet
@-moz-document url-prefix() {
#controls > span.camera,
.player > .menu .screenshot,
.bluffs > svg.fa-camera {
display: none;
}
}
@import "media";
* {
box-sizing: border-box;
@ -255,6 +195,7 @@ ul {
#version {
position: absolute;
text-align: right;
right: 10px;
bottom: 10px;
font-size: 60%;
@ -318,6 +259,7 @@ ul {
&.disabled {
color: gray;
cursor: default;
opacity: 0.75;
}
&:before,
&:after {
@ -326,5 +268,73 @@ ul {
width: 10px;
height: 10px;
}
&.townsfolk {
background: radial-gradient(
at 0 -15%,
rgba(255, 255, 255, 0.07) 70%,
rgba(255, 255, 255, 0) 71%
)
0 0/80% 90% no-repeat content-box,
linear-gradient(#0031ad, rgba(5, 0, 0, 0.22)) content-box,
linear-gradient(#292929, #001142) border-box;
box-shadow: inset 0 1px 1px #002c9c, 0 0 10px #000;
&:hover:not(.disabled) {
color: #008cf7;
}
}
&.demon {
background: radial-gradient(
at 0 -15%,
rgba(255, 255, 255, 0.07) 70%,
rgba(255, 255, 255, 0) 71%
)
0 0/80% 90% no-repeat content-box,
linear-gradient(#ad0000, rgba(5, 0, 0, 0.22)) content-box,
linear-gradient(#292929, #420000) border-box;
box-shadow: inset 0 1px 1px #9c0000, 0 0 10px #000;
}
}
/* Night phase backdrop */
#app > .backdrop {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
pointer-events: none;
background: black;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 1) 0%,
rgba(1, 22, 46, 1) 50%,
rgba(0, 39, 70, 1) 100%
);
opacity: 0;
transition: opacity 1s ease-in-out;
&:after {
content: " ";
display: block;
width: 100%;
padding-right: 2000px;
height: 100%;
background: url("assets/clouds.png") repeat;
background-size: 2000px auto;
animation: move-background 120s linear infinite;
opacity: 0.3;
}
}
@keyframes move-background {
from {
transform: translate3d(-2000px, 0px, 0px);
}
to {
transform: translate3d(0px, 0px, 0px);
}
}
#app.night > .backdrop {
opacity: 0.5;
}
</style>

BIN
src/assets/clouds.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

View file

@ -2,14 +2,19 @@
<div class="intro">
<img src="static/apple-icon.png" alt="" />
Welcome to the (unofficial)
<b> Virtual Blood on the Clocktower Town Square</b>!<br />
Please add more players through the
<b>Virtual Town Square and Grimoire</b> for Blood on the Clocktower! Please
add more players through the
<span class="button" @click="toggleMenu">
<font-awesome-icon icon="cog" /> Menu
</span>
on the top right or by pressing <b>[A]</b>.<br />
on the top right or by pressing <b>[A]</b>. You can also join a game session
by pressing <b>[J]</b>.<br />
<div class="footer">
This project is free and open source and can be found on
<a href="https://github.com/bra1n/townsquare" target="_blank">GitHub</a>.
It is not affiliated with The Pandemonium Institute. "Blood on the
Clocktower" is a trademark of Steven Medway and The Pandemonium Institute.
</div>
</div>
</template>
@ -33,6 +38,9 @@ export default {
border: 3px solid black;
border-radius: 10px;
z-index: 3;
a {
color: white;
}
img {
position: absolute;
bottom: 100%;
@ -45,5 +53,9 @@ export default {
box-shadow: 0 0 10px black;
border: 3px solid black;
}
.footer {
font-size: 60%;
opacity: 0.75;
}
}
</style>

View file

@ -1,6 +1,5 @@
<template>
<div id="controls">
<Screenshot ref="screenshot"></Screenshot>
<span
class="session"
:class="{
@ -18,15 +17,7 @@
<font-awesome-icon icon="broadcast-tower" />
{{ session.playerCount }}
</span>
<span class="camera">
<font-awesome-icon
icon="camera"
@click="takeScreenshot()"
title="Take a screenshot"
:class="{ success: grimoire.isScreenshotSuccess }"
/>
</span>
<div class="menu" v-bind:class="{ open: grimoire.isMenuOpen }">
<div class="menu" :class="{ open: grimoire.isMenuOpen }">
<font-awesome-icon icon="cog" @click="toggleMenu" />
<ul>
<li class="tabs" :class="tab">
@ -49,6 +40,14 @@
<template v-if="grimoire.isPublic">Show</template>
<em>[G]</em>
</li>
<li @click="toggleNight" v-if="!session.isSpectator">
<template v-if="!grimoire.isNight">Switch to Night</template>
<template v-if="grimoire.isNight">Switch to Day</template>
<em
><font-awesome-icon
:icon="['fas', grimoire.isNight ? 'sun' : 'cloud-moon']"
/></em>
</li>
<li @click="toggleNightOrder" v-if="players.length">
Night order
<em
@ -59,10 +58,6 @@
]"
/></em>
</li>
<li v-if="!session.isSpectator" @click="toggleModal('fabled')">
Add Fabled
<em><font-awesome-icon icon="dragon"/></em>
</li>
<li v-if="players.length">
Zoom
<em>
@ -81,6 +76,13 @@
Background image
<em><font-awesome-icon icon="image"/></em>
</li>
<li @click="toggleMute">
Mute Sounds
<em
><font-awesome-icon
:icon="['fas', grimoire.isMuted ? 'volume-mute' : 'volume-up']"
/></em>
</li>
</template>
<template v-if="tab === 'session'">
@ -104,6 +106,16 @@
Copy player link
<em><font-awesome-icon icon="copy"/></em>
</li>
<li v-if="!session.isSpectator" @click="distributeRoles">
Send Characters
<em><font-awesome-icon icon="theater-masks"/></em>
</li>
<li
v-if="session.voteHistory.length"
@click="toggleModal('voteHistory')"
>
Nomination history<em>[V]</em>
</li>
<li @click="leaveSession" v-if="session.sessionId">
Leave Session
<em>{{ session.sessionId }}</em>
@ -138,6 +150,10 @@
Choose & Assign
<em>[C]</em>
</li>
<li v-if="!session.isSpectator" @click="toggleModal('fabled')">
Add Fabled
<em><font-awesome-icon icon="dragon"/></em>
</li>
<li @click="clearRoles" v-if="players.length">
Remove all
<em><font-awesome-icon icon="trash-alt"/></em>
@ -155,6 +171,10 @@
Night Order Sheet
<em>[N]</em>
</li>
<li @click="toggleModal('gameState')">
Game State JSON
<em><font-awesome-icon icon="file-code"/></em>
</li>
<li>
<a href="https://discord.gg/Gd7ybwWbFk" target="_blank">
Join Discord
@ -183,12 +203,8 @@
<script>
import { mapMutations, mapState } from "vuex";
import Screenshot from "./Screenshot";
export default {
components: {
Screenshot
},
computed: {
...mapState(["grimoire", "session"]),
...mapState("players", ["players"])
@ -199,58 +215,57 @@ export default {
};
},
methods: {
takeScreenshot(dimensions = {}) {
this.$store.commit("updateScreenshot");
this.$refs.screenshot.capture(dimensions);
},
setBackground() {
const background = prompt("Enter custom background URL");
if (background || background === "") {
this.$store.commit("setBackground", background);
}
},
toggleMute() {
this.$store.commit("setIsMuted", !this.grimoire.isMuted);
},
hostSession() {
if (this.session.sessionId) return;
const sessionId = prompt(
"Enter a channel number / name for your session",
Math.round(Math.random() * 10000)
);
if (sessionId) {
this.$store.commit("session/clearVoteHistory");
this.$store.commit("session/setSpectator", false);
this.$store.commit(
"session/setSessionId",
sessionId
.toLocaleLowerCase()
.replace(/[^0-9a-z]/g, "")
.substr(0, 10)
);
this.$store.commit("session/setSessionId", sessionId);
this.copySessionUrl();
}
},
copySessionUrl() {
// check for clipboard permissions
navigator.permissions
.query({ name: "clipboard-write" })
.then(({ state }) => {
if (state === "granted" || state === "prompt") {
const url = window.location.href.split("#")[0];
const link = url + "#play/" + this.session.sessionId;
const link = url + "#" + this.session.sessionId;
navigator.clipboard.writeText(link);
},
distributeRoles() {
if (this.session.isSpectator) return;
const popup =
"Do you want to distribute assigned characters to all SEATED players?";
if (confirm(popup)) {
this.$store.commit("session/distributeRoles", true);
setTimeout(
(() => {
this.$store.commit("session/distributeRoles", false);
}).bind(this),
2000
);
}
});
},
joinSession() {
if (this.session.sessionId) return this.leaveSession();
const sessionId = prompt(
"Enter the channel number / name of the session you want to join"
);
if (sessionId) {
this.$store.commit("session/clearVoteHistory");
this.$store.commit("session/setSpectator", true);
this.$store.commit(
"session/setSessionId",
sessionId
.toLocaleLowerCase()
.replace(/[^0-9a-z]/g, "")
.substr(0, 10)
);
this.$store.commit("toggleGrimoire", false);
this.$store.commit("session/setSessionId", sessionId);
}
},
leaveSession() {
@ -277,20 +292,18 @@ export default {
if (this.session.isSpectator) return;
if (confirm("Are you sure you want to remove all players?")) {
this.$store.commit("players/clear");
this.$store.commit("setBluff");
}
},
clearRoles() {
if (confirm("Are you sure you want to remove all player roles?")) {
this.$store.dispatch("players/clearRoles");
this.$store.commit("setBluff");
}
},
...mapMutations([
"toggleGrimoire",
"toggleMenu",
"toggleNight",
"toggleNightOrder",
"updateScreenshot",
"setZoom",
"toggleModal"
])
@ -320,10 +333,6 @@ export default {
padding-right: 50px;
z-index: 200;
#app.screenshot & {
display: none;
}
svg {
filter: drop-shadow(0 0 5px rgba(0, 0, 0, 1));
&.success {

View file

@ -18,7 +18,7 @@
<div class="life" @click="toggleStatus()"></div>
<div
class="night first"
class="night-order first"
v-if="nightOrder.get(player).first && grimoire.isNightOrder"
>
<em>{{ nightOrder.get(player).first }}.</em>
@ -27,7 +27,7 @@
}}</span>
</div>
<div
class="night other"
class="night-order other"
v-if="nightOrder.get(player).other && grimoire.isNightOrder"
>
<em>{{ nightOrder.get(player).other }}.</em>
@ -44,15 +44,15 @@
<!-- Overlay icons -->
<div class="overlay">
<font-awesome-icon
icon="skull"
icon="hand-paper"
class="vote"
title="Voted YES"
title="Hand UP"
@click="vote()"
/>
<font-awesome-icon
icon="times"
class="vote"
title="Voted NO"
title="Hand DOWN"
@click="vote()"
/>
<font-awesome-icon
@ -86,6 +86,7 @@
icon="chair"
v-if="player.id && session.sessionId"
class="seat"
:class="{ highlight: session.isRolesDistributed }"
/>
<!-- Ghost vote icon -->
@ -100,7 +101,7 @@
<div
class="name"
@click="isMenuOpen = !isMenuOpen"
v-bind:class="{ active: isMenuOpen }"
:class="{ active: isMenuOpen }"
>
{{ player.name }}
</div>
@ -123,21 +124,31 @@
<font-awesome-icon icon="exchange-alt" />
Swap seats
</li>
<li class="screenshot" @click="takeScreenshot">
<font-awesome-icon icon="camera" />
Screenshot
</li>
<li @click="removePlayer">
<font-awesome-icon icon="times-circle" />
Remove
</li>
</template>
<li @click="claimSeat" v-if="session.isSpectator">
<li
@click="updatePlayer('id', '', true)"
v-if="player.id && session.sessionId"
>
<font-awesome-icon icon="chair" />
<template v-if="player.id !== session.playerId">
Empty seat
</li>
</template>
<li
@click="claimSeat"
v-if="session.isSpectator"
:class="{ disabled: player.id && player.id !== session.playerId }"
>
<font-awesome-icon icon="chair" />
<template v-if="!player.id">
Claim seat
</template>
<template v-else> Vacate seat </template>
<template v-else-if="player.id === session.playerId">
Vacate seat
</template>
<template v-else> Seat occupied</template>
</li>
</ul>
</transition>
@ -146,14 +157,14 @@
<template v-if="player.reminders">
<div
class="reminder"
v-bind:key="reminder.role + ' ' + reminder.name"
:key="reminder.role + ' ' + reminder.name"
v-for="reminder in player.reminders"
v-bind:class="[reminder.role]"
:class="[reminder.role]"
@click="removeReminder(reminder)"
>
<span
class="icon"
v-bind:style="{
:style="{
backgroundImage: `url(${reminder.image ||
require('../assets/icons/' + reminder.role + '.png')})`
}"
@ -219,11 +230,6 @@ export default {
handleEmojis: text => text.replace(/:([^: ]+?):/g, "").replace(/ •/g, "\n•")
},
methods: {
takeScreenshot() {
const { width, height, x, y } = this.$refs.player.getBoundingClientRect();
this.$emit("screenshot", { width, height, x, y });
this.isMenuOpen = false;
},
toggleStatus() {
if (this.grimoire.isPublic) {
if (!this.player.isDead) {
@ -244,22 +250,23 @@ export default {
changeName() {
if (this.session.isSpectator) return;
const name = prompt("Player name", this.player.name) || this.player.name;
this.updatePlayer("name", name);
this.isMenuOpen = false;
this.updatePlayer("name", name, true);
},
removeReminder(reminder) {
const reminders = [...this.player.reminders];
reminders.splice(this.player.reminders.indexOf(reminder), 1);
this.updatePlayer("reminders", reminders);
this.isMenuOpen = false;
this.updatePlayer("reminders", reminders, true);
},
updatePlayer(property, value) {
updatePlayer(property, value, closeMenu = false) {
if (this.session.isSpectator && property !== "reminders") return;
this.$store.commit("players/update", {
player: this.player,
property,
value
});
if (closeMenu) {
this.isMenuOpen = false;
}
},
removePlayer() {
this.isMenuOpen = false;
@ -500,7 +507,7 @@ export default {
fill: url(#default);
}
&:hover *,
&.fa-skull * {
&.fa-hand-paper * {
fill: url(#demon);
}
&.fa-times * {
@ -510,14 +517,14 @@ export default {
}
// other player voted yes, but is not locked yet
#townsquare.vote .player.vote-yes .overlay svg.vote.fa-skull {
#townsquare.vote .player.vote-yes .overlay svg.vote.fa-hand-paper {
opacity: 0.5;
transform: scale(1);
}
// you voted yes | a locked vote yes | a locked vote no
#townsquare.vote .player.you.vote-yes .overlay svg.vote.fa-skull,
#townsquare.vote .player.vote-lock.vote-yes .overlay svg.vote.fa-skull,
#townsquare.vote .player.you.vote-yes .overlay svg.vote.fa-hand-paper,
#townsquare.vote .player.vote-lock.vote-yes .overlay svg.vote.fa-hand-paper,
#townsquare.vote .player.vote-lock:not(.vote-yes) .overlay svg.vote.fa-times {
opacity: 1;
transform: scale(1);
@ -598,6 +605,20 @@ li.move:not(.from) .player .overlay svg.move {
filter: drop-shadow(0 0 3px black);
cursor: default;
z-index: 2;
&.highlight {
animation-iteration-count: 1;
animation: redToWhite 1s normal forwards;
}
}
// highlight animation
@keyframes redToWhite {
from {
color: $demon;
}
to {
color: white;
}
}
.player.you .seat {
@ -661,6 +682,14 @@ li.move:not(.from) .player .overlay svg.move {
li:hover {
color: red;
}
li.disabled {
cursor: default;
&:hover {
color: white;
}
}
svg {
margin-right: 2px;
}
@ -676,137 +705,11 @@ li.move:not(.from) .player .overlay svg.move {
}
/**** Night reminders ****/
.player .night {
position: absolute;
width: 100%;
z-index: 2;
cursor: pointer;
opacity: 1;
transition: opacity 200ms;
display: flex;
top: 0;
align-items: center;
pointer-events: none;
&:after {
content: " ";
display: block;
padding-top: 100%;
}
#townsquare.public & {
opacity: 0;
pointer-events: none;
}
&:hover ~ .token .ability {
opacity: 0;
}
span {
display: flex;
position: absolute;
padding: 5px 10px 5px 30px;
width: 350px;
z-index: 25;
font-size: 70%;
background: rgba(0, 0, 0, 0.5);
border-radius: 10px;
border: 3px solid black;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5));
text-align: left;
align-items: center;
opacity: 0;
transition: opacity 200ms ease-in-out;
&:before {
transform: rotate(-90deg);
transform-origin: center top;
left: -98px;
top: 50%;
font-size: 100%;
position: absolute;
font-weight: bold;
text-align: center;
width: 200px;
}
&:after {
content: " ";
border: 10px solid transparent;
width: 0;
height: 0;
position: absolute;
}
}
&.first span {
right: 120%;
background: linear-gradient(
to right,
$townsfolk 0%,
rgba(0, 0, 0, 0.5) 20%
);
&:before {
content: "First Night";
}
&:after {
border-left-color: $townsfolk;
margin-left: 3px;
left: 100%;
}
}
&.other span {
left: 120%;
background: linear-gradient(to right, $demon 0%, rgba(0, 0, 0, 0.5) 20%);
&:before {
content: "Other Nights";
}
&:after {
right: 100%;
margin-right: 3px;
border-right-color: $demon;
}
}
em {
font-style: normal;
position: absolute;
width: 40px;
height: 40px;
border-radius: 50%;
border: 3px solid black;
filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.5));
font-weight: bold;
opacity: 1;
pointer-events: all;
transition: opacity 200ms;
display: flex;
justify-content: center;
align-items: center;
}
&.first em {
left: -10%;
background: linear-gradient(180deg, rgba(0, 0, 0, 1) 0%, $townsfolk 100%);
}
&.other em {
right: -10%;
background: linear-gradient(180deg, rgba(0, 0, 0, 1) 0%, $demon 100%);
}
em:hover + span {
opacity: 1;
}
#app.screenshot & {
display: none;
}
.player .night-order {
z-index: 3;
}
.player.dead .night em {
.player.dead .night-order em {
color: #ddd;
background: linear-gradient(180deg, rgba(0, 0, 0, 1) 0%, gray 100%);
}
@ -816,6 +719,9 @@ li.move:not(.from) .player .overlay svg.move {
background: url("../assets/reminder.png") center center;
background-size: 100%;
width: 50%;
height: 0;
padding-bottom: 50%;
box-sizing: content-box;
display: flex;
align-items: center;
justify-content: center;
@ -825,20 +731,20 @@ li.move:not(.from) .player .overlay svg.move {
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
transition: all 200ms;
cursor: pointer;
&:before {
content: " ";
display: block;
padding-top: 100%;
}
.text {
line-height: 90%;
color: black;
font-size: 45%;
font-size: 50%;
font-weight: bold;
width: 90%;
text-align: center;
margin-top: 50%;
height: 100%;
width: 100%;
position: absolute;
top: 15%;
text-shadow: 0 1px 1px #f6dfbd, 0 -1px 1px #f6dfbd, 1px 0 1px #f6dfbd,
-1px 0 1px #f6dfbd;
}
.icon,
@ -858,6 +764,7 @@ li.move:not(.from) .player .overlay svg.move {
&:after {
background-image: url("../assets/icons/x.png");
opacity: 0;
top: 5%;
}
&.add {
@ -867,7 +774,7 @@ li.move:not(.from) .player .overlay svg.move {
display: none;
}
.icon {
top: auto;
top: 5%;
}
}
@ -879,6 +786,12 @@ li.move:not(.from) .player .overlay svg.move {
font-size: 70%;
word-break: break-word;
margin-top: 0;
display: flex;
align-items: center;
align-content: center;
justify-content: center;
border-radius: 50%;
top: 0;
}
}

View file

@ -1,83 +0,0 @@
<template>
<div id="screenshot">
<video ref="video" autoplay></video>
<canvas ref="canvas"></canvas>
</div>
</template>
<script>
export default {
data: function() {
return {
stream: null
};
},
methods: {
async capture({ x = 0, y = 0, width = 0, height = 0 }) {
const canvas = this.$refs.canvas;
const video = this.$refs.video;
// start capturing
if (!this.stream || !this.stream.active) {
alert(
"Please select to stream the current browser tab to get the appropriate screenshots"
);
try {
this.stream = await navigator.mediaDevices.getDisplayMedia({
video: {
// frameRate: 5,
cursor: "never"
},
audio: false
});
} catch (err) {
this.$store.commit("updateScreenshot", false);
}
}
// get screenshot
if (this.stream && this.stream.active) {
video.srcObject = this.stream;
video.play();
setTimeout(() => {
const context = canvas.getContext("2d");
canvas.setAttribute("width", width || video.videoWidth);
canvas.setAttribute("height", height || video.videoHeight);
context.drawImage(
video,
x || 0,
y || 0,
width || video.videoWidth,
height || video.videoHeight,
0,
0,
width || video.videoWidth,
height || video.videoHeight
);
canvas.toBlob(blob => {
try {
// eslint-disable-next-line no-undef
const item = new ClipboardItem({ "image/png": blob });
navigator.clipboard.write([item]);
this.$store.commit("updateScreenshot", true);
} catch (err) {
this.$store.commit("updateScreenshot", false);
}
});
}, 100);
}
}
}
};
</script>
<style scoped>
video {
width: 100%;
height: 100%;
display: none;
}
canvas {
width: 100%;
height: 100%;
display: none;
}
</style>

View file

@ -3,7 +3,7 @@
<span
class="icon"
v-if="role.id"
v-bind:style="{
:style="{
backgroundImage: `url(${role.image ||
require('../assets/icons/' + role.id + '.png')})`
}"
@ -18,7 +18,7 @@
></span>
<span
v-if="role.reminders && role.reminders.length"
v-bind:class="['leaf-top' + role.reminders.length]"
:class="['leaf-top' + role.reminders.length]"
></span>
<span class="leaf-orange" v-if="role.setup"></span>
<svg viewBox="0 0 150 150" class="name">
@ -32,17 +32,14 @@
x="66.6%"
text-anchor="middle"
class="label mozilla"
v-bind:font-size="role.name | nameToFontSize"
:font-size="role.name | nameToFontSize"
>
<textPath xlink:href="#curve">
{{ role.name }}
</textPath>
</text>
</svg>
<div
class="edition"
v-bind:class="[`edition-${role.edition}`, role.team]"
></div>
<div class="edition" :class="[`edition-${role.edition}`, role.team]"></div>
<div class="ability" v-if="role.ability">
{{ role.ability }}
</div>
@ -218,10 +215,6 @@ export default {
margin-right: 2px;
right: 100%;
}
#app.screenshot & {
display: none;
}
}
&:hover .ability {

View file

@ -1,39 +1,69 @@
<template>
<ul class="info">
<li class="edition" v-bind:class="['edition-' + edition]"></li>
<li
class="edition"
:class="['edition-' + edition.id]"
:style="{
backgroundImage: `url(${edition.logo ||
require('../assets/editions/' + edition.id + '.png')})`
}"
></li>
<li v-if="players.length - teams.traveler < 5">
Please add more players!
</li>
<li>
<span class="meta" v-if="!edition.isOfficial">
{{ edition.name }}
{{ edition.author ? "by " + edition.author : "" }}
</span>
<span>
{{ players.length }} <font-awesome-icon class="players" icon="users" />
{{ teams.alive }} <font-awesome-icon class="alive" icon="heartbeat" />
</span>
<span>
{{ teams.alive }}
<font-awesome-icon class="alive" icon="heartbeat" />
</span>
<span>
{{ teams.votes }} <font-awesome-icon class="votes" icon="vote-yea" />
</span>
</li>
<li v-if="players.length - teams.traveler >= 5">
<span>
{{ teams.townsfolk }}
<font-awesome-icon class="townsfolk" icon="user-friends" />
</span>
<span>
{{ teams.outsider }}
<font-awesome-icon
class="outsider"
v-bind:icon="teams.outsider > 1 ? 'user-friends' : 'user'"
:icon="teams.outsider > 1 ? 'user-friends' : 'user'"
/>
</span>
<span>
{{ teams.minion }}
<font-awesome-icon
class="minion"
v-bind:icon="teams.minion > 1 ? 'user-friends' : 'user'"
:icon="teams.minion > 1 ? 'user-friends' : 'user'"
/>
</span>
<span>
{{ teams.demon }}
<font-awesome-icon
class="demon"
v-bind:icon="teams.demon > 1 ? 'user-friends' : 'user'"
:icon="teams.demon > 1 ? 'user-friends' : 'user'"
/>
<template v-if="teams.traveler">
</span>
<span v-if="teams.traveler">
{{ teams.traveler }}
<font-awesome-icon
class="traveler"
v-bind:icon="teams.traveler > 1 ? 'user-friends' : 'user'"
:icon="teams.traveler > 1 ? 'user-friends' : 'user'"
/>
</template>
</span>
<span v-if="grimoire.isNight">
Night phase
<font-awesome-icon :icon="['fas', 'cloud-moon']" />
</span>
</li>
</ul>
</template>
@ -59,7 +89,7 @@ export default {
).length
};
},
...mapState(["edition"]),
...mapState(["edition", "grimoire"]),
...mapState("players", ["players"])
}
};
@ -68,27 +98,6 @@ export default {
<style lang="scss" scoped>
@import "../vars.scss";
// Editions
@each $img, $skipIcons in $editions {
.edition-#{$img} {
background-image: url("../assets/editions/#{$img}.png");
}
@if $skipIcons != true {
.edition-#{$img}.townsfolk {
background-image: url("../assets/editions/#{$img}-townsfolk.png");
}
.edition-#{$img}.outsider {
background-image: url("../assets/editions/#{$img}-outsider.png");
}
.edition-#{$img}.minion {
background-image: url("../assets/editions/#{$img}-minion.png");
}
.edition-#{$img}.demon {
background-image: url("../assets/editions/#{$img}-demon.png");
}
}
}
.info {
position: absolute;
display: flex;
@ -103,12 +112,25 @@ export default {
background-size: auto 100%;
li {
display: block;
font-weight: bold;
text-align: center;
padding: 0 5px;
width: 100%;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7));
display: flex;
flex-wrap: wrap;
justify-content: center;
text-shadow: 0 2px 1px black, 0 -2px 1px black, 2px 0 1px black,
-2px 0 1px black;
span {
white-space: nowrap;
}
.meta {
text-align: center;
flex-basis: 100%;
font-family: PiratesBay, sans-serif;
font-weight: normal;
}
svg {
margin-right: 10px;

View file

@ -2,20 +2,19 @@
<div
id="townsquare"
class="square"
v-bind:class="{
:class="{
public: grimoire.isPublic,
spectator: session.isSpectator,
vote: session.nomination
}"
>
<ul class="circle" v-bind:class="['size-' + players.length]">
<ul class="circle" :class="['size-' + players.length]">
<Player
v-for="(player, index) in players"
:key="index"
:player="player"
@screenshot="$emit('screenshot', $event)"
@trigger="handleTrigger(index, $event)"
v-bind:class="{
:class="{
from: Math.max(swap, move, nominate) === index,
swap: swap > -1,
move: move > -1,
@ -31,7 +30,6 @@
:class="{ closed: !isBluffsOpen }"
>
<h3>
<font-awesome-icon icon="camera" @click.stop="takeScreenshot" />
<span v-if="session.isSpectator">Other characters</span>
<span v-else>Demon bluffs</span>
<font-awesome-icon icon="times-circle" @click.stop="toggleBluffs" />
@ -39,20 +37,16 @@
</h3>
<ul>
<li
v-for="index in bluffs"
v-for="index in bluffSize"
:key="index"
@click="openRoleModal(index * -1)"
>
<Token :role="grimoire.bluffs[index - 1]"></Token>
<Token :role="bluffs[index - 1]"></Token>
</li>
</ul>
</div>
<div
class="fabled"
:class="{ closed: !isFabledOpen }"
v-if="grimoire.fabled.length"
>
<div class="fabled" :class="{ closed: !isFabledOpen }" v-if="fabled.length">
<h3>
<span>Fabled</span>
<font-awesome-icon icon="times-circle" @click.stop="toggleFabled" />
@ -60,11 +54,29 @@
</h3>
<ul>
<li
v-for="(fabled, index) in grimoire.fabled"
v-for="(role, index) in fabled"
:key="index"
@click="removeFabled(index)"
>
<Token :role="fabled"></Token>
<div
class="night-order first"
v-if="nightOrder.get(role).first && grimoire.isNightOrder"
>
<em>{{ nightOrder.get(role).first }}.</em>
<span v-if="role.firstNightReminder">{{
role.firstNightReminder
}}</span>
</div>
<div
class="night-order other"
v-if="nightOrder.get(role).other && grimoire.isNightOrder"
>
<em>{{ nightOrder.get(role).other }}.</em>
<span v-if="role.otherNightReminder">{{
role.otherNightReminder
}}</span>
</div>
<Token :role="role"></Token>
</li>
</ul>
</div>
@ -75,7 +87,7 @@
</template>
<script>
import { mapState } from "vuex";
import { mapGetters, mapState } from "vuex";
import Player from "./Player";
import Token from "./Token";
import ReminderModal from "./modals/ReminderModal";
@ -89,13 +101,14 @@ export default {
ReminderModal
},
computed: {
...mapGetters({ nightOrder: "players/nightOrder" }),
...mapState(["grimoire", "roles", "session"]),
...mapState("players", ["players"])
...mapState("players", ["players", "bluffs", "fabled"])
},
data() {
return {
selectedPlayer: 0,
bluffs: 3,
bluffSize: 3,
swap: -1,
move: -1,
nominate: -1,
@ -104,10 +117,6 @@ export default {
};
},
methods: {
takeScreenshot() {
const { width, height, x, y } = this.$refs.bluffs.getBoundingClientRect();
this.$emit("screenshot", { width, height, x, y });
},
toggleBluffs() {
this.isBluffsOpen = !this.isBluffsOpen;
},
@ -116,7 +125,7 @@ export default {
},
removeFabled(index) {
if (this.session.isSpectator) return;
this.$store.commit("setFabled", { index });
this.$store.commit("players/setFabled", { index });
},
handleTrigger(playerIndex, [method, params]) {
if (typeof this[method] === "function") {
@ -198,6 +207,8 @@ export default {
</script>
<style lang="scss">
@import "../vars.scss";
#townsquare {
width: 100%;
height: 100%;
@ -270,9 +281,7 @@ export default {
.fold-leave-to {
transform: perspective(200px) rotateY(-90deg);
}
} @else {
// second half of players
z-index: $i - 1;
// show ability tooltip on the left
.ability {
right: 120%;
left: auto;
@ -283,7 +292,11 @@ export default {
left: 100%;
}
}
} @else {
// second half of players
z-index: $i - 1;
}
> * {
transform: rotate($rot * -1deg);
}
@ -292,7 +305,9 @@ export default {
.life,
.token,
.shroud,
.night {
.night-order,
.seat {
animation-delay: ($i - 1) * 50ms;
transition-delay: ($i - 1) * 50ms;
}
@ -320,8 +335,8 @@ export default {
}
/***** Demon bluffs / Fabled *******/
.bluffs,
.fabled {
#townsquare > .bluffs,
#townsquare > .fabled {
position: absolute;
&.bluffs {
bottom: 10px;
@ -352,9 +367,6 @@ export default {
&:hover {
color: red;
}
#app.screenshot & {
display: none;
}
}
h3 {
margin: 5px 1vh 0;
@ -371,9 +383,6 @@ export default {
svg {
cursor: pointer;
flex-grow: 0;
&.fa-camera {
margin-right: 1vh;
}
&.fa-times-circle {
margin-left: 1vh;
}
@ -401,9 +410,6 @@ export default {
}
}
&.closed {
svg.fa-camera {
display: none;
}
svg.fa-times-circle {
display: none;
}
@ -413,6 +419,9 @@ export default {
ul li {
width: 0;
height: 0;
.night-order {
opacity: 0;
}
.token {
border-width: 0;
}
@ -428,6 +437,149 @@ export default {
z-index: 2;
}
/**** Night reminders ****/
.night-order {
position: absolute;
width: 100%;
cursor: pointer;
opacity: 1;
transition: opacity 200ms;
display: flex;
top: 0;
align-items: center;
pointer-events: none;
&:after {
content: " ";
display: block;
padding-top: 100%;
}
#townsquare.public & {
opacity: 0;
pointer-events: none;
}
&:hover ~ .token .ability {
opacity: 0;
}
span {
display: flex;
position: absolute;
padding: 5px 10px 5px 30px;
width: 350px;
z-index: 25;
font-size: 70%;
background: rgba(0, 0, 0, 0.5);
border-radius: 10px;
border: 3px solid black;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5));
text-align: left;
align-items: center;
opacity: 0;
transition: opacity 200ms ease-in-out;
&:before {
transform: rotate(-90deg);
transform-origin: center top;
left: -98px;
top: 50%;
font-size: 100%;
position: absolute;
font-weight: bold;
text-align: center;
width: 200px;
}
&:after {
content: " ";
border: 10px solid transparent;
width: 0;
height: 0;
position: absolute;
}
}
&.first span {
right: 120%;
background: linear-gradient(
to right,
$townsfolk 0%,
rgba(0, 0, 0, 0.5) 20%
);
&:before {
content: "First Night";
}
&:after {
border-left-color: $townsfolk;
margin-left: 3px;
left: 100%;
}
}
&.other span {
left: 120%;
background: linear-gradient(to right, $demon 0%, rgba(0, 0, 0, 0.5) 20%);
&:before {
content: "Other Nights";
}
&:after {
right: 100%;
margin-right: 3px;
border-right-color: $demon;
}
}
em {
font-style: normal;
position: absolute;
width: 40px;
height: 40px;
border-radius: 50%;
border: 3px solid black;
filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.5));
font-weight: bold;
opacity: 1;
pointer-events: all;
transition: opacity 200ms;
display: flex;
justify-content: center;
align-items: center;
z-index: 3;
}
&.first em {
left: -10%;
background: linear-gradient(180deg, rgba(0, 0, 0, 1) 0%, $townsfolk 100%);
}
&.other em {
right: -10%;
background: linear-gradient(180deg, rgba(0, 0, 0, 1) 0%, $demon 100%);
}
em:hover + span {
opacity: 1;
}
// adjustment for fabled
.fabled &.first {
span {
right: auto;
left: 40px;
&:after {
left: auto;
right: 100%;
margin-left: 0;
margin-right: 3px;
border-left-color: transparent;
border-right-color: $townsfolk;
}
}
}
}
#townsquare:not(.spectator) .fabled ul li:hover .token:before {
opacity: 1;
}

View file

@ -5,6 +5,7 @@
<span class="nominator" :style="nominatorStyle"></span>
</div>
<div class="overlay">
<audio src="../assets/sounds/countdown.mp3" preload="auto"></audio>
<em class="blue">{{ nominator.name }}</em> nominated
<em>{{ nominee.name }}</em
>!
@ -21,48 +22,90 @@
<em>majority</em>.
</template>
<div v-if="session.lockedVote > 1">
<div v-if="session.isVoteInProgress || session.lockedVote > 1">
<em class="blue" v-if="voters.length">{{ voters.join(", ") }} </em>
<span v-else>nobody</span>
voted <em>YES</em>
had their hand <em>UP</em>
</div>
<template v-if="!session.isSpectator">
<div v-if="!session.lockedVote">
Vote time per player:
<div v-if="!session.isVoteInProgress && session.lockedVote < 1">
Time per player:
<font-awesome-icon
@mousedown.prevent="setVotingSpeed(-1)"
@mousedown.prevent="setVotingSpeed(-500)"
icon="minus-circle"
/>
{{ session.votingSpeed }}s
{{ session.votingSpeed / 1000 }}s
<font-awesome-icon
@mousedown.prevent="setVotingSpeed(1)"
@mousedown.prevent="setVotingSpeed(500)"
icon="plus-circle"
/>
</div>
<div class="button-group">
<div class="button" v-if="!session.lockedVote" @click="start">
Start Vote
<div
class="button townsfolk"
v-if="!session.isVoteInProgress"
@click="countdown"
>
Countdown
</div>
<div class="button" v-else @click="stop">
Reset Vote
<div class="button" v-if="!session.isVoteInProgress" @click="start">
{{ session.lockedVote ? "Restart" : "Start" }}
</div>
<div class="button" @click="finish">Finish</div>
<template v-else>
<div
class="button townsfolk"
:class="{ disabled: !session.lockedVote }"
@click="pause"
>
{{ voteTimer ? "Pause" : "Resume" }}
</div>
<div class="button" @click="stop">Reset</div>
</template>
<div class="button demon" @click="finish">Close</div>
</div>
</template>
<template v-else-if="canVote">
<div v-if="!session.lockedVote">
{{ session.votingSpeed }} seconds between votes
<div v-if="!session.isVoteInProgress">
{{ session.votingSpeed / 1000 }} seconds between votes
</div>
<div class="button-group">
<div class="button vote-no" @click="vote(false)">Vote NO</div>
<div class="button vote-yes" @click="vote(true)">Vote YES</div>
<div
class="button townsfolk"
@click="vote(false)"
:class="{ disabled: !currentVote }"
>
Hand DOWN
</div>
<div
class="button demon"
@click="vote(true)"
:class="{ disabled: currentVote }"
>
Hand UP
</div>
</div>
</template>
<div v-else-if="!player">
Please claim a seat to vote.
</div>
</div>
<transition name="blur">
<div
class="countdown"
v-if="session.isVoteInProgress && !session.lockedVote"
>
<span>3</span>
<span>2</span>
<span>1</span>
<span>GO</span>
<audio
:autoplay="!grimoire.isMuted"
src="../assets/sounds/countdown.mp3"
:muted="grimoire.isMuted"
></audio>
</div>
</transition>
</div>
</template>
@ -72,7 +115,7 @@ import { mapGetters, mapState } from "vuex";
export default {
computed: {
...mapState("players", ["players"]),
...mapState(["session"]),
...mapState(["session", "grimoire"]),
...mapGetters({ alive: "players/alive" }),
nominator: function() {
return this.players[this.session.nomination[0]];
@ -95,12 +138,16 @@ export default {
const rotation = (360 * (nomination + Math.min(lock, players))) / players;
return {
transform: `rotate(${Math.round(rotation)}deg)`,
transitionDuration: this.session.votingSpeed - 0.1 + "s"
transitionDuration: this.session.votingSpeed - 100 + "ms"
};
},
player: function() {
return this.players.find(p => p.id === this.session.playerId);
},
currentVote: function() {
const index = this.players.findIndex(p => p.id === this.session.playerId);
return index >= 0 ? !!this.session.votes[index] : undefined;
},
canVote: function() {
if (!this.player) return false;
if (this.player.isVoteless && this.nominee.role.team !== "traveler")
@ -114,33 +161,66 @@ export default {
},
voters: function() {
const nomination = this.session.nomination[1];
const voters = this.session.votes.map((vote, index) =>
vote ? this.players[index].name : ""
const voters = Array(this.players.length)
.fill("")
.map((x, index) =>
this.session.votes[index] ? this.players[index].name : ""
);
const reorder = [
...voters.slice(nomination + 1, this.players.length),
...voters.slice(nomination + 1),
...voters.slice(0, nomination + 1)
];
return reorder.slice(0, this.session.lockedVote - 1).filter(n => !!n);
}
},
data() {
return {
voteTimer: null
};
},
methods: {
countdown() {
this.$store.commit("session/lockVote", 0);
this.$store.commit("session/setVoteInProgress", true);
this.voteTimer = setInterval(() => {
this.start();
}, 4000);
},
start() {
this.$store.commit("session/lockVote");
this.$store.commit("session/lockVote", 1);
this.$store.commit("session/setVoteInProgress", true);
clearInterval(this.voteTimer);
this.voteTimer = setInterval(() => {
this.$store.commit("session/lockVote");
if (this.session.lockedVote > this.players.length) {
clearInterval(this.voteTimer);
this.$store.commit("session/setVoteInProgress", false);
}
}, this.session.votingSpeed);
},
pause() {
if (this.voteTimer) {
clearInterval(this.voteTimer);
this.voteTimer = null;
} else {
this.voteTimer = setInterval(() => {
this.$store.commit("session/lockVote");
if (this.session.lockedVote > this.players.length) {
clearInterval(this.voteTimer);
this.$store.commit("session/setVoteInProgress", false);
}
}, this.session.votingSpeed);
}
}, this.session.votingSpeed * 1000);
},
stop() {
clearInterval(this.voteTimer);
this.voteTimer = null;
this.$store.commit("session/setVoteInProgress", false);
this.$store.commit("session/lockVote", 0);
},
finish() {
clearInterval(this.voteTimer);
this.$store.commit("session/addHistory", this.players);
this.$store.commit("session/nomination");
},
vote(vote) {
@ -151,7 +231,7 @@ export default {
}
},
setVotingSpeed(diff) {
const speed = this.session.votingSpeed + diff;
const speed = Math.round(this.session.votingSpeed + diff);
if (speed > 0) {
this.$store.commit("session/setVotingSpeed", speed);
}
@ -257,29 +337,78 @@ export default {
}
}
.button.vote-no {
background: radial-gradient(
at 0 -15%,
rgba(255, 255, 255, 0.07) 70%,
rgba(255, 255, 255, 0) 71%
)
0 0/80% 90% no-repeat content-box,
linear-gradient(#0031ad, rgba(5, 0, 0, 0.22)) content-box,
linear-gradient(#292929, #001142) border-box;
box-shadow: inset 0 1px 1px #002c9c, 0 0 10px #000;
&:hover {
color: #008cf7;
@keyframes countdown {
0% {
transform: scale(1.5);
opacity: 0;
filter: blur(20px);
}
10% {
opacity: 1;
}
50% {
transform: scale(1);
filter: blur(0);
}
90% {
color: $townsfolk;
opacity: 1;
}
100% {
opacity: 0;
}
}
.button.vote-yes {
background: radial-gradient(
at 0 -15%,
rgba(255, 255, 255, 0.07) 70%,
rgba(255, 255, 255, 0) 71%
)
0 0/80% 90% no-repeat content-box,
linear-gradient(#ad0000, rgba(5, 0, 0, 0.22)) content-box,
linear-gradient(#292929, #420000) border-box;
box-shadow: inset 0 1px 1px #9c0000, 0 0 10px #000;
@keyframes countdown-go {
0% {
transform: scale(1.5);
opacity: 0;
filter: blur(20px);
}
10% {
opacity: 1;
}
50% {
transform: scale(1);
filter: blur(0);
}
90% {
color: $demon;
opacity: 1;
}
100% {
opacity: 0;
}
}
.countdown {
display: flex;
position: absolute;
align-items: center;
justify-content: center;
pointer-events: none;
audio {
height: 0;
width: 0;
visibility: hidden;
}
span {
position: absolute;
font-size: 8em;
font-weight: bold;
opacity: 0;
}
span:nth-child(1) {
animation: countdown 1100ms normal forwards;
}
span:nth-child(2) {
animation: countdown 1100ms normal forwards 1000ms;
}
span:nth-child(3) {
animation: countdown 1100ms normal forwards 2000ms;
}
span:nth-child(4) {
animation: countdown-go 1100ms normal forwards 3000ms;
}
}
</style>

View file

@ -1,22 +1,29 @@
<template>
<Modal
class="editions"
v-show="modals.edition"
@close="toggleModal('edition')"
>
<Modal class="editions" v-if="modals.edition" @close="toggleModal('edition')">
<div v-if="!isCustom">
<h3>Select an edition:</h3>
<ul class="editions">
<li
v-for="edition in editions"
class="edition"
v-bind:class="['edition-' + edition.id]"
v-bind:key="edition.id"
@click="setEdition(edition.id)"
:class="['edition-' + edition.id]"
:style="{
backgroundImage: `url(${require('../../assets/editions/' +
edition.id +
'.png')})`
}"
:key="edition.id"
@click="setEdition(edition)"
>
{{ edition.name }}
</li>
<li class="edition edition-custom" @click="isCustom = true">
<li
class="edition edition-custom"
@click="isCustom = true"
:style="{
backgroundImage: `url(${require('../../assets/editions/custom.png')})`
}"
>
Custom Script / Characters
</li>
</ul>
@ -38,11 +45,12 @@
>the documentation</a
>
on how to write a custom character definition file.
<b>Only load custom JSON files from sources that you trust!</b>
<h3>Some popular custom scripts:</h3>
<ul class="scripts">
<li
v-for="(script, index) in scripts"
v-bind:key="index"
:key="index"
@click="handleURL(script[1])"
>
{{ script[0] }}
@ -85,11 +93,11 @@ export default {
scripts: [
[
"Deadly Penance Day",
"https://gist.githubusercontent.com/bra1n/0337cc44c6fd2c44f7589256ed5486d2/raw/4a7a1545004620146f47583cde4b05f77dd9b6d2/penanceday.json"
"https://gist.githubusercontent.com/bra1n/0337cc44c6fd2c44f7589256ed5486d2/raw/16be38fa3c01aaf49827303ac80577bdb52c0b25/penanceday.json"
],
[
"Catfishing 9.0",
"https://gist.githubusercontent.com/bra1n/8a5ec41a7bbf945f6b7dfc1cef72b569/raw/998767f82badc48cbb9c284765ad36330f7e28f6/catfishing.json"
"https://gist.githubusercontent.com/bra1n/8a5ec41a7bbf945f6b7dfc1cef72b569/raw/fed370d55554e0d83e9d56023c230099f41d0660/catfishing.json"
],
[
"On Thin Ice (Teensyville)",
@ -150,12 +158,20 @@ export default {
},
parseRoles(roles) {
if (!roles || !roles.length) return;
const metaIndex = roles.findIndex(({ id }) => id === "_meta");
let meta = {};
if (metaIndex > -1) {
meta = roles.splice(metaIndex, 1).pop();
}
const customRoles = roles.map(role => {
role.id = role.id.toLocaleLowerCase().replace(/[^a-z0-9]/g, "");
return role;
});
this.$store.commit("setCustomRoles", customRoles);
this.$store.commit("setEdition", "custom");
this.$store.commit(
"setEdition",
Object.assign({}, meta, { id: "custom" })
);
// check for fabled and set those too, if present
if (customRoles.some(({ id }) => this.$store.state.fabled.has(id))) {
const fabled = [];
@ -164,7 +180,7 @@ export default {
fabled.push(this.$store.state.fabled.get(id));
}
});
this.$store.commit("setFabled", { fabled });
this.$store.commit("players/setFabled", { fabled });
}
this.isCustom = false;
},
@ -174,29 +190,6 @@ export default {
</script>
<style scoped lang="scss">
@import "../../vars";
// Editions
@each $img, $skipIcons in $editions {
.edition-#{$img} {
background-image: url("../../assets/editions/#{$img}.png");
}
@if $skipIcons != true {
.edition-#{$img}.townsfolk {
background-image: url("../../assets/editions/#{$img}-townsfolk.png");
}
.edition-#{$img}.outsider {
background-image: url("../../assets/editions/#{$img}-outsider.png");
}
.edition-#{$img}.minion {
background-image: url("../../assets/editions/#{$img}-minion.png");
}
.edition-#{$img}.demon {
background-image: url("../../assets/editions/#{$img}-demon.png");
}
}
}
ul.editions .edition {
font-family: PiratesBay, sans-serif;
letter-spacing: 1px;

View file

@ -1,10 +1,10 @@
<template>
<Modal v-show="modals.fabled && fabled.length" @close="toggleModal('fabled')">
<Modal v-if="modals.fabled && fabled.length" @close="toggleModal('fabled')">
<h3>
Choose a fabled character to add to the game
</h3>
<ul class="tokens">
<li v-for="role in fabled" v-bind:key="role.id" @click="setFabled(role)">
<li v-for="role in fabled" :key="role.id" @click="setFabled(role)">
<Token :role="role" />
</li>
</ul>
@ -25,7 +25,7 @@ export default {
this.$store.state.fabled.forEach(role => {
// don't show fabled that are already in play
if (
!this.$store.state.grimoire.fabled.some(fable => fable.id === role.id)
!this.$store.state.players.fabled.some(fable => fable.id === role.id)
) {
fabled.push(role);
}
@ -35,7 +35,7 @@ export default {
},
methods: {
setFabled(role) {
this.$store.commit("setFabled", {
this.$store.commit("players/setFabled", {
fabled: role
});
this.$store.commit("toggleModal", "fabled");

View file

@ -0,0 +1,123 @@
<template>
<Modal
class="game-state"
v-if="modals.gameState"
@close="toggleModal('gameState')"
>
<h3>Current Game State</h3>
<textarea
:value="gamestate"
@input.stop="input = $event.target.value"
@click="$event.target.select()"
@keyup.stop=""
></textarea>
<div class="button-group">
<div class="button townsfolk" @click="copy">
<font-awesome-icon icon="copy" /> Copy JSON
</div>
<div class="button demon" @click="load" v-if="!session.isSpectator">
<font-awesome-icon icon="cog" /> Load State
</div>
</div>
</Modal>
</template>
<script>
import Modal from "./Modal";
import { mapMutations, mapState } from "vuex";
export default {
components: {
Modal
},
computed: {
gamestate: function() {
return JSON.stringify({
bluffs: this.players.bluffs.map(({ id }) => id),
edition: this.edition.isOfficial
? { id: this.edition.id }
: this.edition,
roles: this.edition.isOfficial ? "" : this.$store.getters.customRoles,
fabled: this.players.fabled.map(({ id }) => id),
players: this.players.players.map(player => ({
...player,
role: player.role.id || {}
}))
});
},
...mapState(["modals", "players", "edition", "roles", "session"])
},
data() {
return {
input: ""
};
},
methods: {
copy: function() {
navigator.clipboard.writeText(this.input || this.gamestate);
},
load: function() {
if (this.session.isSpectator) return;
try {
const data = JSON.parse(this.input || this.gamestate);
const { bluffs, edition, roles, fabled, players } = data;
if (roles) {
this.$store.commit("setCustomRoles", roles);
}
if (edition) {
this.$store.commit("setEdition", edition);
}
if (bluffs.length) {
bluffs.forEach((role, index) => {
this.$store.commit("players/setBluff", {
index,
role: this.$store.state.roles.get(role) || {}
});
});
}
if (fabled) {
this.$store.commit("players/setFabled", {
fabled: fabled.map(id => this.$store.state.fabled.get(id))
});
}
if (players) {
this.$store.commit(
"players/set",
players.map(player => ({
...player,
role:
this.$store.state.roles.get(player.role) ||
this.$store.getters.rolesJSONbyId.get(player.role) ||
{}
}))
);
}
this.toggleModal("gameState");
} catch (e) {
alert("Unable to parse JSON: " + e);
}
},
...mapMutations(["toggleModal"])
}
};
</script>
<style lang="scss" scoped>
@import "../../vars.scss";
h3 {
margin: 0 40px;
}
textarea {
background: transparent;
color: white;
white-space: pre-wrap;
word-break: break-all;
border: 1px solid rgba(255, 255, 255, 0.5);
width: 60vw;
height: 30vh;
max-width: 100%;
margin: 5px 0;
}
</style>

View file

@ -48,7 +48,9 @@ export default {
flex-direction: column;
max-width: 60%;
.characters & {
.characters &,
.vote-history &,
.night-reference & {
max-height: 80%;
max-width: 80%;
overflow-y: auto;

View file

@ -1,9 +1,8 @@
<template>
<Modal
class="characters"
v-show="modals.nightOrder"
class="night-reference"
@close="toggleModal('nightOrder')"
v-if="roles.size"
v-if="modals.nightOrder && roles.size"
>
<font-awesome-icon
@click="toggleModal('reference')"
@ -14,7 +13,7 @@
<h3>
Night Order
<font-awesome-icon icon="cloud-moon" />
{{ editionName }}
{{ edition.name || "Custom Script" }}
</h3>
<div class="night">
<ul class="first">
@ -30,7 +29,7 @@
<span
class="icon"
v-if="role.id"
v-bind:style="{
:style="{
backgroundImage: `url(${role.image ||
require('../../assets/icons/' + role.id + '.png')})`
}"
@ -47,7 +46,7 @@
<span
class="icon"
v-if="role.id"
v-bind:style="{
:style="{
backgroundImage: `url(${role.image ||
require('../../assets/icons/' + role.id + '.png')})`
}"
@ -63,7 +62,6 @@
<script>
import Modal from "./Modal";
import editionJSON from "./../../editions.json";
import { mapMutations, mapState } from "vuex";
export default {
@ -76,10 +74,6 @@ export default {
};
},
computed: {
editionName: function() {
const edition = editionJSON.find(({ id }) => id === this.edition);
return edition ? edition.name : "Custom Script";
},
rolesFirstNight: function() {
const rolesFirstNight = [];
// add minion / demon infos to night order sheet
@ -108,6 +102,11 @@ export default {
rolesFirstNight.push(role);
}
});
this.fabled
.filter(({ firstNight }) => firstNight)
.forEach(fabled => {
rolesFirstNight.push(fabled);
});
rolesFirstNight.sort((a, b) => a.firstNight - b.firstNight);
return rolesFirstNight;
},
@ -122,11 +121,16 @@ export default {
rolesOtherNight.push(role);
}
});
this.fabled
.filter(({ otherNight }) => otherNight)
.forEach(fabled => {
rolesOtherNight.push(fabled);
});
rolesOtherNight.sort((a, b) => a.otherNight - b.otherNight);
return rolesOtherNight;
},
...mapState(["roles", "modals", "edition"]),
...mapState("players", ["players"])
...mapState(["roles", "modals", "edition", "grimoire"]),
...mapState("players", ["players", "fabled"])
},
methods: {
...mapMutations(["toggleModal"])
@ -174,6 +178,17 @@ h4 {
}
}
.fabled {
.name,
.player,
h4 {
color: $fabled;
&:before,
&:after {
background-color: $fabled;
}
}
}
.townsfolk {
.name,
.player,

View file

@ -1,9 +1,8 @@
<template>
<Modal
class="characters"
v-show="modals.reference"
@close="toggleModal('reference')"
v-if="roles.size"
v-if="modals.reference && roles.size"
>
<font-awesome-icon
@click="toggleModal('nightOrder')"
@ -14,7 +13,7 @@
<h3>
Character Reference
<font-awesome-icon icon="address-card" />
{{ editionName }}
{{ edition.name || "Custom Script" }}
</h3>
<ul class="legend">
<li>
@ -34,7 +33,7 @@
<span
class="icon"
v-if="role.id"
v-bind:style="{
:style="{
backgroundImage: `url(${role.image ||
require('../../assets/icons/' + role.id + '.png')})`
}"
@ -51,7 +50,6 @@
<script>
import Modal from "./Modal";
import editionJSON from "./../../editions.json";
import { mapMutations, mapState } from "vuex";
export default {
@ -64,10 +62,6 @@ export default {
};
},
computed: {
editionName: function() {
const edition = editionJSON.find(({ id }) => id === this.edition);
return edition ? edition.name : "Custom Script";
},
rolesGrouped: function() {
const rolesGrouped = {};
this.roles.forEach(role => {

View file

@ -1,7 +1,6 @@
<template>
<Modal
v-show="modals.reminder && availableReminders.length"
v-if="players[playerIndex]"
v-if="modals.reminder && availableReminders.length && players[playerIndex]"
@close="toggleModal('reminder')"
>
<h3>Choose a reminder token:</h3>
@ -36,8 +35,9 @@ export default {
computed: {
availableReminders() {
let reminders = [];
const players = this.$store.state.players.players;
const { players, bluffs } = this.$store.state.players;
this.$store.state.roles.forEach(role => {
// add reminders from player roles
if (players.some(p => p.role.id === role.id)) {
reminders = [
...reminders,
@ -48,6 +48,18 @@ export default {
}))
];
}
// add reminders from bluff/other roles
else if (bluffs.some(bluff => bluff.id === role.id)) {
reminders = [
...reminders,
...role.reminders.map(name => ({
role: role.id,
image: role.image,
name
}))
];
}
// add global reminders
if (role.remindersGlobal && role.remindersGlobal.length) {
reminders = [
...reminders,
@ -59,7 +71,8 @@ export default {
];
}
});
this.$store.state.grimoire.fabled.forEach(role => {
// add fabled reminders
this.$store.state.players.fabled.forEach(role => {
reminders = [
...reminders,
...role.reminders.map(name => ({

View file

@ -1,6 +1,6 @@
<template>
<Modal
v-show="modals.role && availableRoles.length"
v-if="modals.role && availableRoles.length"
@close="toggleModal('role')"
>
<h3>
@ -14,8 +14,8 @@
<ul class="tokens">
<li
v-for="role in availableRoles"
v-bind:class="[role.team]"
v-bind:key="role.id"
:class="[role.team]"
:key="role.id"
@click="setRole(role)"
>
<Token :role="role" />
@ -55,8 +55,8 @@ export default {
methods: {
setRole(role) {
if (this.playerIndex < 0) {
// assign to bluff slot
this.$store.commit("setBluff", {
// assign to bluff slot (index < 0)
this.$store.commit("players/setBluff", {
index: this.playerIndex * -1 - 1,
role
});

View file

@ -1,24 +1,19 @@
<template>
<Modal
class="roles"
v-show="modals.roles"
v-if="modals.roles && nonTravelers >= 5"
@close="toggleModal('roles')"
v-if="nonTravelers >= 5"
>
<h3>Select the characters for {{ nonTravelers }} players:</h3>
<ul
class="tokens"
v-for="(teamRoles, team) in roleSelection"
v-bind:key="team"
>
<li class="count" v-bind:class="[team]">
<ul class="tokens" v-for="(teamRoles, team) in roleSelection" :key="team">
<li class="count" :class="[team]">
{{ teamRoles.filter(role => role.selected).length }} /
{{ game[nonTravelers - 5][team] }}
</li>
<li
v-for="role in teamRoles"
v-bind:class="[role.team, role.selected ? 'selected' : '']"
v-bind:key="role.id"
:class="[role.team, role.selected ? 'selected' : '']"
:key="role.id"
@click="role.selected = !role.selected"
>
<Token :role="role" />
@ -32,7 +27,7 @@
<div
class="button"
@click="assignRoles"
v-bind:class="{
:class="{
disabled: selectedRoles > nonTravelers || !selectedRoles
}"
>

View file

@ -0,0 +1,102 @@
<template>
<Modal
class="vote-history"
v-show="modals.voteHistory && session.voteHistory"
@close="toggleModal('voteHistory')"
>
<font-awesome-icon
@click="clearVoteHistory"
icon="trash-alt"
class="clear"
title="Clear history"
/>
<h3>Nomination history</h3>
<table>
<thead>
<tr>
<td>Nominator</td>
<td>Nominee</td>
<td>Type</td>
<td>Majority</td>
<td><font-awesome-icon icon="hand-paper" /> Hand up</td>
</tr>
</thead>
<tbody>
<tr v-for="(vote, index) in session.voteHistory" :key="index">
<td>{{ vote.nominator }}</td>
<td>{{ vote.nominee }}</td>
<td>{{ vote.type }}</td>
<td>{{ vote.majority }}</td>
<td>
{{ vote.votes.length }}
<font-awesome-icon icon="user-friends" />
{{ vote.votes.join(", ") }}
</td>
</tr>
</tbody>
</table>
</Modal>
</template>
<script>
import Modal from "./Modal";
import { mapMutations, mapState } from "vuex";
export default {
components: {
Modal
},
computed: {
...mapState(["session", "modals"])
},
methods: {
...mapMutations(["toggleModal"]),
...mapMutations("session", ["clearVoteHistory"])
}
};
</script>
<style lang="scss" scoped>
@import "../../vars.scss";
.clear {
position: absolute;
left: 20px;
top: 20px;
cursor: pointer;
&:hover {
color: red;
}
}
h3 {
margin: 0 40px;
svg {
vertical-align: middle;
}
}
table {
border-spacing: 10px 0;
}
thead td {
font-weight: bold;
border-bottom: 1px solid white;
text-align: center;
padding: 0 3px;
}
tbody {
td:nth-child(1) {
color: $townsfolk;
}
td:nth-child(2) {
color: $demon;
}
td:nth-child(4) {
text-align: center;
}
}
</style>

View file

@ -2,33 +2,37 @@
{
"id": "tb",
"name": "Trouble Brewing",
"hasTravelers": true,
"author": "The Pandemonium Institute",
"description": "Clouds roll in over Ravenswood Bluff, engulfing this sleepy town and its superstitious inhabitants in foreboding shadow. Freshly-washed clothes dance eerily on lines strung between cottages. Chimneys cough plumes of smoke into the air. Exotic scents waft through cracks in windows and under doors, as hidden cauldrons lay bubbling. An unusually warm Autumn breeze wraps around vine-covered walls and whispers ominously to those brave enough to walk the cobbled streets.\n\nAnxious mothers call their children home from play, as thunder begins to clap on the horizon. If you listen more closely, however, noises stranger still can be heard echoing from the neighbouring forest. Under the watchful eye of a looming monastery, silhouetted figures skip from doorway to doorway. Those who can read the signs know there is... Trouble Brewing.",
"level": "Beginner",
"roles": []
"roles": [],
"isOfficial": true
},
{
"id": "bmr",
"name": "Bad Moon Rising",
"hasTravelers": true,
"author": "The Pandemonium Institute",
"description": "The sun is swallowed by a jagged horizon as another winter's day surrenders to the night. Flecks of orange and red decay into deeper browns, the forest transforming in silent anticipation of the coming snow.\n\nRavenous wolves howl from the bowels of a rocky crevasse beyond the town borders, sending birds scattering from their cozy rooks. Travelers hurry into the inn, seeking shelter from the gathering chill. They warm themselves with hot tea, sweet strains of music and hearty ale, unaware that strange and nefarious eyes stalk them from the ruins of this once great city.\n\nTonight, even the livestock know there is a... Bad Moon Rising.",
"level": "Intermediate",
"roles": []
"roles": [],
"isOfficial": true
},
{
"id": "snv",
"name": "Sects & Violets",
"hasTravelers": true,
"author": "The Pandemonium Institute",
"description": "Vibrant spring gives way to a warm and inviting summer. Flowers of every description blossom as far as the eye can see, tenderly nurtured in public gardens and window boxes overlooking the lavish promenade. Birds sing, artists paint and philosophers ponder life's greatest mysteries inside a bustling tavern as a circus pitches its endearingly ragged tent on the edge of town.\n\nAs the townsfolk bask in frivolity and mischief, indulging themselves in fine entertainment and even finer wine, dark and clandestine forces are assembling. Witches and cults lurk in majestic ruins on the fringes of the community, hosting secret meetings in underground caves and malevolently plotting the downfall of Ravenswood Bluff and its revelers.\n\nThe time is ripe for... Sects & Violets.",
"level": "Intermediate",
"roles": []
"roles": [],
"isOfficial": true
},
{
"id": "luf",
"name": "Laissez un Faire",
"hasTravelers": false,
"author": "The Pandemonium Institute",
"description": "",
"level": "Veteran",
"roles": ["balloonist", "savant", "amnesiac", "fisherman", "artist", "cannibal", "mutant", "lunatic", "widow", "goblin", "leviathan"]
"roles": ["balloonist", "savant", "amnesiac", "fisherman", "artist", "cannibal", "mutant", "lunatic", "widow", "goblin", "leviathan"],
"isOfficial": true
}
]

View file

@ -62,6 +62,7 @@
{
"id": "toymaker",
"firstNightReminder": "",
"otherNight": 1,
"otherNightReminder": "If it is a night when a Demon attack could end the game, and the Demon is marked “Final night: No Attack,” then the Demon does not act tonight. (Do not wake them.)",
"reminders": ["Final Night: No Attack"],
"setup": false,
@ -82,6 +83,7 @@
{
"id": "duchess",
"firstNightReminder": "",
"otherNight": 1,
"otherNightReminder": "Wake each player marked “Visitor” or “False Info” one at a time. Show them the Duchess token, then fingers (1, 2, 3) equaling the number of evil players marked “Visitor” or, if you are waking the player marked “False Info,” show them any number of fingers except the number of evil players marked “Visitor.”",
"reminders": ["Visitor", "False Info"],
"setup": false,
@ -111,6 +113,7 @@
},
{
"id": "djinn",
"firstNight": 1,
"firstNightReminder": "Wake each evil player and show them which jinxed characters are in play, so that they know not to bluff as characters that can not be in play.",
"otherNightReminder": "",
"reminders": [],
@ -121,6 +124,7 @@
},
{
"id": "stormcatcher",
"firstNight": 1,
"firstNightReminder": "Mark a good player as \"Safe\". Wake each evil player and show them the marked player.",
"otherNightReminder": "",
"reminders": ["Safe"],
@ -128,5 +132,15 @@
"name": "Storm Catcher",
"team": "fabled",
"ability": "Name a good character. If in play, they can only die by execution, but evil players learn which player it is."
},
{
"id": "deusexfiasco",
"firstNightReminder": "",
"otherNightReminder": "",
"reminders": ["Whoops"],
"setup": false,
"name": "Deus ex Fiasco",
"team": "fabled",
"ability": "Once per game, the Storyteller will make a \"mistake\", correct it and publicly admit to it."
}
]

View file

@ -10,7 +10,6 @@ const faIcons = [
"AddressCard",
"BookOpen",
"BroadcastTower",
"Camera",
"Chair",
"CheckSquare",
"CloudMoon",
@ -19,7 +18,9 @@ const faIcons = [
"Dice",
"Dragon",
"ExchangeAlt",
"FileCode",
"FileUpload",
"HandPaper",
"HandPointRight",
"Heartbeat",
"Image",
@ -32,8 +33,8 @@ const faIcons = [
"RedoAlt",
"SearchMinus",
"SearchPlus",
"Skull",
"Square",
"Sun",
"TheaterMasks",
"Times",
"TimesCircle",
@ -43,6 +44,8 @@ const faIcons = [
"UserEdit",
"UserFriends",
"Users",
"VolumeUp",
"VolumeMute",
"VoteYea"
];
const fabIcons = ["Github", "Discord"];

78
src/media.scss Normal file
View file

@ -0,0 +1,78 @@
/*** Media queries ***/
// Large devices (desktops, less than 1200px)
@media screen and (max-width: 1199.98px) {
html,
body {
font-size: 1.1em;
}
.night-order em {
width: 30px;
height: 30px;
}
.fabled .night-order.first span {
left: 30px;
}
}
// Medium devices (tablets, less than 992px)
@media screen and (max-width: 991.98px) {
html,
body {
font-size: 1em;
}
#controls svg {
font-size: 20px;
}
.night-order em {
width: 20px;
height: 20px;
}
.fabled .night-order.first span {
left: 20px;
}
#townsquare {
padding: 10px;
}
}
// Small devices (landscape phones, less than 768px)
@media screen and (max-width: 767.98px) {
html,
body {
font-size: 0.9em;
}
.player > .name {
top: 0;
}
}
// Old phones
@media screen and (max-width: 575.98px) {
html,
body {
font-size: 0.8em;
}
}
// odd aspect ratio
@media (max-aspect-ratio: 11/7) {
.bluffs,
.fabled {
h3 {
max-width: 14vh;
}
ul {
flex-direction: column;
}
}
}
// touch devices
@media (hover: none) {
.circle li .reminder.add {
opacity: 1;
top: 0;
width: 35%;
padding-bottom: 35%;
}
}

File diff suppressed because it is too large Load diff

View file

@ -10,17 +10,16 @@ import fabledJSON from "../fabled.json";
Vue.use(Vuex);
const editionJSONbyId = new Map(
editionJSON.map(edition => [edition.id, edition])
);
const rolesJSONbyId = new Map(rolesJSON.map(role => [role.id, role]));
const fabled = new Map(fabledJSON.map(role => [role.id, role]));
const getRolesByEdition = (edition = "tb") => {
const selectedEdition =
editionJSON.find(({ id }) => id === edition) || editionJSON[0];
const getRolesByEdition = (edition = editionJSON[0]) => {
return new Map(
rolesJSON
.filter(
r => r.edition === edition || selectedEdition.roles.includes(r.id)
)
.filter(r => r.edition === edition.id || edition.roles.includes(r.id))
.sort((a, b) => b.team.localeCompare(a.team))
.map(role => [role.id, role])
);
@ -50,26 +49,26 @@ export default new Vuex.Store({
},
state: {
grimoire: {
isNight: false,
isNightOrder: true,
isPublic: true,
isMenuOpen: false,
isScreenshot: false,
isScreenshotSuccess: false,
isMuted: false,
zoom: 0,
background: "",
bluffs: [],
fabled: []
background: ""
},
modals: {
edition: false,
fabled: false,
gameState: false,
nightOrder: false,
reference: false,
reminder: false,
role: false,
roles: false
roles: false,
voteHistory: false
},
edition: "tb",
edition: editionJSONbyId.get("tb"),
roles: getRolesByEdition(),
fabled
},
@ -88,7 +87,10 @@ export default new Vuex.Store({
const strippedRole = {};
for (let prop in role) {
const value = role[prop];
if (prop === "image" && value.match(new RegExp("^" + imageBase))) {
if (
prop === "image" &&
value.toLocaleLowerCase().includes(imageBase)
) {
continue;
}
if (prop !== "isCustom" && value !== customRole[prop]) {
@ -99,7 +101,8 @@ export default new Vuex.Store({
}
});
return customRoles;
}
},
rolesJSONbyId: () => rolesJSONbyId
},
mutations: {
toggleMenu({ grimoire }) {
@ -115,6 +118,13 @@ export default new Vuex.Store({
grimoire.isPublic ? "Town Square" : "Grimoire"
}`;
},
toggleNight({ grimoire }, isNight) {
if (isNight === true || isNight === false) {
grimoire.isNight = isNight;
} else {
grimoire.isNight = !grimoire.isNight;
}
},
toggleNightOrder({ grimoire }) {
grimoire.isNightOrder = !grimoire.isNightOrder;
},
@ -124,23 +134,8 @@ export default new Vuex.Store({
setBackground({ grimoire }, background) {
grimoire.background = background;
},
setBluff({ grimoire }, { index, role } = {}) {
if (index !== undefined) {
grimoire.bluffs.splice(index, 1, role);
} else {
grimoire.bluffs = [];
}
},
setFabled({ grimoire }, { index, fabled } = {}) {
if (index !== undefined) {
grimoire.fabled.splice(index, 1);
} else if (fabled) {
if (!Array.isArray(fabled)) {
grimoire.fabled.push(fabled);
} else {
grimoire.fabled = fabled;
}
}
setIsMuted({ grimoire }, isMuted) {
grimoire.isMuted = isMuted;
},
toggleModal({ modals }, name) {
if (name) {
@ -151,15 +146,6 @@ export default new Vuex.Store({
modals[modal] = false;
}
},
updateScreenshot({ grimoire }, status) {
if (status !== true && status !== false) {
grimoire.isScreenshotSuccess = false;
grimoire.isScreenshot = true;
} else {
grimoire.isScreenshotSuccess = status;
grimoire.isScreenshot = false;
}
},
/**
* Store custom roles
* @param state
@ -171,7 +157,9 @@ export default new Vuex.Store({
// map existing roles to base definition or pre-populate custom roles to ensure all properties
.map(
role =>
rolesJSONbyId.get(role.id) || Object.assign({}, customRole, role)
rolesJSONbyId.get(role.id) ||
state.roles.get(role.id) ||
Object.assign({}, customRole, role)
)
// default empty icons to good / evil / traveler
.map(role => {
@ -194,11 +182,13 @@ export default new Vuex.Store({
);
},
setEdition(state, edition) {
if (editionJSONbyId.has(edition.id)) {
state.edition = editionJSONbyId.get(edition.id);
state.roles = getRolesByEdition(state.edition);
} else {
state.edition = edition;
state.modals.edition = false;
if (edition !== "custom") {
state.roles = getRolesByEdition(edition);
}
state.modals.edition = false;
}
},
plugins: [persistence, socket]

View file

@ -8,7 +8,9 @@ const NEWPLAYER = {
};
const state = () => ({
players: []
players: [],
fabled: [],
bluffs: []
});
const getters = {
@ -22,11 +24,18 @@ const getters = {
return Math.min(nonTravelers.length, 15);
},
// calculate a Map of player => night order
nightOrder({ players }) {
nightOrder({ players, fabled }) {
const firstNight = [0];
const otherNight = [0];
players.forEach(({ role }) => {
// if (isDead) return;
if (role.firstNight && !firstNight.includes(role.firstNight)) {
firstNight.push(role.firstNight);
}
if (role.otherNight && !otherNight.includes(role.otherNight)) {
otherNight.push(role.otherNight);
}
});
fabled.forEach(role => {
if (role.firstNight && !firstNight.includes(role.firstNight)) {
firstNight.push(role.firstNight);
}
@ -42,6 +51,11 @@ const getters = {
const other = Math.max(otherNight.indexOf(player.role.otherNight), 0);
nightOrder.set(player, { first, other });
});
fabled.forEach(role => {
const first = Math.max(firstNight.indexOf(role.firstNight), 0);
const other = Math.max(otherNight.indexOf(role.otherNight), 0);
nightOrder.set(role, { first, other });
});
return nightOrder;
}
};
@ -70,14 +84,18 @@ const actions = {
name,
id
}));
commit("setFabled", { fabled: [] });
}
commit("set", players);
commit("setBluff");
}
};
const mutations = {
clear(state) {
state.players = [];
state.bluffs = [];
state.fabled = [];
},
set(state, players = []) {
state.players = players;
@ -107,6 +125,24 @@ const mutations = {
},
move(state, [from, to]) {
state.players.splice(to, 0, state.players.splice(from, 1)[0]);
},
setBluff(state, { index, role } = {}) {
if (index !== undefined) {
state.bluffs.splice(index, 1, role);
} else {
state.bluffs = [];
}
},
setFabled(state, { index, fabled } = {}) {
if (index !== undefined) {
state.fabled.splice(index, 1);
} else if (fabled) {
if (!Array.isArray(fabled)) {
state.fabled.push(fabled);
} else {
state.fabled = fabled;
}
}
}
};

View file

@ -9,7 +9,6 @@ const set = key => (state, val) => {
* @param state session state
* @param index seat of the player in the circle
* @param vote true or false
* @param indexAdjusted seat of the player counted from the nominated player
*/
const handleVote = (state, [index, vote]) => {
if (!state.nomination) return;
@ -28,7 +27,10 @@ const state = () => ({
nomination: false,
votes: [],
lockedVote: 0,
votingSpeed: 3
votingSpeed: 3000,
isVoteInProgress: false,
voteHistory: [],
isRolesDistributed: false
});
const getters = {};
@ -36,19 +38,55 @@ const getters = {};
const actions = {};
const mutations = {
setSessionId: set("sessionId"),
setPlayerId: set("playerId"),
setSpectator: set("isSpectator"),
setReconnecting: set("isReconnecting"),
setPlayerCount: set("playerCount"),
setPing: set("ping"),
setVotingSpeed: set("votingSpeed"),
setVoteInProgress: set("isVoteInProgress"),
claimSeat: set("claimedSeat"),
nomination(state, { nomination, votes, votingSpeed, lockedVote } = {}) {
distributeRoles: set("isRolesDistributed"),
setSessionId(state, sessionId) {
state.sessionId = sessionId
.toLocaleLowerCase()
.replace(/[^0-9a-z]/g, "")
.substr(0, 10);
},
nomination(
state,
{ nomination, votes, votingSpeed, lockedVote, isVoteInProgress } = {}
) {
state.nomination = nomination || false;
state.votes = votes || [];
state.votingSpeed = votingSpeed || state.votingSpeed;
state.lockedVote = lockedVote || 0;
state.isVoteInProgress = isVoteInProgress || false;
},
/**
* Create an entry in the vote history log. Requires current player array because it might change later in the game.
* Only stores votes that were completed.
* @param state
* @param players
*/
addHistory(state, players) {
if (!state.nomination || state.lockedVote <= players.length) return;
const isBanishment = players[state.nomination[1]].role.team === "traveler";
console.log(isBanishment);
state.voteHistory.push({
nominator: players[state.nomination[0]].name,
nominee: players[state.nomination[1]].name,
type: isBanishment ? "Banishment" : "Execution",
majority: Math.ceil(
players.filter(player => !player.isDead || isBanishment).length / 2
),
votes: players
.filter((player, index) => state.votes[index])
.map(({ name }) => name)
});
},
clearVoteHistory(state) {
state.voteHistory = [];
},
/**
* Store a vote with and without syncing it to the live session.

View file

@ -3,29 +3,33 @@ module.exports = store => {
if (localStorage.getItem("background")) {
store.commit("setBackground", localStorage.background);
}
if (localStorage.getItem("muted")) {
store.commit("setIsMuted", true);
}
if (localStorage.getItem("zoom")) {
store.commit("setZoom", parseFloat(localStorage.getItem("zoom")));
}
if (localStorage.isPublic !== undefined) {
store.commit("toggleGrimoire", JSON.parse(localStorage.isPublic));
}
if (localStorage.edition !== undefined) {
// this will initialize state.roles!
store.commit("setEdition", localStorage.edition);
}
if (localStorage.roles !== undefined) {
store.commit("setCustomRoles", JSON.parse(localStorage.roles));
store.commit("setEdition", { id: "custom" });
}
if (localStorage.edition !== undefined) {
// this will initialize state.roles for official editions
store.commit("setEdition", JSON.parse(localStorage.edition));
}
if (localStorage.bluffs !== undefined) {
JSON.parse(localStorage.bluffs).forEach((role, index) => {
store.commit("setBluff", {
store.commit("players/setBluff", {
index,
role: store.state.roles.get(role) || {}
});
});
}
if (localStorage.fabled !== undefined) {
store.commit("setFabled", {
store.commit("players/setFabled", {
fabled: JSON.parse(localStorage.fabled).map(id =>
store.state.fabled.get(id)
)
@ -36,7 +40,10 @@ module.exports = store => {
"players/set",
JSON.parse(localStorage.players).map(player => ({
...player,
role: store.state.roles.get(player.role) || {}
role:
store.state.roles.get(player.role) ||
store.getters.rolesJSONbyId.get(player.role) ||
{}
}))
);
}
@ -66,6 +73,13 @@ module.exports = store => {
localStorage.removeItem("background");
}
break;
case "setIsMuted":
if (payload) {
localStorage.setItem("muted", 1);
} else {
localStorage.removeItem("muted");
}
break;
case "setZoom":
if (payload !== 0) {
localStorage.setItem("zoom", payload);
@ -74,10 +88,8 @@ module.exports = store => {
}
break;
case "setEdition":
if (payload === "custom") {
localStorage.removeItem("edition");
} else {
localStorage.setItem("edition", payload);
localStorage.setItem("edition", JSON.stringify(payload));
if (state.edition.isOfficial) {
localStorage.removeItem("roles");
}
break;
@ -85,19 +97,22 @@ module.exports = store => {
if (!payload.length) {
localStorage.removeItem("roles");
} else {
localStorage.setItem("roles", JSON.stringify(payload));
localStorage.setItem(
"roles",
JSON.stringify(store.getters.customRoles)
);
}
break;
case "setBluff":
case "players/setBluff":
localStorage.setItem(
"bluffs",
JSON.stringify(state.grimoire.bluffs.map(({ id }) => id))
JSON.stringify(state.players.bluffs.map(({ id }) => id))
);
break;
case "setFabled":
case "players/setFabled":
localStorage.setItem(
"fabled",
JSON.stringify(state.grimoire.fabled.map(({ id }) => id))
JSON.stringify(state.players.fabled.map(({ id }) => id))
);
break;
case "players/add":

View file

@ -1,9 +1,7 @@
import rolesJSON from "../roles.json";
class LiveSession {
constructor(store) {
//this._wss = "ws://localhost:8081/";
this._wss = "wss://baumgart.biz:8080/";
this._wss = "wss://live.clocktower.online:8080/";
//this._wss = "wss://localhost:8081/";
this._socket = null;
this._isSpectator = true;
this._gamestate = [];
@ -27,7 +25,10 @@ class LiveSession {
_open(channel) {
this.disconnect();
this._socket = new WebSocket(
this._wss + channel + (this._isSpectator ? "" : "-host")
this._wss +
channel +
"/" +
(this._isSpectator ? this._store.state.session.playerId : "host")
);
this._socket.addEventListener("message", this._handleMessage.bind(this));
this._socket.onopen = this._onOpen.bind(this);
@ -127,6 +128,13 @@ class LiveSession {
break;
case "nomination":
if (!this._isSpectator) return;
if (!params) {
// create vote history record
this._store.commit(
"session/addHistory",
this._store.state.players.players
);
}
this._store.commit("session/nomination", { nomination: params });
break;
case "swap":
@ -137,10 +145,22 @@ class LiveSession {
if (!this._isSpectator) return;
this._store.commit("players/move", params);
break;
case "isNight":
if (!this._isSpectator) return;
this._store.commit("toggleNight", params);
break;
case "votingSpeed":
if (!this._isSpectator) return;
this._store.commit("session/setVotingSpeed", params);
break;
case "clearVoteHistory":
if (!this._isSpectator) return;
this._store.commit("session/clearVoteHistory");
break;
case "isVoteInProgress":
if (!this._isSpectator) return;
this._store.commit("session/setVoteInProgress", params);
break;
case "vote":
this._handleVote(params);
break;
@ -192,8 +212,10 @@ class LiveSession {
/**
* Publish the current gamestate.
* Optional param to reduce traffic. (send only player data)
* @param isLightweight
*/
sendGamestate() {
sendGamestate(isLightweight = false) {
if (this._isSpectator) return;
this._gamestate = this._store.state.players.players.map(player => ({
name: player.name,
@ -204,17 +226,24 @@ class LiveSession {
? { roleId: player.role.id }
: {})
}));
const { session } = this._store.state;
if (isLightweight) {
this._send("gs", { gamestate: this._gamestate, isLightweight });
} else {
const { session, grimoire } = this._store.state;
const { fabled } = this._store.state.players;
this.sendEdition();
this.sendFabled();
this._send("gs", {
gamestate: this._gamestate,
isNight: grimoire.isNight,
nomination: session.nomination,
votingSpeed: session.votingSpeed,
lockedVote: session.lockedVote,
isVoteInProgress: session.isVoteInProgress,
fabled: fabled.map(({ id }) => id),
...(session.nomination ? { votes: session.votes } : {})
});
}
}
/**
* Update the gamestate based on incoming data.
@ -223,13 +252,17 @@ class LiveSession {
*/
_updateGamestate(data) {
if (!this._isSpectator) return;
const { gamestate, nomination, votingSpeed, votes, lockedVote } = data;
this._store.commit("session/nomination", {
const {
gamestate,
isLightweight,
isNight,
nomination,
votes,
votingSpeed,
lockedVote
});
votes,
lockedVote,
isVoteInProgress,
fabled
} = data;
const players = this._store.state.players.players;
// adjust number of players
if (players.length < gamestate.length) {
@ -254,12 +287,16 @@ class LiveSession {
});
// roles are special, because of travelers
if (roleId && player.role.id !== roleId) {
const role = rolesJSON.find(r => r.id === roleId);
const role =
this._store.state.roles.get(roleId) ||
this._store.getters.rolesJSONbyId.get(roleId);
if (role) {
this._store.commit("players/update", {
player,
property: "role",
value: role
});
}
} else if (!roleId && player.role.team === "traveler") {
this._store.commit("players/update", {
player,
@ -268,6 +305,19 @@ class LiveSession {
});
}
});
if (!isLightweight) {
this._store.commit("toggleNight", !!isNight);
this._store.commit("session/nomination", {
nomination,
votes,
votingSpeed,
lockedVote,
isVoteInProgress
});
this._store.commit("players/setFabled", {
fabled: fabled.map(id => this._store.state.fabled.get(id))
});
}
}
/**
@ -277,11 +327,13 @@ class LiveSession {
if (this._isSpectator) return;
const { edition } = this._store.state;
let roles;
if (edition === "custom") {
roles = this._store.getters.customRoles;
if (!edition.isOfficial) {
roles = Array.from(this._store.state.roles.keys());
}
this._send("edition", {
edition,
edition: edition.isOfficial
? { id: edition.id }
: Object.assign({}, edition, { logo: "" }),
...(roles ? { roles } : {})
});
}
@ -296,7 +348,25 @@ class LiveSession {
if (!this._isSpectator) return;
this._store.commit("setEdition", edition);
if (roles) {
this._store.commit("setCustomRoles", roles);
this._store.commit(
"setCustomRoles",
roles.map(id => ({ id }))
);
if (this._store.state.roles.size !== roles.length) {
const missing = [];
roles.forEach(id => {
if (!this._store.state.roles.get(id)) {
missing.push(id);
}
});
alert(
`This session contains custom characters that can't be found. ` +
`Please load them before joining! ` +
`Missing roles: ${missing.join(", ")}`
);
this.disconnect();
this._store.commit("toggleModal", "edition");
}
}
}
@ -305,7 +375,7 @@ class LiveSession {
*/
sendFabled() {
if (this._isSpectator) return;
const { fabled } = this._store.state.grimoire;
const { fabled } = this._store.state.players;
this._send(
"fabled",
fabled.map(({ id }) => id)
@ -319,7 +389,7 @@ class LiveSession {
*/
_updateFabled(fabled) {
if (!this._isSpectator) return;
this._store.commit("setFabled", {
this._store.commit("players/setFabled", {
fabled: fabled.map(id => this._store.state.fabled.get(id))
});
}
@ -353,13 +423,14 @@ class LiveSession {
}
/**
* Update a player based on incoming data.
* Update a player based on incoming data. Player only.
* @param index
* @param property
* @param value
* @private
*/
_updatePlayer({ index, property, value }) {
if (!this._isSpectator) return;
const player = this._store.state.players.players[index];
if (!player) return;
// special case where a player stops being a traveler
@ -372,8 +443,8 @@ class LiveSession {
value: {}
});
} else {
// load traveler role
const role = rolesJSON.find(r => r.id === value);
// load role
const role = this._store.state.roles.get(value);
this._store.commit("players/update", {
player,
property: "role",
@ -452,11 +523,13 @@ class LiveSession {
/**
* Claim a seat, needs to be confirmed by the Storyteller.
* @param seat either -1 or the index of the seat claimed
* Seats already occupied can't be claimed.
* @param seat either -1 to vacate or the index of the seat claimed
*/
claimSeat(seat) {
if (!this._isSpectator) return;
if (this._store.state.players.players.length > seat) {
const players = this._store.state.players.players;
if (players.length > seat && (seat < 0 || !players[seat].id)) {
this._send("claim", [seat, this._store.state.session.playerId]);
}
}
@ -487,7 +560,27 @@ class LiveSession {
this._store.commit("players/update", { player, property, value });
}
// update player session list as if this was a ping
this._handlePing([true, value]);
this._handlePing([true, value, 0]);
}
/**
* Distribute player roles to all seated players in a direct message.
* This will be split server side so that each player only receives their own (sub)message.
*/
distributeRoles() {
if (this._isSpectator) return;
const message = {};
this._store.state.players.players.forEach((player, index) => {
if (player.id && player.role) {
message[player.id] = [
"player",
{ index, property: "role", value: player.role.id }
];
}
});
if (Object.keys(message).length) {
this._send("direct", message);
}
}
/**
@ -507,6 +600,22 @@ class LiveSession {
}
}
/**
* Set the isVoteInProgress status. ST only
*/
setVoteInProgress() {
if (this._isSpectator) return;
this._send("isVoteInProgress", this._store.state.session.isVoteInProgress);
}
/**
* Send the isNight status. ST only
*/
setIsNight() {
if (this._isSpectator) return;
this._send("isNight", this._store.state.grimoire.isNight);
}
/**
* Send the voting speed. ST only
* @param votingSpeed voting speed in seconds, minimum 1
@ -518,6 +627,14 @@ class LiveSession {
}
}
/**
* Clear the vote history for everyone. ST only
*/
clearVoteHistory() {
if (this._isSpectator) return;
this._send("clearVoteHistory");
}
/**
* Send a vote. Player or ST
* @param index Seat of the player
@ -566,12 +683,13 @@ class LiveSession {
}
/**
* Update vote lock and the locked vote, if it differs.
* Update vote lock and the locked vote, if it differs. Player only
* @param lock
* @param vote
* @private
*/
_handleLock([lock, vote]) {
if (!this._isSpectator) return;
this._store.commit("session/lockVote", lock);
if (lock > 1) {
const { lockedVote, nomination } = this._store.state.session;
@ -620,9 +738,17 @@ export default store => {
case "session/claimSeat":
session.claimSeat(payload);
break;
case "session/distributeRoles":
if (payload) {
session.distributeRoles();
}
break;
case "session/nomination":
session.nomination(payload);
break;
case "session/setVoteInProgress":
session.setVoteInProgress(payload);
break;
case "session/voteSync":
session.vote(payload);
break;
@ -632,10 +758,16 @@ export default store => {
case "session/setVotingSpeed":
session.setVotingSpeed(payload);
break;
case "session/clearVoteHistory":
session.clearVoteHistory();
break;
case "toggleNight":
session.setIsNight();
break;
case "setEdition":
session.sendEdition();
break;
case "setFabled":
case "players/setFabled":
session.sendFabled();
break;
case "players/swap":
@ -648,7 +780,7 @@ export default store => {
case "players/clear":
case "players/remove":
case "players/add":
session.sendGamestate();
session.sendGamestate(true);
break;
case "players/update":
session.sendPlayer(payload);
@ -657,9 +789,9 @@ export default store => {
});
// check for session Id in hash
const [command, param] = window.location.hash.substr(1).split("/");
if (command === "play") {
const sessionId = window.location.hash.substr(1);
if (sessionId) {
store.commit("session/setSpectator", true);
store.commit("session/setSessionId", param);
store.commit("session/setSessionId", sessionId);
}
};

View file

@ -1,13 +1,6 @@
$fabled: #ffe91f;
$townsfolk: #1f65ff;
$outsider: #46d5ff;
$minion: #ff6900;
$demon: #ce0100;
$traveler: #cc04ff;
$editions:
'tb',
'bmr',
'snv',
'luf' true,
'custom' true
;

View file

@ -1,3 +1,3 @@
module.exports = {
publicPath: process.env.NODE_ENV === "production" ? "/townsquare/" : "/"
publicPath: process.env.NODE_ENV === "production" ? "/" : "/"
};