Merge pull request #73 from bra1n/v2.0

V2.0
This commit is contained in:
Steffen 2020-12-22 12:11:10 +01:00 committed by GitHub
commit ed03b055f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1531 additions and 534 deletions

38
CHANGELOG.md Normal file
View File

@ -0,0 +1,38 @@
# Release Notes
## Version 2.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
- 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 resons:
- 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

@ -5,7 +5,7 @@
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)
@ -14,12 +14,35 @@ To set up a game as the host, check out this tutorial video:
- 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 +92,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)

2
package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "townsquare",
"version": "1.9.1",
"version": "2.0.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "townsquare",
"version": "1.9.1",
"version": "2.0.1",
"description": "Blood on the Clocktower Town Square",
"author": "Steffen Baumgart",
"scripts": {

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

@ -2,6 +2,8 @@ const fs = require("fs");
const https = require("https");
const WebSocket = require("ws");
const PING_INTERVAL = 30000; // 30 seconds
const server = https.createServer({
cert: fs.readFileSync("cert.pem"),
key: fs.readFileSync("key.pem")
@ -9,67 +11,118 @@ 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?:\/\/(bra1n\.github\.io|localhost|eddbra1nprivatetownsquare\.xyz)/i
/^https?:\/\/([^.]+\.github\.io|localhost|live\.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 = {};
// 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);
// check for another host on this channel
if (
Array.from(wss.clients).some(
client =>
client !== ws &&
client.readyState === WebSocket.OPEN &&
client.channel === ws.channel &&
client.isHost
)
) {
console.log(ws.channel, "duplicate host");
ws.close(1000, `The channel "${ws.channel}" already has a host`);
return;
}
// 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 (
ws.playerId === "host" &&
channels[ws.channel] &&
channels[ws.channel].some(
client =>
client !== ws &&
client.readyState === WebSocket.OPEN &&
client.playerId === "host"
)
) {
console.log(ws.channel, "duplicate host");
ws.close(1000, `The channel "${ws.channel}" already has a host`);
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);
ws.on("message", function incoming(data) {
const isPing = data.match(/^\["ping/i);
if (!isPing) {
console.log(new Date(), wss.clients.size, ws.channel, data);
// remove client from channels on close
ws.on("close", () => {
const index = channels[ws.channel].indexOf(ws);
if (index >= 0) {
channels[ws.channel].splice(index, 1);
}
wss.clients.forEach(function each(client) {
if (
client !== ws &&
client.readyState === WebSocket.OPEN &&
client.channel === ws.channel
) {
// inject latency between both clients if ping message
if (isPing && client.latency && ws.latency) {
client.send(data.replace(/latency/, client.latency + ws.latency));
} else {
client.send(data);
}
if (!channels[ws.channel].length) delete channels[ws.channel];
});
// handle message
ws.on("message", function incoming(data) {
// 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."
);
return;
}
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 &&
dataToPlayer[client.playerId]
) {
client.send(JSON.stringify(dataToPlayer[client.playerId]));
}
});
} 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 (messageType === '"ping"' && client.latency && ws.latency) {
client.send(data.replace(/latency/, client.latency + ws.latency));
} else {
client.send(data);
}
}
});
}
});
});
// start ping interval timer
const interval = setInterval(function ping() {
wss.clients.forEach(function each(ws) {
if (ws.isAlive === false) return ws.terminate();
@ -77,12 +130,24 @@ const interval = setInterval(function ping() {
ws.pingStart = new Date().getTime();
ws.ping(noop);
});
}, 30000);
}, 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.writeHead(200);
res.end(
JSON.stringify({
players: wss.clients.size,
channels: Object.keys(channels).length
})
);
});
}

View File

