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. 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. 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: 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) [![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\]**) - Public Town Square and Storyteller Grimoire (toggle with **shortcut \[G\]**)
- Supports custom script JSON generated by the [Script Tool](https://bloodontheclocktower.com/script) - Supports custom script JSON generated by the [Script Tool](https://bloodontheclocktower.com/script)
- Live Session for Storyteller / Players including live voting! - Live Session for Storyteller / Players including live voting and character distribution!
- Includes all 3 base editions and travelers - Includes all 3 base editions, Travelers and Fabled
- Night sheet and reminder text for each character ability to help storytellers - Night sheet and reminder text for each character ability to help storytellers
- Many other customization options! - 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, 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 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` - **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 - **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) ## [Code of Conduct](CODE_OF_CONDUCT.md)

2
package-lock.json generated
View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "townsquare", "name": "townsquare",
"version": "1.9.1", "version": "2.0.1",
"description": "Blood on the Clocktower Town Square", "description": "Blood on the Clocktower Town Square",
"author": "Steffen Baumgart", "author": "Steffen Baumgart",
"scripts": { "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 https = require("https");
const WebSocket = require("ws"); const WebSocket = require("ws");
const PING_INTERVAL = 30000; // 30 seconds
const server = https.createServer({ const server = https.createServer({
cert: fs.readFileSync("cert.pem"), cert: fs.readFileSync("cert.pem"),
key: fs.readFileSync("key.pem") key: fs.readFileSync("key.pem")
@ -9,67 +11,118 @@ const server = https.createServer({
const wss = new WebSocket.Server({ const wss = new WebSocket.Server({
...(process.env.NODE_ENV === "development" ? { port: 8081 } : { server }), ...(process.env.NODE_ENV === "development" ? { port: 8081 } : { server }),
verifyClient: info => verifyClient: info =>
info.origin &&
!!info.origin.match( !!info.origin.match(
/^https?:\/\/(bra1n\.github\.io|localhost|eddbra1nprivatetownsquare\.xyz)/i /^https?:\/\/([^.]+\.github\.io|localhost|live\.clocktower\.online|eddbra1nprivatetownsquare\.xyz)/i
) )
}); });
function noop() {} function noop() {}
// calculate latency on heartbeat
function heartbeat() { function heartbeat() {
this.latency = Math.round((new Date().getTime() - this.pingStart) / 2); this.latency = Math.round((new Date().getTime() - this.pingStart) / 2);
this.counter = 0;
this.isAlive = true; this.isAlive = true;
} }
// map of channels currently in use
const channels = {};
// a new client connects
wss.on("connection", function connection(ws, req) { wss.on("connection", function connection(ws, req) {
ws.channel = req.url // url pattern: clocktower.online/<channel>/<playerId|host>
.split("/") const url = req.url.toLocaleLowerCase().split("/");
.pop() ws.playerId = url.pop();
.toLocaleLowerCase(); ws.channel = url.pop();
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 // check for another host on this channel
if ( if (
Array.from(wss.clients).some( ws.playerId === "host" &&
channels[ws.channel] &&
channels[ws.channel].some(
client => client =>
client !== ws && client !== ws &&
client.readyState === WebSocket.OPEN && client.readyState === WebSocket.OPEN &&
client.channel === ws.channel && client.playerId === "host"
client.isHost
) )
) { ) {
console.log(ws.channel, "duplicate host"); console.log(ws.channel, "duplicate host");
ws.close(1000, `The channel "${ws.channel}" already has a host`); ws.close(1000, `The channel "${ws.channel}" already has a host`);
return; return;
} }
}
ws.isAlive = true; ws.isAlive = true;
ws.pingStart = new Date().getTime(); 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.ping(noop);
ws.on("pong", heartbeat); ws.on("pong", heartbeat);
ws.on("message", function incoming(data) { // remove client from channels on close
const isPing = data.match(/^\["ping/i); ws.on("close", () => {
if (!isPing) { const index = channels[ws.channel].indexOf(ws);
console.log(new Date(), wss.clients.size, ws.channel, data); if (index >= 0) {
channels[ws.channel].splice(index, 1);
} }
wss.clients.forEach(function each(client) { 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 ( if (
client !== ws && client !== ws &&
client.readyState === WebSocket.OPEN && client.readyState === WebSocket.OPEN &&
client.channel === ws.channel 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 // 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)); client.send(data.replace(/latency/, client.latency + ws.latency));
} else { } else {
client.send(data); client.send(data);
} }
} }
}); });
}
}); });
}); });
// start ping interval timer
const interval = setInterval(function ping() { const interval = setInterval(function ping() {
wss.clients.forEach(function each(ws) { wss.clients.forEach(function each(ws) {
if (ws.isAlive === false) return ws.terminate(); if (ws.isAlive === false) return ws.terminate();
@ -77,12 +130,24 @@ const interval = setInterval(function ping() {
ws.pingStart = new Date().getTime(); ws.pingStart = new Date().getTime();
ws.ping(noop); ws.ping(noop);
}); });
}, 30000); }, PING_INTERVAL);
// handle server shutdown
wss.on("close", function close() { wss.on("close", function close() {
clearInterval(interval); clearInterval(interval);
}); });
// prod mode with stats API
if (process.env.NODE_ENV !== "development") { if (process.env.NODE_ENV !== "development") {
console.log("server starting");
server.listen(8080); 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" id="app"
@keyup="keyup" @keyup="keyup"
tabindex="-1" tabindex="-1"
v-bind:class="{ screenshot: grimoire.isScreenshot }" :class="{
v-bind:style="{ screenshot: grimoire.isScreenshot,
night: grimoire.isNight
}"
:style="{
backgroundImage: grimoire.background backgroundImage: grimoire.background
? `url('${grimoire.background}')` ? `url('${grimoire.background}')`
: '' : ''
}" }"
> >
<div class="backdrop"></div>
<transition name="blur"> <transition name="blur">
<Intro v-if="!players.length"></Intro> <Intro v-if="!players.length"></Intro>
<TownInfo v-if="players.length && !session.nomination"></TownInfo> <TownInfo v-if="players.length && !session.nomination"></TownInfo>
@ -22,6 +26,8 @@
<RolesModal /> <RolesModal />
<ReferenceModal /> <ReferenceModal />
<NightOrderModal /> <NightOrderModal />
<VoteHistoryModal />
<GameStateModal />
<Gradients /> <Gradients />
<span id="version">v{{ version }}</span> <span id="version">v{{ version }}</span>
</div> </div>
@ -41,9 +47,13 @@ import Vote from "./components/Vote";
import Gradients from "./components/Gradients"; import Gradients from "./components/Gradients";
import NightOrderModal from "./components/modals/NightOrderModal"; import NightOrderModal from "./components/modals/NightOrderModal";
import FabledModal from "@/components/modals/FabledModal"; import FabledModal from "@/components/modals/FabledModal";
import VoteHistoryModal from "@/components/modals/VoteHistoryModal";
import GameStateModal from "@/components/modals/GameStateModal";
export default { export default {
components: { components: {
GameStateModal,
VoteHistoryModal,
FabledModal, FabledModal,
NightOrderModal, NightOrderModal,
Vote, Vote,
@ -148,10 +158,13 @@ body {
body { body {
font-size: 1.1em; font-size: 1.1em;
} }
.player .night em { .night-order em {
width: 30px; width: 30px;
height: 30px; height: 30px;
} }
.fabled .night-order.first span {
left: 30px;
}
} }
// Medium devices (tablets, less than 992px) // Medium devices (tablets, less than 992px)
@ -163,10 +176,13 @@ body {
#controls svg { #controls svg {
font-size: 20px; font-size: 20px;
} }
.player .night em { .night-order em {
width: 20px; width: 20px;
height: 20px; height: 20px;
} }
.fabled .night-order.first span {
left: 20px;
}
#townsquare { #townsquare {
padding: 10px; padding: 10px;
} }
@ -255,6 +271,7 @@ ul {
#version { #version {
position: absolute; position: absolute;
text-align: right;
right: 10px; right: 10px;
bottom: 10px; bottom: 10px;
font-size: 60%; font-size: 60%;
@ -318,6 +335,7 @@ ul {
&.disabled { &.disabled {
color: gray; color: gray;
cursor: default; cursor: default;
opacity: 0.75;
} }
&:before, &:before,
&:after { &:after {
@ -326,5 +344,73 @@ ul {
width: 10px; width: 10px;
height: 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> </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"> <div class="intro">
<img src="static/apple-icon.png" alt="" /> <img src="static/apple-icon.png" alt="" />
Welcome to the (unofficial) Welcome to the (unofficial)
<b> Virtual Blood on the Clocktower Town Square</b>!<br /> <b>Virtual Town Square and Grimoire</b> for Blood on the Clocktower! Please
Please add more players through the add more players through the
<span class="button" @click="toggleMenu"> <span class="button" @click="toggleMenu">
<font-awesome-icon icon="cog" /> Menu <font-awesome-icon icon="cog" /> Menu
</span> </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 This project is free and open source and can be found on
<a href="https://github.com/bra1n/townsquare" target="_blank">GitHub</a>. <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> </div>
</template> </template>
@ -33,6 +38,9 @@ export default {
border: 3px solid black; border: 3px solid black;
border-radius: 10px; border-radius: 10px;
z-index: 3; z-index: 3;
a {
color: white;
}
img { img {
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
@ -45,5 +53,9 @@ export default {
box-shadow: 0 0 10px black; box-shadow: 0 0 10px black;
border: 3px solid black; border: 3px solid black;
} }
.footer {
font-size: 60%;
opacity: 0.75;
}
} }
</style> </style>

View File

@ -26,7 +26,7 @@
:class="{ success: grimoire.isScreenshotSuccess }" :class="{ success: grimoire.isScreenshotSuccess }"
/> />
</span> </span>
<div class="menu" v-bind:class="{ open: grimoire.isMenuOpen }"> <div class="menu" :class="{ open: grimoire.isMenuOpen }">
<font-awesome-icon icon="cog" @click="toggleMenu" /> <font-awesome-icon icon="cog" @click="toggleMenu" />
<ul> <ul>
<li class="tabs" :class="tab"> <li class="tabs" :class="tab">
@ -49,6 +49,14 @@
<template v-if="grimoire.isPublic">Show</template> <template v-if="grimoire.isPublic">Show</template>
<em>[G]</em> <em>[G]</em>
</li> </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"> <li @click="toggleNightOrder" v-if="players.length">
Night order Night order
<em <em
@ -59,10 +67,6 @@
]" ]"
/></em> /></em>
</li> </li>
<li v-if="!session.isSpectator" @click="toggleModal('fabled')">
Add Fabled
<em><font-awesome-icon icon="dragon"/></em>
</li>
<li v-if="players.length"> <li v-if="players.length">
Zoom Zoom
<em> <em>
@ -104,6 +108,17 @@
Copy player link Copy player link
<em><font-awesome-icon icon="copy"/></em> <em><font-awesome-icon icon="copy"/></em>
</li> </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"> <li @click="leaveSession" v-if="session.sessionId">
Leave Session Leave Session
<em>{{ session.sessionId }}</em> <em>{{ session.sessionId }}</em>
@ -138,6 +153,10 @@
Choose & Assign Choose & Assign
<em>[C]</em> <em>[C]</em>
</li> </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"> <li @click="clearRoles" v-if="players.length">
Remove all Remove all
<em><font-awesome-icon icon="trash-alt"/></em> <em><font-awesome-icon icon="trash-alt"/></em>
@ -155,6 +174,10 @@
Night Order Sheet Night Order Sheet
<em>[N]</em> <em>[N]</em>
</li> </li>
<li @click="toggleModal('gameState')">
Game State JSON
<em><font-awesome-icon icon="file-code"/></em>
</li>
<li> <li>
<a href="https://discord.gg/Gd7ybwWbFk" target="_blank"> <a href="https://discord.gg/Gd7ybwWbFk" target="_blank">
Join Discord Join Discord
@ -215,6 +238,7 @@ export default {
Math.round(Math.random() * 10000) Math.round(Math.random() * 10000)
); );
if (sessionId) { if (sessionId) {
this.$store.commit("session/clearVoteHistory");
this.$store.commit("session/setSpectator", false); this.$store.commit("session/setSpectator", false);
this.$store.commit( this.$store.commit(
"session/setSessionId", "session/setSessionId",
@ -233,17 +257,33 @@ export default {
.then(({ state }) => { .then(({ state }) => {
if (state === "granted" || state === "prompt") { if (state === "granted" || state === "prompt") {
const url = window.location.href.split("#")[0]; const url = window.location.href.split("#")[0];
const link = url + "#play/" + this.session.sessionId; const link = url + "#" + this.session.sessionId;
navigator.clipboard.writeText(link); 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() { joinSession() {
const sessionId = prompt( const sessionId = prompt(
"Enter the channel number / name of the session you want to join" "Enter the channel number / name of the session you want to join"
); );
if (sessionId) { if (sessionId) {
this.$store.commit("session/clearVoteHistory");
this.$store.commit("session/setSpectator", true); this.$store.commit("session/setSpectator", true);
this.$store.commit("toggleGrimoire", false);
this.$store.commit( this.$store.commit(
"session/setSessionId", "session/setSessionId",
sessionId sessionId
@ -277,18 +317,17 @@ export default {
if (this.session.isSpectator) return; if (this.session.isSpectator) return;
if (confirm("Are you sure you want to remove all players?")) { if (confirm("Are you sure you want to remove all players?")) {
this.$store.commit("players/clear"); this.$store.commit("players/clear");
this.$store.commit("setBluff");
} }
}, },
clearRoles() { clearRoles() {
if (confirm("Are you sure you want to remove all player roles?")) { if (confirm("Are you sure you want to remove all player roles?")) {
this.$store.dispatch("players/clearRoles"); this.$store.dispatch("players/clearRoles");
this.$store.commit("setBluff");
} }
}, },
...mapMutations([ ...mapMutations([
"toggleGrimoire", "toggleGrimoire",
"toggleMenu", "toggleMenu",
"toggleNight",
"toggleNightOrder", "toggleNightOrder",
"updateScreenshot", "updateScreenshot",
"setZoom", "setZoom",

View File

@ -18,7 +18,7 @@
<div class="life" @click="toggleStatus()"></div> <div class="life" @click="toggleStatus()"></div>
<div <div
class="night first" class="night-order first"
v-if="nightOrder.get(player).first && grimoire.isNightOrder" v-if="nightOrder.get(player).first && grimoire.isNightOrder"
> >
<em>{{ nightOrder.get(player).first }}.</em> <em>{{ nightOrder.get(player).first }}.</em>
@ -27,7 +27,7 @@
}}</span> }}</span>
</div> </div>
<div <div
class="night other" class="night-order other"
v-if="nightOrder.get(player).other && grimoire.isNightOrder" v-if="nightOrder.get(player).other && grimoire.isNightOrder"
> >
<em>{{ nightOrder.get(player).other }}.</em> <em>{{ nightOrder.get(player).other }}.</em>
@ -44,15 +44,15 @@
<!-- Overlay icons --> <!-- Overlay icons -->
<div class="overlay"> <div class="overlay">
<font-awesome-icon <font-awesome-icon
icon="skull" icon="hand-paper"
class="vote" class="vote"
title="Voted YES" title="Hand UP"
@click="vote()" @click="vote()"
/> />
<font-awesome-icon <font-awesome-icon
icon="times" icon="times"
class="vote" class="vote"
title="Voted NO" title="Hand DOWN"
@click="vote()" @click="vote()"
/> />
<font-awesome-icon <font-awesome-icon
@ -86,6 +86,7 @@
icon="chair" icon="chair"
v-if="player.id && session.sessionId" v-if="player.id && session.sessionId"
class="seat" class="seat"
:class="{ highlight: session.isRolesDistributed }"
/> />
<!-- Ghost vote icon --> <!-- Ghost vote icon -->
@ -100,7 +101,7 @@
<div <div
class="name" class="name"
@click="isMenuOpen = !isMenuOpen" @click="isMenuOpen = !isMenuOpen"
v-bind:class="{ active: isMenuOpen }" :class="{ active: isMenuOpen }"
> >
{{ player.name }} {{ player.name }}
</div> </div>
@ -131,13 +132,27 @@
<font-awesome-icon icon="times-circle" /> <font-awesome-icon icon="times-circle" />
Remove Remove
</li> </li>
</template> <li
<li @click="claimSeat" v-if="session.isSpectator"> @click="updatePlayer('id', '', true)"
v-if="player.id && session.sessionId"
>
<font-awesome-icon icon="chair" /> <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 Claim seat
</template> </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> </li>
</ul> </ul>
</transition> </transition>
@ -146,14 +161,14 @@
<template v-if="player.reminders"> <template v-if="player.reminders">
<div <div
class="reminder" class="reminder"
v-bind:key="reminder.role + ' ' + reminder.name" :key="reminder.role + ' ' + reminder.name"
v-for="reminder in player.reminders" v-for="reminder in player.reminders"
v-bind:class="[reminder.role]" :class="[reminder.role]"
@click="removeReminder(reminder)" @click="removeReminder(reminder)"
> >
<span <span
class="icon" class="icon"
v-bind:style="{ :style="{
backgroundImage: `url(${reminder.image || backgroundImage: `url(${reminder.image ||
require('../assets/icons/' + reminder.role + '.png')})` require('../assets/icons/' + reminder.role + '.png')})`
}" }"
@ -244,22 +259,23 @@ export default {
changeName() { changeName() {
if (this.session.isSpectator) return; if (this.session.isSpectator) return;
const name = prompt("Player name", this.player.name) || this.player.name; const name = prompt("Player name", this.player.name) || this.player.name;
this.updatePlayer("name", name); this.updatePlayer("name", name, true);
this.isMenuOpen = false;
}, },
removeReminder(reminder) { removeReminder(reminder) {
const reminders = [...this.player.reminders]; const reminders = [...this.player.reminders];
reminders.splice(this.player.reminders.indexOf(reminder), 1); reminders.splice(this.player.reminders.indexOf(reminder), 1);
this.updatePlayer("reminders", reminders); this.updatePlayer("reminders", reminders, true);
this.isMenuOpen = false;
}, },
updatePlayer(property, value) { updatePlayer(property, value, closeMenu = false) {
if (this.session.isSpectator && property !== "reminders") return; if (this.session.isSpectator && property !== "reminders") return;
this.$store.commit("players/update", { this.$store.commit("players/update", {
player: this.player, player: this.player,
property, property,
value value
}); });
if (closeMenu) {
this.isMenuOpen = false;
}
}, },
removePlayer() { removePlayer() {
this.isMenuOpen = false; this.isMenuOpen = false;
@ -500,7 +516,7 @@ export default {
fill: url(#default); fill: url(#default);
} }
&:hover *, &:hover *,
&.fa-skull * { &.fa-hand-paper * {
fill: url(#demon); fill: url(#demon);
} }
&.fa-times * { &.fa-times * {
@ -510,14 +526,14 @@ export default {
} }
// other player voted yes, but is not locked yet // 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; opacity: 0.5;
transform: scale(1); transform: scale(1);
} }
// you voted yes | a locked vote yes | a locked vote no // 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.you.vote-yes .overlay svg.vote.fa-hand-paper,
#townsquare.vote .player.vote-lock.vote-yes .overlay svg.vote.fa-skull, #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 { #townsquare.vote .player.vote-lock:not(.vote-yes) .overlay svg.vote.fa-times {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
@ -598,6 +614,20 @@ li.move:not(.from) .player .overlay svg.move {
filter: drop-shadow(0 0 3px black); filter: drop-shadow(0 0 3px black);
cursor: default; cursor: default;
z-index: 2; 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 { .player.you .seat {
@ -661,6 +691,14 @@ li.move:not(.from) .player .overlay svg.move {
li:hover { li:hover {
color: red; color: red;
} }
li.disabled {
cursor: default;
&:hover {
color: white;
}
}
svg { svg {
margin-right: 2px; margin-right: 2px;
} }
@ -676,137 +714,11 @@ li.move:not(.from) .player .overlay svg.move {
} }
/**** Night reminders ****/ /**** Night reminders ****/
.player .night { .player .night-order {
position: absolute; z-index: 3;
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.dead .night em { .player.dead .night-order em {
color: #ddd; color: #ddd;
background: linear-gradient(180deg, rgba(0, 0, 0, 1) 0%, gray 100%); 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: url("../assets/reminder.png") center center;
background-size: 100%; background-size: 100%;
width: 50%; width: 50%;
height: 0;
padding-bottom: 50%;
box-sizing: content-box;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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); box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
transition: all 200ms; transition: all 200ms;
cursor: pointer; cursor: pointer;
&:before {
content: " ";
display: block;
padding-top: 100%;
}
.text { .text {
line-height: 90%; line-height: 90%;
color: black; color: black;
font-size: 45%; font-size: 50%;
font-weight: bold; font-weight: bold;
width: 90%;
text-align: center; text-align: center;
margin-top: 50%; 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, .icon,
@ -858,6 +773,7 @@ li.move:not(.from) .player .overlay svg.move {
&:after { &:after {
background-image: url("../assets/icons/x.png"); background-image: url("../assets/icons/x.png");
opacity: 0; opacity: 0;
top: 5%;
} }
&.add { &.add {
@ -867,7 +783,7 @@ li.move:not(.from) .player .overlay svg.move {
display: none; display: none;
} }
.icon { .icon {
top: auto; top: 5%;
} }
} }
@ -879,6 +795,12 @@ li.move:not(.from) .player .overlay svg.move {
font-size: 70%; font-size: 70%;
word-break: break-word; word-break: break-word;
margin-top: 0; 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 <span
class="icon" class="icon"
v-if="role.id" v-if="role.id"
v-bind:style="{ :style="{
backgroundImage: `url(${role.image || backgroundImage: `url(${role.image ||
require('../assets/icons/' + role.id + '.png')})` require('../assets/icons/' + role.id + '.png')})`
}" }"
@ -18,7 +18,7 @@
></span> ></span>
<span <span
v-if="role.reminders && role.reminders.length" v-if="role.reminders && role.reminders.length"
v-bind:class="['leaf-top' + role.reminders.length]" :class="['leaf-top' + role.reminders.length]"
></span> ></span>
<span class="leaf-orange" v-if="role.setup"></span> <span class="leaf-orange" v-if="role.setup"></span>
<svg viewBox="0 0 150 150" class="name"> <svg viewBox="0 0 150 150" class="name">
@ -32,17 +32,14 @@
x="66.6%" x="66.6%"
text-anchor="middle" text-anchor="middle"
class="label mozilla" class="label mozilla"
v-bind:font-size="role.name | nameToFontSize" :font-size="role.name | nameToFontSize"
> >
<textPath xlink:href="#curve"> <textPath xlink:href="#curve">
{{ role.name }} {{ role.name }}
</textPath> </textPath>
</text> </text>
</svg> </svg>
<div <div class="edition" :class="[`edition-${role.edition}`, role.team]"></div>
class="edition"
v-bind:class="[`edition-${role.edition}`, role.team]"
></div>
<div class="ability" v-if="role.ability"> <div class="ability" v-if="role.ability">
{{ role.ability }} {{ role.ability }}
</div> </div>

View File

@ -1,39 +1,69 @@
<template> <template>
<ul class="info"> <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"> <li v-if="players.length - teams.traveler < 5">
Please add more players! Please add more players!
</li> </li>
<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" /> {{ 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" /> {{ teams.votes }} <font-awesome-icon class="votes" icon="vote-yea" />
</span>
</li> </li>
<li v-if="players.length - teams.traveler >= 5"> <li v-if="players.length - teams.traveler >= 5">
<span>
{{ teams.townsfolk }} {{ teams.townsfolk }}
<font-awesome-icon class="townsfolk" icon="user-friends" /> <font-awesome-icon class="townsfolk" icon="user-friends" />
</span>
<span>
{{ teams.outsider }} {{ teams.outsider }}
<font-awesome-icon <font-awesome-icon
class="outsider" class="outsider"
v-bind:icon="teams.outsider > 1 ? 'user-friends' : 'user'" :icon="teams.outsider > 1 ? 'user-friends' : 'user'"
/> />
</span>
<span>
{{ teams.minion }} {{ teams.minion }}
<font-awesome-icon <font-awesome-icon
class="minion" class="minion"
v-bind:icon="teams.minion > 1 ? 'user-friends' : 'user'" :icon="teams.minion > 1 ? 'user-friends' : 'user'"
/> />
</span>
<span>
{{ teams.demon }} {{ teams.demon }}
<font-awesome-icon <font-awesome-icon
class="demon" 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 }} {{ teams.traveler }}
<font-awesome-icon <font-awesome-icon
class="traveler" 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> </li>
</ul> </ul>
</template> </template>
@ -59,7 +89,7 @@ export default {
).length ).length
}; };
}, },
...mapState(["edition"]), ...mapState(["edition", "grimoire"]),
...mapState("players", ["players"]) ...mapState("players", ["players"])
} }
}; };
@ -68,27 +98,6 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
@import "../vars.scss"; @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 { .info {
position: absolute; position: absolute;
display: flex; display: flex;
@ -103,12 +112,25 @@ export default {
background-size: auto 100%; background-size: auto 100%;
li { li {
display: block;
font-weight: bold; font-weight: bold;
text-align: center;
padding: 0 5px;
width: 100%; width: 100%;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7)); 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 { svg {
margin-right: 10px; margin-right: 10px;

View File

@ -2,20 +2,20 @@
<div <div
id="townsquare" id="townsquare"
class="square" class="square"
v-bind:class="{ :class="{
public: grimoire.isPublic, public: grimoire.isPublic,
spectator: session.isSpectator, spectator: session.isSpectator,
vote: session.nomination vote: session.nomination
}" }"
> >
<ul class="circle" v-bind:class="['size-' + players.length]"> <ul class="circle" :class="['size-' + players.length]">
<Player <Player
v-for="(player, index) in players" v-for="(player, index) in players"
:key="index" :key="index"
:player="player" :player="player"
@screenshot="$emit('screenshot', $event)" @screenshot="$emit('screenshot', $event)"
@trigger="handleTrigger(index, $event)" @trigger="handleTrigger(index, $event)"
v-bind:class="{ :class="{
from: Math.max(swap, move, nominate) === index, from: Math.max(swap, move, nominate) === index,
swap: swap > -1, swap: swap > -1,
move: move > -1, move: move > -1,
@ -39,20 +39,16 @@
</h3> </h3>
<ul> <ul>
<li <li
v-for="index in bluffs" v-for="index in bluffSize"
:key="index" :key="index"
@click="openRoleModal(index * -1)" @click="openRoleModal(index * -1)"
> >
<Token :role="grimoire.bluffs[index - 1]"></Token> <Token :role="bluffs[index - 1]"></Token>
</li> </li>
</ul> </ul>
</div> </div>
<div <div class="fabled" :class="{ closed: !isFabledOpen }" v-if="fabled.length">
class="fabled"
:class="{ closed: !isFabledOpen }"
v-if="grimoire.fabled.length"
>
<h3> <h3>
<span>Fabled</span> <span>Fabled</span>
<font-awesome-icon icon="times-circle" @click.stop="toggleFabled" /> <font-awesome-icon icon="times-circle" @click.stop="toggleFabled" />
@ -60,11 +56,29 @@
</h3> </h3>
<ul> <ul>
<li <li
v-for="(fabled, index) in grimoire.fabled" v-for="(role, index) in fabled"
:key="index" :key="index"
@click="removeFabled(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> </li>
</ul> </ul>
</div> </div>
@ -75,7 +89,7 @@
</template> </template>
<script> <script>
import { mapState } from "vuex"; import { mapGetters, mapState } from "vuex";
import Player from "./Player"; import Player from "./Player";
import Token from "./Token"; import Token from "./Token";
import ReminderModal from "./modals/ReminderModal"; import ReminderModal from "./modals/ReminderModal";
@ -89,13 +103,14 @@ export default {
ReminderModal ReminderModal
}, },
computed: { computed: {
...mapGetters({ nightOrder: "players/nightOrder" }),
...mapState(["grimoire", "roles", "session"]), ...mapState(["grimoire", "roles", "session"]),
...mapState("players", ["players"]) ...mapState("players", ["players", "bluffs", "fabled"])
}, },
data() { data() {
return { return {
selectedPlayer: 0, selectedPlayer: 0,
bluffs: 3, bluffSize: 3,
swap: -1, swap: -1,
move: -1, move: -1,
nominate: -1, nominate: -1,
@ -116,7 +131,7 @@ export default {
}, },
removeFabled(index) { removeFabled(index) {
if (this.session.isSpectator) return; if (this.session.isSpectator) return;
this.$store.commit("setFabled", { index }); this.$store.commit("players/setFabled", { index });
}, },
handleTrigger(playerIndex, [method, params]) { handleTrigger(playerIndex, [method, params]) {
if (typeof this[method] === "function") { if (typeof this[method] === "function") {
@ -198,6 +213,8 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
@import "../vars.scss";
#townsquare { #townsquare {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -292,7 +309,9 @@ export default {
.life, .life,
.token, .token,
.shroud, .shroud,
.night { .night-order,
.seat {
animation-delay: ($i - 1) * 50ms;
transition-delay: ($i - 1) * 50ms; transition-delay: ($i - 1) * 50ms;
} }
@ -320,8 +339,8 @@ export default {
} }
/***** Demon bluffs / Fabled *******/ /***** Demon bluffs / Fabled *******/
.bluffs, #townsquare > .bluffs,
.fabled { #townsquare > .fabled {
position: absolute; position: absolute;
&.bluffs { &.bluffs {
bottom: 10px; bottom: 10px;
@ -413,6 +432,9 @@ export default {
ul li { ul li {
width: 0; width: 0;
height: 0; height: 0;
.night-order {
opacity: 0;
}
.token { .token {
border-width: 0; border-width: 0;
} }
@ -428,6 +450,153 @@ export default {
z-index: 2; 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 { #townsquare:not(.spectator) .fabled ul li:hover .token:before {
opacity: 1; opacity: 1;
} }

View File

@ -5,6 +5,7 @@
<span class="nominator" :style="nominatorStyle"></span> <span class="nominator" :style="nominatorStyle"></span>
</div> </div>
<div class="overlay"> <div class="overlay">
<audio src="../assets/sounds/countdown.mp3"></audio>
<em class="blue">{{ nominator.name }}</em> nominated <em class="blue">{{ nominator.name }}</em> nominated
<em>{{ nominee.name }}</em <em>{{ nominee.name }}</em
>! >!
@ -21,48 +22,86 @@
<em>majority</em>. <em>majority</em>.
</template> </template>
<div v-if="session.lockedVote > 1"> <div v-if="session.isVoteInProgress">
<em class="blue" v-if="voters.length">{{ voters.join(", ") }} </em> <em class="blue" v-if="voters.length">{{ voters.join(", ") }} </em>
<span v-else>nobody</span> <span v-else>nobody</span>
voted <em>YES</em> had their hand <em>UP</em>
</div> </div>
<template v-if="!session.isSpectator"> <template v-if="!session.isSpectator">
<div v-if="!session.lockedVote"> <div v-if="!session.isVoteInProgress">
Vote time per player: Time per player:
<font-awesome-icon <font-awesome-icon
@mousedown.prevent="setVotingSpeed(-1)" @mousedown.prevent="setVotingSpeed(-500)"
icon="minus-circle" icon="minus-circle"
/> />
{{ session.votingSpeed }}s {{ session.votingSpeed / 1000 }}s
<font-awesome-icon <font-awesome-icon
@mousedown.prevent="setVotingSpeed(1)" @mousedown.prevent="setVotingSpeed(500)"
icon="plus-circle" icon="plus-circle"
/> />
</div> </div>
<div class="button-group"> <div class="button-group">
<div class="button" v-if="!session.lockedVote" @click="start"> <div
Start Vote class="button townsfolk"
v-if="!session.isVoteInProgress"
@click="countdown"
>
Countdown
</div> </div>
<div class="button" v-else @click="stop"> <div class="button" v-if="!session.isVoteInProgress" @click="start">
Reset Vote {{ session.lockedVote ? "Restart" : "Start" }}
</div> </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> </div>
</template> </template>
<template v-else-if="canVote"> <template v-else-if="canVote">
<div v-if="!session.lockedVote"> <div v-if="!session.isVoteInProgress">
{{ session.votingSpeed }} seconds between votes {{ session.votingSpeed / 1000 }} seconds between votes
</div> </div>
<div class="button-group"> <div class="button-group">
<div class="button vote-no" @click="vote(false)">Vote NO</div> <div
<div class="button vote-yes" @click="vote(true)">Vote YES</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> </div>
</template> </template>
<div v-else-if="!player"> <div v-else-if="!player">
Please claim a seat to vote. Please claim a seat to vote.
</div> </div>
</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> </div>
</template> </template>
@ -95,12 +134,16 @@ export default {
const rotation = (360 * (nomination + Math.min(lock, players))) / players; const rotation = (360 * (nomination + Math.min(lock, players))) / players;
return { return {
transform: `rotate(${Math.round(rotation)}deg)`, transform: `rotate(${Math.round(rotation)}deg)`,
transitionDuration: this.session.votingSpeed - 0.1 + "s" transitionDuration: this.session.votingSpeed - 100 + "ms"
}; };
}, },
player: function() { player: function() {
return this.players.find(p => p.id === this.session.playerId); 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() { canVote: function() {
if (!this.player) return false; if (!this.player) return false;
if (this.player.isVoteless && this.nominee.role.team !== "traveler") 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); return reorder.slice(0, this.session.lockedVote - 1).filter(n => !!n);
} }
}, },
data() {
return {
voteTimer: null
};
},
methods: { methods: {
countdown() {
this.$store.commit("session/setVoteInProgress", true);
this.$store.commit("session/lockVote", 0);
this.voteTimer = setInterval(() => {
this.start();
}, 4000);
},
start() { start() {
this.$store.commit("session/lockVote"); this.$store.commit("session/setVoteInProgress", true);
this.$store.commit("session/lockVote", 1);
clearInterval(this.voteTimer); clearInterval(this.voteTimer);
this.voteTimer = setInterval(() => { this.voteTimer = setInterval(() => {
this.$store.commit("session/lockVote"); this.$store.commit("session/lockVote");
if (this.session.lockedVote > this.players.length) { if (this.session.lockedVote > this.players.length) {
clearInterval(this.voteTimer); 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() { stop() {
clearInterval(this.voteTimer); clearInterval(this.voteTimer);
this.voteTimer = null;
this.$store.commit("session/setVoteInProgress", false);
this.$store.commit("session/lockVote", 0); this.$store.commit("session/lockVote", 0);
}, },
finish() { finish() {
clearInterval(this.voteTimer); clearInterval(this.voteTimer);
this.$store.commit("session/addHistory", this.players);
this.$store.commit("session/nomination"); this.$store.commit("session/nomination");
}, },
vote(vote) { vote(vote) {
@ -151,7 +225,7 @@ export default {
} }
}, },
setVotingSpeed(diff) { setVotingSpeed(diff) {
const speed = this.session.votingSpeed + diff; const speed = Math.round(this.session.votingSpeed + diff);
if (speed > 0) { if (speed > 0) {
this.$store.commit("session/setVotingSpeed", speed); this.$store.commit("session/setVotingSpeed", speed);
} }
@ -257,29 +331,78 @@ export default {
} }
} }
.button.vote-no { @keyframes countdown {
background: radial-gradient( 0% {
at 0 -15%, transform: scale(1.5);
rgba(255, 255, 255, 0.07) 70%, opacity: 0;
rgba(255, 255, 255, 0) 71% filter: blur(20px);
) }
0 0/80% 90% no-repeat content-box, 10% {
linear-gradient(#0031ad, rgba(5, 0, 0, 0.22)) content-box, opacity: 1;
linear-gradient(#292929, #001142) border-box; }
box-shadow: inset 0 1px 1px #002c9c, 0 0 10px #000; 50% {
&:hover { transform: scale(1);
color: #008cf7; filter: blur(0);
}
90% {
color: $townsfolk;
opacity: 1;
}
100% {
opacity: 0;
} }
} }
.button.vote-yes {
background: radial-gradient( @keyframes countdown-go {
at 0 -15%, 0% {
rgba(255, 255, 255, 0.07) 70%, transform: scale(1.5);
rgba(255, 255, 255, 0) 71% opacity: 0;
) filter: blur(20px);
0 0/80% 90% no-repeat content-box, }
linear-gradient(#ad0000, rgba(5, 0, 0, 0.22)) content-box, 10% {
linear-gradient(#292929, #420000) border-box; opacity: 1;
box-shadow: inset 0 1px 1px #9c0000, 0 0 10px #000; }
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> </style>

View File

@ -1,22 +1,29 @@
<template> <template>
<Modal <Modal class="editions" v-if="modals.edition" @close="toggleModal('edition')">
class="editions"
v-show="modals.edition"
@close="toggleModal('edition')"
>
<div v-if="!isCustom"> <div v-if="!isCustom">
<h3>Select an edition:</h3> <h3>Select an edition:</h3>
<ul class="editions"> <ul class="editions">
<li <li
v-for="edition in editions" v-for="edition in editions"
class="edition" class="edition"
v-bind:class="['edition-' + edition.id]" :class="['edition-' + edition.id]"
v-bind:key="edition.id" :style="{
@click="setEdition(edition.id)" backgroundImage: `url(${require('../../assets/editions/' +
edition.id +
'.png')})`
}"
:key="edition.id"
@click="setEdition(edition)"
> >
{{ edition.name }} {{ edition.name }}
</li> </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 Custom Script / Characters
</li> </li>
</ul> </ul>
@ -38,11 +45,12 @@
>the documentation</a >the documentation</a
> >
on how to write a custom character definition file. 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> <h3>Some popular custom scripts:</h3>
<ul class="scripts"> <ul class="scripts">
<li <li
v-for="(script, index) in scripts" v-for="(script, index) in scripts"
v-bind:key="index" :key="index"
@click="handleURL(script[1])" @click="handleURL(script[1])"
> >
{{ script[0] }} {{ script[0] }}
@ -85,11 +93,11 @@ export default {
scripts: [ scripts: [
[ [
"Deadly Penance Day", "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", "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)", "On Thin Ice (Teensyville)",
@ -150,12 +158,20 @@ export default {
}, },
parseRoles(roles) { parseRoles(roles) {
if (!roles || !roles.length) return; 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 => { const customRoles = roles.map(role => {
role.id = role.id.toLocaleLowerCase().replace(/[^a-z0-9]/g, ""); role.id = role.id.toLocaleLowerCase().replace(/[^a-z0-9]/g, "");
return role; return role;
}); });
this.$store.commit("setCustomRoles", customRoles); 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 // check for fabled and set those too, if present
if (customRoles.some(({ id }) => this.$store.state.fabled.has(id))) { if (customRoles.some(({ id }) => this.$store.state.fabled.has(id))) {
const fabled = []; const fabled = [];
@ -164,7 +180,7 @@ export default {
fabled.push(this.$store.state.fabled.get(id)); fabled.push(this.$store.state.fabled.get(id));
} }
}); });
this.$store.commit("setFabled", { fabled }); this.$store.commit("players/setFabled", { fabled });
} }
this.isCustom = false; this.isCustom = false;
}, },
@ -174,29 +190,6 @@ export default {
</script> </script>
<style scoped lang="scss"> <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 { ul.editions .edition {
font-family: PiratesBay, sans-serif; font-family: PiratesBay, sans-serif;
letter-spacing: 1px; letter-spacing: 1px;

View File

@ -1,10 +1,10 @@
<template> <template>
<Modal v-show="modals.fabled && fabled.length" @close="toggleModal('fabled')"> <Modal v-if="modals.fabled && fabled.length" @close="toggleModal('fabled')">
<h3> <h3>
Choose a fabled character to add to the game Choose a fabled character to add to the game
</h3> </h3>
<ul class="tokens"> <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" /> <Token :role="role" />
</li> </li>
</ul> </ul>
@ -25,7 +25,7 @@ export default {
this.$store.state.fabled.forEach(role => { this.$store.state.fabled.forEach(role => {
// don't show fabled that are already in play // don't show fabled that are already in play
if ( 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); fabled.push(role);
} }
@ -35,7 +35,7 @@ export default {
}, },
methods: { methods: {
setFabled(role) { setFabled(role) {
this.$store.commit("setFabled", { this.$store.commit("players/setFabled", {
fabled: role fabled: role
}); });
this.$store.commit("toggleModal", "fabled"); 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; flex-direction: column;
max-width: 60%; max-width: 60%;
.characters & { .characters &,
.vote-history &,
.night-reference & {
max-height: 80%; max-height: 80%;
max-width: 80%; max-width: 80%;
overflow-y: auto; overflow-y: auto;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,24 +1,19 @@
<template> <template>
<Modal <Modal
class="roles" class="roles"
v-show="modals.roles" v-if="modals.roles && nonTravelers >= 5"
@close="toggleModal('roles')" @close="toggleModal('roles')"
v-if="nonTravelers >= 5"
> >
<h3>Select the characters for {{ nonTravelers }} players:</h3> <h3>Select the characters for {{ nonTravelers }} players:</h3>
<ul <ul class="tokens" v-for="(teamRoles, team) in roleSelection" :key="team">
class="tokens" <li class="count" :class="[team]">
v-for="(teamRoles, team) in roleSelection"
v-bind:key="team"
>
<li class="count" v-bind:class="[team]">
{{ teamRoles.filter(role => role.selected).length }} / {{ teamRoles.filter(role => role.selected).length }} /
{{ game[nonTravelers - 5][team] }} {{ game[nonTravelers - 5][team] }}
</li> </li>
<li <li
v-for="role in teamRoles" v-for="role in teamRoles"
v-bind:class="[role.team, role.selected ? 'selected' : '']" :class="[role.team, role.selected ? 'selected' : '']"
v-bind:key="role.id" :key="role.id"
@click="role.selected = !role.selected" @click="role.selected = !role.selected"
> >
<Token :role="role" /> <Token :role="role" />
@ -32,7 +27,7 @@
<div <div
class="button" class="button"
@click="assignRoles" @click="assignRoles"
v-bind:class="{ :class="{
disabled: selectedRoles > nonTravelers || !selectedRoles 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", "id": "tb",
"name": "Trouble Brewing", "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.", "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", "level": "Beginner",
"roles": [] "roles": [],
"isOfficial": true
}, },
{ {
"id": "bmr", "id": "bmr",
"name": "Bad Moon Rising", "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.", "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", "level": "Intermediate",
"roles": [] "roles": [],
"isOfficial": true
}, },
{ {
"id": "snv", "id": "snv",
"name": "Sects & Violets", "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.", "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", "level": "Intermediate",
"roles": [] "roles": [],
"isOfficial": true
}, },
{ {
"id": "luf", "id": "luf",
"name": "Laissez un Faire", "name": "Laissez un Faire",
"hasTravelers": false, "author": "The Pandemonium Institute",
"description": "", "description": "",
"level": "Veteran", "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", "id": "toymaker",
"firstNightReminder": "", "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.)", "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"], "reminders": ["Final Night: No Attack"],
"setup": false, "setup": false,
@ -82,6 +83,7 @@
{ {
"id": "duchess", "id": "duchess",
"firstNightReminder": "", "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.”", "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"], "reminders": ["Visitor", "False Info"],
"setup": false, "setup": false,
@ -111,6 +113,7 @@
}, },
{ {
"id": "djinn", "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.", "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": "", "otherNightReminder": "",
"reminders": [], "reminders": [],
@ -121,6 +124,7 @@
}, },
{ {
"id": "stormcatcher", "id": "stormcatcher",
"firstNight": 1,
"firstNightReminder": "Mark a good player as \"Safe\". Wake each evil player and show them the marked player.", "firstNightReminder": "Mark a good player as \"Safe\". Wake each evil player and show them the marked player.",
"otherNightReminder": "", "otherNightReminder": "",
"reminders": ["Safe"], "reminders": ["Safe"],
@ -128,5 +132,15 @@
"name": "Storm Catcher", "name": "Storm Catcher",
"team": "fabled", "team": "fabled",
"ability": "Name a good character. If in play, they can only die by execution, but evil players learn which player it is." "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", "Dice",
"Dragon", "Dragon",
"ExchangeAlt", "ExchangeAlt",
"FileCode",
"FileUpload", "FileUpload",
"HandPaper",
"HandPointRight", "HandPointRight",
"Heartbeat", "Heartbeat",
"Image", "Image",
@ -32,8 +34,8 @@ const faIcons = [
"RedoAlt", "RedoAlt",
"SearchMinus", "SearchMinus",
"SearchPlus", "SearchPlus",
"Skull",
"Square", "Square",
"Sun",
"TheaterMasks", "TheaterMasks",
"Times", "Times",
"TimesCircle", "TimesCircle",

View File

@ -1288,7 +1288,10 @@
"otherNight": 47, "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.", "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": [ "reminders": [
"Seen" "Seen Townsfolk",
"Seen Outsider",
"Seen Minion",
"Seen Demon"
], ],
"setup": true, "setup": true,
"name": "Balloonist", "name": "Balloonist",

View File

@ -10,17 +10,16 @@ import fabledJSON from "../fabled.json";
Vue.use(Vuex); Vue.use(Vuex);
const editionJSONbyId = new Map(
editionJSON.map(edition => [edition.id, edition])
);
const rolesJSONbyId = new Map(rolesJSON.map(role => [role.id, role])); const rolesJSONbyId = new Map(rolesJSON.map(role => [role.id, role]));
const fabled = new Map(fabledJSON.map(role => [role.id, role])); const fabled = new Map(fabledJSON.map(role => [role.id, role]));
const getRolesByEdition = (edition = "tb") => { const getRolesByEdition = (edition = editionJSON[0]) => {
const selectedEdition =
editionJSON.find(({ id }) => id === edition) || editionJSON[0];
return new Map( return new Map(
rolesJSON rolesJSON
.filter( .filter(r => r.edition === edition.id || edition.roles.includes(r.id))
r => r.edition === edition || selectedEdition.roles.includes(r.id)
)
.sort((a, b) => b.team.localeCompare(a.team)) .sort((a, b) => b.team.localeCompare(a.team))
.map(role => [role.id, role]) .map(role => [role.id, role])
); );
@ -50,26 +49,27 @@ export default new Vuex.Store({
}, },
state: { state: {
grimoire: { grimoire: {
isNight: false,
isNightOrder: true, isNightOrder: true,
isPublic: true, isPublic: true,
isMenuOpen: false, isMenuOpen: false,
isScreenshot: false, isScreenshot: false,
isScreenshotSuccess: false, isScreenshotSuccess: false,
zoom: 0, zoom: 0,
background: "", background: ""
bluffs: [],
fabled: []
}, },
modals: { modals: {
edition: false, edition: false,
fabled: false, fabled: false,
gameState: false,
nightOrder: false, nightOrder: false,
reference: false, reference: false,
reminder: false, reminder: false,
role: false, role: false,
roles: false roles: false,
voteHistory: false
}, },
edition: "tb", edition: editionJSONbyId.get("tb"),
roles: getRolesByEdition(), roles: getRolesByEdition(),
fabled fabled
}, },
@ -88,7 +88,10 @@ export default new Vuex.Store({
const strippedRole = {}; const strippedRole = {};
for (let prop in role) { for (let prop in role) {
const value = role[prop]; const value = role[prop];
if (prop === "image" && value.match(new RegExp("^" + imageBase))) { if (
prop === "image" &&
value.toLocaleLowerCase().includes(imageBase)
) {
continue; continue;
} }
if (prop !== "isCustom" && value !== customRole[prop]) { if (prop !== "isCustom" && value !== customRole[prop]) {
@ -115,6 +118,13 @@ export default new Vuex.Store({
grimoire.isPublic ? "Town Square" : "Grimoire" grimoire.isPublic ? "Town Square" : "Grimoire"
}`; }`;
}, },
toggleNight({ grimoire }, isNight) {
if (isNight === true || isNight === false) {
grimoire.isNight = isNight;
} else {
grimoire.isNight = !grimoire.isNight;
}
},
toggleNightOrder({ grimoire }) { toggleNightOrder({ grimoire }) {
grimoire.isNightOrder = !grimoire.isNightOrder; grimoire.isNightOrder = !grimoire.isNightOrder;
}, },
@ -124,24 +134,6 @@ export default new Vuex.Store({
setBackground({ grimoire }, background) { setBackground({ grimoire }, background) {
grimoire.background = 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) { toggleModal({ modals }, name) {
if (name) { if (name) {
modals[name] = !modals[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 existing roles to base definition or pre-populate custom roles to ensure all properties
.map( .map(
role => 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 // default empty icons to good / evil / traveler
.map(role => { .map(role => {
@ -194,11 +188,13 @@ export default new Vuex.Store({
); );
}, },
setEdition(state, edition) { setEdition(state, edition) {
if (editionJSONbyId.has(edition.id)) {
state.edition = editionJSONbyId.get(edition.id);
state.roles = getRolesByEdition(state.edition);
} else {
state.edition = edition; state.edition = edition;
state.modals.edition = false;
if (edition !== "custom") {
state.roles = getRolesByEdition(edition);
} }
state.modals.edition = false;
} }
}, },
plugins: [persistence, socket] plugins: [persistence, socket]

View File

@ -8,7 +8,9 @@ const NEWPLAYER = {
}; };
const state = () => ({ const state = () => ({
players: [] players: [],
fabled: [],
bluffs: []
}); });
const getters = { const getters = {
@ -22,11 +24,18 @@ const getters = {
return Math.min(nonTravelers.length, 15); return Math.min(nonTravelers.length, 15);
}, },
// calculate a Map of player => night order // calculate a Map of player => night order
nightOrder({ players }) { nightOrder({ players, fabled }) {
const firstNight = [0]; const firstNight = [0];
const otherNight = [0]; const otherNight = [0];
players.forEach(({ role }) => { 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)) { if (role.firstNight && !firstNight.includes(role.firstNight)) {
firstNight.push(role.firstNight); firstNight.push(role.firstNight);
} }
@ -42,6 +51,11 @@ const getters = {
const other = Math.max(otherNight.indexOf(player.role.otherNight), 0); const other = Math.max(otherNight.indexOf(player.role.otherNight), 0);
nightOrder.set(player, { first, other }); 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; return nightOrder;
} }
}; };
@ -72,12 +86,14 @@ const actions = {
})); }));
} }
commit("set", players); commit("set", players);
commit("setBluff");
} }
}; };
const mutations = { const mutations = {
clear(state) { clear(state) {
state.players = []; state.players = [];
state.bluffs = [];
}, },
set(state, players = []) { set(state, players = []) {
state.players = players; state.players = players;
@ -107,6 +123,24 @@ const mutations = {
}, },
move(state, [from, to]) { move(state, [from, to]) {
state.players.splice(to, 0, state.players.splice(from, 1)[0]); 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 state session state
* @param index seat of the player in the circle * @param index seat of the player in the circle
* @param vote true or false * @param vote true or false
* @param indexAdjusted seat of the player counted from the nominated player
*/ */
const handleVote = (state, [index, vote]) => { const handleVote = (state, [index, vote]) => {
if (!state.nomination) return; if (!state.nomination) return;
@ -28,7 +27,10 @@ const state = () => ({
nomination: false, nomination: false,
votes: [], votes: [],
lockedVote: 0, lockedVote: 0,
votingSpeed: 3 votingSpeed: 3000,
isVoteInProgress: false,
voteHistory: [],
isRolesDistributed: false
}); });
const getters = {}; const getters = {};
@ -43,12 +45,42 @@ const mutations = {
setPlayerCount: set("playerCount"), setPlayerCount: set("playerCount"),
setPing: set("ping"), setPing: set("ping"),
setVotingSpeed: set("votingSpeed"), setVotingSpeed: set("votingSpeed"),
setVoteInProgress: set("isVoteInProgress"),
claimSeat: set("claimedSeat"), claimSeat: set("claimedSeat"),
nomination(state, { nomination, votes, votingSpeed, lockedVote } = {}) { distributeRoles: set("isRolesDistributed"),
nomination(
state,
{ nomination, votes, votingSpeed, lockedVote, isVoteInProgress } = {}
) {
state.nomination = nomination || false; state.nomination = nomination || false;
state.votes = votes || []; state.votes = votes || [];
state.votingSpeed = votingSpeed || state.votingSpeed; state.votingSpeed = votingSpeed || state.votingSpeed;
state.lockedVote = lockedVote || 0; 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. * 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) { if (localStorage.isPublic !== undefined) {
store.commit("toggleGrimoire", JSON.parse(localStorage.isPublic)); 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) { if (localStorage.roles !== undefined) {
store.commit("setCustomRoles", JSON.parse(localStorage.roles)); 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) { if (localStorage.bluffs !== undefined) {
JSON.parse(localStorage.bluffs).forEach((role, index) => { JSON.parse(localStorage.bluffs).forEach((role, index) => {
store.commit("setBluff", { store.commit("players/setBluff", {
index, index,
role: store.state.roles.get(role) || {} role: store.state.roles.get(role) || {}
}); });
}); });
} }
if (localStorage.fabled !== undefined) { if (localStorage.fabled !== undefined) {
store.commit("setFabled", { store.commit("players/setFabled", {
fabled: JSON.parse(localStorage.fabled).map(id => fabled: JSON.parse(localStorage.fabled).map(id =>
store.state.fabled.get(id) store.state.fabled.get(id)
) )
@ -74,10 +75,8 @@ module.exports = store => {
} }
break; break;
case "setEdition": case "setEdition":
if (payload === "custom") { localStorage.setItem("edition", JSON.stringify(payload));
localStorage.removeItem("edition"); if (state.edition.isOfficial) {
} else {
localStorage.setItem("edition", payload);
localStorage.removeItem("roles"); localStorage.removeItem("roles");
} }
break; break;
@ -85,19 +84,22 @@ module.exports = store => {
if (!payload.length) { if (!payload.length) {
localStorage.removeItem("roles"); localStorage.removeItem("roles");
} else { } else {
localStorage.setItem("roles", JSON.stringify(payload)); localStorage.setItem(
"roles",
JSON.stringify(store.getters.customRoles)
);
} }
break; break;
case "setBluff": case "players/setBluff":
localStorage.setItem( localStorage.setItem(
"bluffs", "bluffs",
JSON.stringify(state.grimoire.bluffs.map(({ id }) => id)) JSON.stringify(state.players.bluffs.map(({ id }) => id))
); );
break; break;
case "setFabled": case "players/setFabled":
localStorage.setItem( localStorage.setItem(
"fabled", "fabled",
JSON.stringify(state.grimoire.fabled.map(({ id }) => id)) JSON.stringify(state.players.fabled.map(({ id }) => id))
); );
break; break;
case "players/add": case "players/add":

View File

@ -1,9 +1,8 @@
import rolesJSON from "../roles.json";
class LiveSession { class LiveSession {
constructor(store) { 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 = "ws://localhost:8081/";
this._wss = "wss://baumgart.biz:8080/";
this._socket = null; this._socket = null;
this._isSpectator = true; this._isSpectator = true;
this._gamestate = []; this._gamestate = [];
@ -27,7 +26,10 @@ class LiveSession {
_open(channel) { _open(channel) {
this.disconnect(); this.disconnect();
this._socket = new WebSocket( 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.addEventListener("message", this._handleMessage.bind(this));
this._socket.onopen = this._onOpen.bind(this); this._socket.onopen = this._onOpen.bind(this);
@ -127,6 +129,13 @@ class LiveSession {
break; break;
case "nomination": case "nomination":
if (!this._isSpectator) return; 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 }); this._store.commit("session/nomination", { nomination: params });
break; break;
case "swap": case "swap":
@ -137,10 +146,22 @@ class LiveSession {
if (!this._isSpectator) return; if (!this._isSpectator) return;
this._store.commit("players/move", params); this._store.commit("players/move", params);
break; break;
case "isNight":
if (!this._isSpectator) return;
this._store.commit("toggleNight", params);
break;
case "votingSpeed": case "votingSpeed":
if (!this._isSpectator) return; if (!this._isSpectator) return;
this._store.commit("session/setVotingSpeed", params); this._store.commit("session/setVotingSpeed", params);
break; 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": case "vote":
this._handleVote(params); this._handleVote(params);
break; break;
@ -192,8 +213,10 @@ class LiveSession {
/** /**
* Publish the current gamestate. * Publish the current gamestate.
* Optional param to reduce traffic. (send only player data)
* @param isLightweight
*/ */
sendGamestate() { sendGamestate(isLightweight = false) {
if (this._isSpectator) return; if (this._isSpectator) return;
this._gamestate = this._store.state.players.players.map(player => ({ this._gamestate = this._store.state.players.players.map(player => ({
name: player.name, name: player.name,
@ -204,17 +227,24 @@ class LiveSession {
? { roleId: player.role.id } ? { 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.sendEdition();
this.sendFabled();
this._send("gs", { this._send("gs", {
gamestate: this._gamestate, gamestate: this._gamestate,
isNight: grimoire.isNight,
nomination: session.nomination, nomination: session.nomination,
votingSpeed: session.votingSpeed, votingSpeed: session.votingSpeed,
lockedVote: session.lockedVote, lockedVote: session.lockedVote,
isVoteInProgress: session.isVoteInProgress,
fabled: fabled.map(({ id }) => id),
...(session.nomination ? { votes: session.votes } : {}) ...(session.nomination ? { votes: session.votes } : {})
}); });
} }
}
/** /**
* Update the gamestate based on incoming data. * Update the gamestate based on incoming data.
@ -223,13 +253,17 @@ class LiveSession {
*/ */
_updateGamestate(data) { _updateGamestate(data) {
if (!this._isSpectator) return; if (!this._isSpectator) return;
const { gamestate, nomination, votingSpeed, votes, lockedVote } = data; const {
this._store.commit("session/nomination", { gamestate,
isLightweight,
isNight,
nomination, nomination,
votes,
votingSpeed, votingSpeed,
lockedVote votes,
}); lockedVote,
isVoteInProgress,
fabled
} = data;
const players = this._store.state.players.players; const players = this._store.state.players.players;
// adjust number of players // adjust number of players
if (players.length < gamestate.length) { if (players.length < gamestate.length) {
@ -254,7 +288,7 @@ class LiveSession {
}); });
// roles are special, because of travelers // roles are special, because of travelers
if (roleId && player.role.id !== roleId) { 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", { this._store.commit("players/update", {
player, player,
property: "role", 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; if (this._isSpectator) return;
const { edition } = this._store.state; const { edition } = this._store.state;
let roles; let roles;
if (edition === "custom") { if (!edition.isOfficial) {
roles = this._store.getters.customRoles; roles = Array.from(this._store.state.roles.keys());
} }
this._send("edition", { this._send("edition", {
edition, edition: edition.isOfficial
? { id: edition.id }
: Object.assign({}, edition, { logo: "" }),
...(roles ? { roles } : {}) ...(roles ? { roles } : {})
}); });
} }
@ -296,7 +345,23 @@ class LiveSession {
if (!this._isSpectator) return; if (!this._isSpectator) return;
this._store.commit("setEdition", edition); this._store.commit("setEdition", edition);
if (roles) { 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() { sendFabled() {
if (this._isSpectator) return; if (this._isSpectator) return;
const { fabled } = this._store.state.grimoire; const { fabled } = this._store.state.players;
this._send( this._send(
"fabled", "fabled",
fabled.map(({ id }) => id) fabled.map(({ id }) => id)
@ -319,7 +384,7 @@ class LiveSession {
*/ */
_updateFabled(fabled) { _updateFabled(fabled) {
if (!this._isSpectator) return; if (!this._isSpectator) return;
this._store.commit("setFabled", { this._store.commit("players/setFabled", {
fabled: fabled.map(id => this._store.state.fabled.get(id)) fabled: fabled.map(id => this._store.state.fabled.get(id))
}); });
} }
@ -372,8 +437,8 @@ class LiveSession {
value: {} value: {}
}); });
} else { } else {
// load traveler role // load role
const role = rolesJSON.find(r => r.id === value); const role = this._store.state.roles.get(value);
this._store.commit("players/update", { this._store.commit("players/update", {
player, player,
property: "role", property: "role",
@ -452,11 +517,13 @@ class LiveSession {
/** /**
* Claim a seat, needs to be confirmed by the Storyteller. * 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) { claimSeat(seat) {
if (!this._isSpectator) return; 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]); this._send("claim", [seat, this._store.state.session.playerId]);
} }
} }
@ -490,6 +557,26 @@ class LiveSession {
this._handlePing([true, value]); 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 * A player nomination. ST only
* This also syncs the voting speed to the players. * 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 * Send the voting speed. ST only
* @param votingSpeed voting speed in seconds, minimum 1 * @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 * Send a vote. Player or ST
* @param index Seat of the player * @param index Seat of the player
@ -620,9 +731,17 @@ export default store => {
case "session/claimSeat": case "session/claimSeat":
session.claimSeat(payload); session.claimSeat(payload);
break; break;
case "session/distributeRoles":
if (payload) {
session.distributeRoles();
}
break;
case "session/nomination": case "session/nomination":
session.nomination(payload); session.nomination(payload);
break; break;
case "session/setVoteInProgress":
session.setVoteInProgress(payload);
break;
case "session/voteSync": case "session/voteSync":
session.vote(payload); session.vote(payload);
break; break;
@ -632,10 +751,16 @@ export default store => {
case "session/setVotingSpeed": case "session/setVotingSpeed":
session.setVotingSpeed(payload); session.setVotingSpeed(payload);
break; break;
case "session/clearVoteHistory":
session.clearVoteHistory();
break;
case "toggleNight":
session.setIsNight();
break;
case "setEdition": case "setEdition":
session.sendEdition(); session.sendEdition();
break; break;
case "setFabled": case "players/setFabled":
session.sendFabled(); session.sendFabled();
break; break;
case "players/swap": case "players/swap":
@ -648,7 +773,7 @@ export default store => {
case "players/clear": case "players/clear":
case "players/remove": case "players/remove":
case "players/add": case "players/add":
session.sendGamestate(); session.sendGamestate(true);
break; break;
case "players/update": case "players/update":
session.sendPlayer(payload); session.sendPlayer(payload);
@ -657,9 +782,9 @@ export default store => {
}); });
// check for session Id in hash // check for session Id in hash
const [command, param] = window.location.hash.substr(1).split("/"); const sessionId = window.location.hash.substr(1);
if (command === "play") { if (sessionId) {
store.commit("session/setSpectator", true); 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; $townsfolk: #1f65ff;
$outsider: #46d5ff; $outsider: #46d5ff;
$minion: #ff6900; $minion: #ff6900;
$demon: #ce0100; $demon: #ce0100;
$traveler: #cc04ff; $traveler: #cc04ff;
$editions:
'tb',
'bmr',
'snv',
'luf' true,
'custom' true
;