@ -3,13 +3,17 @@
id="app"
@keyup="keyup"
tabindex="-1"
v-bind:class="{ screenshot: grimoire.isScreenshot }"
v-bind:style="{
:class="{
screenshot: grimoire.isScreenshot,
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>
@ -22,6 +26,8 @@
<RolesModal />
<ReferenceModal />
<NightOrderModal />
<VoteHistoryModal />
<GameStateModal />
<Gradients />
<span id="version">v{{ version }}</span>
</div>
@ -41,9 +47,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,
@ -148,10 +158,13 @@ body {
body {
font-size: 1.1em;
}
.player .night em {
.night-order em {
width: 30px;
height: 30px;
}
.fabled .night-order.first span {
left: 30px;
}
}
// Medium devices (tablets, less than 992px)
@ -163,10 +176,13 @@ body {
#controls svg {
font-size: 20px;
}
.player .night em {
.night-order em {
width: 20px;
height: 20px;
}
.fabled .night-order.first span {
left: 20px;
}
#townsquare {
padding: 10px;
}
@ -255,6 +271,7 @@ ul {
#version {
position: absolute;
text-align: right;
right: 10px;
bottom: 10px;
font-size: 60%;
@ -318,6 +335,7 @@ ul {
&.disabled {
color: gray;
cursor: default;
opacity: 0.75;
}
&:before,
&:after {
@ -326,5 +344,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.

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 />
This project is free and open source and can be found on
<a href="https://github.com/bra1n/townsquare" target="_blank">GitHub</a>.
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

@ -26,7 +26,7 @@
: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 +49,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 +67,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>
@ -104,6 +108,17 @@
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><font-awesome-icon icon="hand-paper"/></em>
</li>
<li @click="leaveSession" v-if="session.sessionId">
Leave Session
<em>{{ session.sessionId }}</em>
@ -138,6 +153,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 +174,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
@ -215,6 +238,7 @@ export default {
Math.round(Math.random() * 10000)
);
if (sessionId) {
this.$store.commit("session/clearVoteHistory");
this.$store.commit("session/setSpectator", false);
this.$store.commit(
"session/setSessionId",
@ -233,17 +257,33 @@ export default {
.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() {
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("toggleGrimoire", false);
this.$store.commit(
"session/setSessionId",
sessionId
@ -277,18 +317,17 @@ 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",

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>
@ -131,13 +132,27 @@
<font-awesome-icon icon="times-circle" />
Remove
</li>
<li
@click="updatePlayer('id', '', true)"
v-if="player.id && session.sessionId"
>
<font-awesome-icon icon="chair" />
Empty seat
</li>
</template>
<li @click="claimSeat" v-if="session.isSpectator">
<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 !== session.playerId">
<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 +161,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')})`
}"
@ -244,22 +259,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 +516,7 @@ export default {
fill: url(#default);
}
&:hover *,
&.fa-skull * {
&.fa-hand-paper * {
fill: url(#demon);
}
&.fa-times * {
@ -510,14 +526,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 +614,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 +691,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 +714,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 +728,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 +740,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 +773,7 @@ li.move:not(.from) .player .overlay svg.move {
&:after {
background-image: url("../assets/icons/x.png");
opacity: 0;
top: 5%;
}
&.add {
@ -867,7 +783,7 @@ li.move:not(.from) .player .overlay svg.move {
display: none;
}
.icon {
top: auto;
top: 5%;
}
}
@ -879,6 +795,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

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

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>
{{ players.length }} <font-awesome-icon class="players" icon="users" />
{{ teams.alive }} <font-awesome-icon class="alive" icon="heartbeat" />
{{ teams.votes }} <font-awesome-icon class="votes" icon="vote-yea" />
<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" />
</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">
{{ teams.townsfolk }}
<font-awesome-icon class="townsfolk" icon="user-friends" />
{{ teams.outsider }}
<font-awesome-icon
class="outsider"
v-bind:icon="teams.outsider > 1 ? 'user-friends' : 'user'"
/>
{{ teams.minion }}
<font-awesome-icon
class="minion"
v-bind:icon="teams.minion > 1 ? 'user-friends' : 'user'"
/>
{{ teams.demon }}
<font-awesome-icon
class="demon"
v-bind:icon="teams.demon > 1 ? 'user-friends' : 'user'"
/>
<template v-if="teams.traveler">
<span>
{{ teams.townsfolk }}
<font-awesome-icon class="townsfolk" icon="user-friends" />
</span>
<span>
{{ teams.outsider }}
<font-awesome-icon
class="outsider"
:icon="teams.outsider > 1 ? 'user-friends' : 'user'"
/>
</span>
<span>
{{ teams.minion }}
<font-awesome-icon
class="minion"
:icon="teams.minion > 1 ? 'user-friends' : 'user'"
/>
</span>
<span>
{{ teams.demon }}
<font-awesome-icon
class="demon"
:icon="teams.demon > 1 ? 'user-friends' : 'user'"
/>
</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,20 @@
<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,
@ -39,20 +39,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 +56,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 +89,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 +103,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,
@ -116,7 +131,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 +213,8 @@ export default {
</script>
<style lang="scss">
@import "../vars.scss";
#townsquare {
width: 100%;
height: 100%;
@ -292,7 +309,9 @@ export default {
.life,
.token,
.shroud,
.night {
.night-order,
.seat {
animation-delay: ($i - 1) * 50ms;
transition-delay: ($i - 1) * 50ms;
}
@ -320,8 +339,8 @@ export default {
}
/***** Demon bluffs / Fabled *******/
.bluffs,
.fabled {
#townsquare > .bluffs,
#townsquare > .fabled {
position: absolute;
&.bluffs {
bottom: 10px;
@ -413,6 +432,9 @@ export default {
ul li {
width: 0;
height: 0;
.night-order {
opacity: 0;
}
.token {
border-width: 0;
}
@ -428,6 +450,153 @@ 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;
}
#app.screenshot & {
display: none;
}
// 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"></audio>
<em class="blue">{{ nominator.name }}</em> nominated
<em>{{ nominee.name }}</em
>!
@ -21,48 +22,86 @@
<em>majority</em>.
</template>
<div v-if="session.lockedVote > 1">
<div v-if="session.isVoteInProgress">
<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">
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 src="../assets/sounds/countdown.mp3"></audio>
</div>
</transition>
</div>
</template>
@ -95,12 +134,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")
@ -124,23 +167,54 @@ export default {
return reorder.slice(0, this.session.lockedVote - 1).filter(n => !!n);
}
},
data() {
return {
voteTimer: null
};
},
methods: {
countdown() {
this.$store.commit("session/setVoteInProgress", true);
this.$store.commit("session/lockVote", 0);
this.voteTimer = setInterval(() => {
this.start();
}, 4000);
},
start() {
this.$store.commit("session/lockVote");
this.$store.commit("session/setVoteInProgress", true);
this.$store.commit("session/lockVote", 1);
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 * 1000);
}, 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);
}
},
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 +225,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 +331,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,127 @@
<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() {
// check for clipboard permissions
navigator.permissions
.query({ name: "clipboard-write" })
.then(({ state }) => {
if (state === "granted" || state === "prompt") {
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.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,7 +48,19 @@ export default {
}))
];
}
if (role.remindersGlobal && role.remindersGlobal.length) {
// 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
else if (role.remindersGlobal && role.remindersGlobal.length) {
reminders = [
...reminders,
...role.remindersGlobal.map(name => ({
@ -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

@ -19,7 +19,9 @@ const faIcons = [
"Dice",
"Dragon",
"ExchangeAlt",
"FileCode",
"FileUpload",
"HandPaper",
"HandPointRight",
"Heartbeat",
"Image",
@ -32,8 +34,8 @@ const faIcons = [
"RedoAlt",
"SearchMinus",
"SearchPlus",
"Skull",
"Square",
"Sun",
"TheaterMasks",
"Times",
"TimesCircle",

View File

@ -1288,7 +1288,10 @@
"otherNight": 47,
"otherNightReminder": "Choose a character type you have not chosen yet. Point to a player whose character is of that type, if there are any. Place the Balloonist's \"Seen\" reminder next to that character.",
"reminders": [
"Seen"
"Seen Townsfolk",
"Seen Outsider",
"Seen Minion",
"Seen Demon"
],
"setup": true,
"name": "Balloonist",

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,27 @@ export default new Vuex.Store({
},
state: {
grimoire: {
isNight: false,
isNightOrder: true,
isPublic: true,
isMenuOpen: false,
isScreenshot: false,
isScreenshotSuccess: 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 +88,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]) {
@ -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,24 +134,6 @@ 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;
}
}
},
toggleModal({ modals }, name) {
if (name) {
modals[name] = !modals[name];
@ -171,7 +163,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 +188,13 @@ export default new Vuex.Store({
);
},
setEdition(state, edition) {
state.edition = edition;
state.modals.edition = false;
if (edition !== "custom") {
state.roles = getRolesByEdition(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;
}
},
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;
}
};
@ -72,12 +86,14 @@ const actions = {
}));
}
commit("set", players);
commit("setBluff");
}
};
const mutations = {
clear(state) {
state.players = [];
state.bluffs = [];
},
set(state, players = []) {
state.players = players;
@ -107,6 +123,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 = {};
@ -43,12 +45,42 @@ const mutations = {
setPlayerCount: set("playerCount"),
setPing: set("ping"),
setVotingSpeed: set("votingSpeed"),
setVoteInProgress: set("isVoteInProgress"),
claimSeat: set("claimedSeat"),
nomination(state, { nomination, votes, votingSpeed, lockedVote } = {}) {
distributeRoles: set("isRolesDistributed"),
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]].team === "traveler";
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

@ -9,23 +9,24 @@ module.exports = store => {
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)
)
@ -74,10 +75,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 +84,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,8 @@
import rolesJSON from "../roles.json";
class LiveSession {
constructor(store) {
this._wss = "wss://live.clocktower.online:8080/";
//this._wss = "wss://baumgart.biz:8080/"; //todo: delete this
//this._wss = "ws://localhost:8081/";
this._wss = "wss://baumgart.biz:8080/";
this._socket = null;
this._isSpectator = true;
this._gamestate = [];
@ -27,7 +26,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 +129,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 +146,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 +213,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,16 +227,23 @@ class LiveSession {
? { roleId: player.role.id }
: {})
}));
const { session } = this._store.state;
this.sendEdition();
this.sendFabled();
this._send("gs", {
gamestate: this._gamestate,
nomination: session.nomination,
votingSpeed: session.votingSpeed,
lockedVote: session.lockedVote,
...(session.nomination ? { votes: session.votes } : {})
});
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._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 } : {})
});
}
}
/**
@ -223,13 +253,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,7 +288,7 @@ 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.commit("players/update", {
player,
property: "role",
@ -268,6 +302,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 +324,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 +345,23 @@ 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(", ")}`
);
}
}
}
@ -305,7 +370,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 +384,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))
});
}
@ -372,8 +437,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 +517,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]);
}
}
@ -490,6 +557,26 @@ class LiveSession {
this._handlePing([true, value]);
}
/**
* 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);
}
}
/**
* A player nomination. ST only
* This also syncs the voting speed to the players.
@ -507,6 +594,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 +621,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
@ -620,9 +731,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 +751,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 +773,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 +782,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
;