mirror of https://github.com/bra1n/townsquare.git
commit
ed03b055f3
|
@ -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
|
34
README.md
34
README.md
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
```
|
145
server/index.js
145
server/index.js
|
@ -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)) {
|
// check for another host on this channel
|
||||||
ws.isHost = true;
|
if (
|
||||||
ws.channel = ws.channel.substr(0, ws.channel.length - 5);
|
ws.playerId === "host" &&
|
||||||
// check for another host on this channel
|
channels[ws.channel] &&
|
||||||
if (
|
channels[ws.channel].some(
|
||||||
Array.from(wss.clients).some(
|
client =>
|
||||||
client =>
|
client !== ws &&
|
||||||
client !== ws &&
|
client.readyState === WebSocket.OPEN &&
|
||||||
client.readyState === WebSocket.OPEN &&
|
client.playerId === "host"
|
||||||
client.channel === ws.channel &&
|
)
|
||||||
client.isHost
|
) {
|
||||||
)
|
console.log(ws.channel, "duplicate host");
|
||||||
) {
|
ws.close(1000, `The channel "${ws.channel}" already has a host`);
|
||||||
console.log(ws.channel, "duplicate host");
|
return;
|
||||||
ws.close(1000, `The channel "${ws.channel}" already has a host`);
|
|
||||||
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];
|
||||||
if (
|
});
|
||||||
client !== ws &&
|
// handle message
|
||||||
client.readyState === WebSocket.OPEN &&
|
ws.on("message", function incoming(data) {
|
||||||
client.channel === ws.channel
|
// check rate limit (max 5msg/second)
|
||||||
) {
|
ws.counter++;
|
||||||
// inject latency between both clients if ping message
|
if (ws.counter > (5 * PING_INTERVAL) / 1000) {
|
||||||
if (isPing && client.latency && ws.latency) {
|
console.log(ws.channel, "disconnecting user due to spam");
|
||||||
client.send(data.replace(/latency/, client.latency + ws.latency));
|
ws.close(
|
||||||
} else {
|
1000,
|
||||||
client.send(data);
|
"Your app seems to be malfunctioning, please clear your browser cache."
|
||||||
}
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const messageType = data
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.substr(1)
|
||||||
|
.split(",", 1)
|
||||||
|
.pop();
|
||||||
|
// don't log ping messages
|
||||||
|
if (messageType !== '"ping"') {
|
||||||
|
console.log(new Date(), wss.clients.size, ws.channel, ws.playerId, data);
|
||||||
|
}
|
||||||
|
// handle "direct" messages differently
|
||||||
|
if (messageType === '"direct"') {
|
||||||
|
try {
|
||||||
|
const dataToPlayer = JSON.parse(data)[1];
|
||||||
|
channels[ws.channel].forEach(function each(client) {
|
||||||
|
if (
|
||||||
|
client !== ws &&
|
||||||
|
client.readyState === WebSocket.OPEN &&
|
||||||
|
dataToPlayer[client.playerId]
|
||||||
|
) {
|
||||||
|
client.send(JSON.stringify(dataToPlayer[client.playerId]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.log("error parsing direct message JSON", e);
|
||||||
}
|
}
|
||||||
});
|
} else {
|
||||||
|
// all other messages
|
||||||
|
channels[ws.channel].forEach(function each(client) {
|
||||||
|
if (client !== ws && client.readyState === WebSocket.OPEN) {
|
||||||
|
// inject latency between both clients if ping message
|
||||||
|
if (messageType === '"ping"' && client.latency && ws.latency) {
|
||||||
|
client.send(data.replace(/latency/, client.latency + ws.latency));
|
||||||
|
} else {
|
||||||
|
client.send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// start ping interval timer
|
||||||
const interval = setInterval(function ping() {
|
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
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
94
src/App.vue
94
src/App.vue
|
@ -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>
|
||||||
|
|
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.
|
@ -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
|
||||||
This project is free and open source and can be found on
|
by pressing <b>[J]</b>.<br />
|
||||||
<a href="https://github.com/bra1n/townsquare" target="_blank">GitHub</a>.
|
<div class="footer">
|
||||||
|
This project is free and open source and can be found on
|
||||||
|
<a href="https://github.com/bra1n/townsquare" target="_blank">GitHub</a>.
|
||||||
|
It is not affiliated with The Pandemonium Institute. "Blood on the
|
||||||
|
Clocktower" is a trademark of Steven Medway and The Pandemonium Institute.
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
|
<li
|
||||||
|
@click="updatePlayer('id', '', true)"
|
||||||
|
v-if="player.id && session.sessionId"
|
||||||
|
>
|
||||||
|
<font-awesome-icon icon="chair" />
|
||||||
|
Empty seat
|
||||||
|
</li>
|
||||||
</template>
|
</template>
|
||||||
<li @click="claimSeat" v-if="session.isSpectator">
|
<li
|
||||||
|
@click="claimSeat"
|
||||||
|
v-if="session.isSpectator"
|
||||||
|
:class="{ disabled: player.id && player.id !== session.playerId }"
|
||||||
|
>
|
||||||
<font-awesome-icon icon="chair" />
|
<font-awesome-icon icon="chair" />
|
||||||
<template v-if="player.id !== session.playerId">
|
<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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
{{ players.length }} <font-awesome-icon class="players" icon="users" />
|
<span class="meta" v-if="!edition.isOfficial">
|
||||||
{{ teams.alive }} <font-awesome-icon class="alive" icon="heartbeat" />
|
{{ edition.name }}
|
||||||
{{ teams.votes }} <font-awesome-icon class="votes" icon="vote-yea" />
|
{{ edition.author ? "by " + edition.author : "" }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{ players.length }} <font-awesome-icon class="players" icon="users" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{ teams.alive }}
|
||||||
|
<font-awesome-icon class="alive" icon="heartbeat" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{ teams.votes }} <font-awesome-icon class="votes" icon="vote-yea" />
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="players.length - teams.traveler >= 5">
|
<li v-if="players.length - teams.traveler >= 5">
|
||||||
{{ teams.townsfolk }}
|
<span>
|
||||||
<font-awesome-icon class="townsfolk" icon="user-friends" />
|
{{ teams.townsfolk }}
|
||||||
{{ teams.outsider }}
|
<font-awesome-icon class="townsfolk" icon="user-friends" />
|
||||||
<font-awesome-icon
|
</span>
|
||||||
class="outsider"
|
<span>
|
||||||
v-bind:icon="teams.outsider > 1 ? 'user-friends' : 'user'"
|
{{ teams.outsider }}
|
||||||
/>
|
<font-awesome-icon
|
||||||
{{ teams.minion }}
|
class="outsider"
|
||||||
<font-awesome-icon
|
:icon="teams.outsider > 1 ? 'user-friends' : 'user'"
|
||||||
class="minion"
|
/>
|
||||||
v-bind:icon="teams.minion > 1 ? 'user-friends' : 'user'"
|
</span>
|
||||||
/>
|
<span>
|
||||||
{{ teams.demon }}
|
{{ teams.minion }}
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
class="demon"
|
class="minion"
|
||||||
v-bind:icon="teams.demon > 1 ? 'user-friends' : 'user'"
|
:icon="teams.minion > 1 ? 'user-friends' : 'user'"
|
||||||
/>
|
/>
|
||||||
<template v-if="teams.traveler">
|
</span>
|
||||||
|
<span>
|
||||||
|
{{ teams.demon }}
|
||||||
|
<font-awesome-icon
|
||||||
|
class="demon"
|
||||||
|
:icon="teams.demon > 1 ? 'user-friends' : 'user'"
|
||||||
|
/>
|
||||||
|
</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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 * 1000);
|
}, this.session.votingSpeed);
|
||||||
|
},
|
||||||
|
pause() {
|
||||||
|
if (this.voteTimer) {
|
||||||
|
clearInterval(this.voteTimer);
|
||||||
|
this.voteTimer = null;
|
||||||
|
} else {
|
||||||
|
this.voteTimer = setInterval(() => {
|
||||||
|
this.$store.commit("session/lockVote");
|
||||||
|
if (this.session.lockedVote > this.players.length) {
|
||||||
|
clearInterval(this.voteTimer);
|
||||||
|
this.$store.commit("session/setVoteInProgress", false);
|
||||||
|
}
|
||||||
|
}, this.session.votingSpeed);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
stop() {
|
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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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>
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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 => ({
|
||||||
|
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
|
|
@ -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>
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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) {
|
||||||
state.edition = edition;
|
if (editionJSONbyId.has(edition.id)) {
|
||||||
state.modals.edition = false;
|
state.edition = editionJSONbyId.get(edition.id);
|
||||||
if (edition !== "custom") {
|
state.roles = getRolesByEdition(state.edition);
|
||||||
state.roles = getRolesByEdition(edition);
|
} else {
|
||||||
|
state.edition = edition;
|
||||||
}
|
}
|
||||||
|
state.modals.edition = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [persistence, socket]
|
plugins: [persistence, socket]
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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":
|
||||||
|
|
|
@ -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,16 +227,23 @@ class LiveSession {
|
||||||
? { roleId: player.role.id }
|
? { roleId: player.role.id }
|
||||||
: {})
|
: {})
|
||||||
}));
|
}));
|
||||||
const { session } = this._store.state;
|
if (isLightweight) {
|
||||||
this.sendEdition();
|
this._send("gs", { gamestate: this._gamestate, isLightweight });
|
||||||
this.sendFabled();
|
} else {
|
||||||
this._send("gs", {
|
const { session, grimoire } = this._store.state;
|
||||||
gamestate: this._gamestate,
|
const { fabled } = this._store.state.players;
|
||||||
nomination: session.nomination,
|
this.sendEdition();
|
||||||
votingSpeed: session.votingSpeed,
|
this._send("gs", {
|
||||||
lockedVote: session.lockedVote,
|
gamestate: this._gamestate,
|
||||||
...(session.nomination ? { votes: session.votes } : {})
|
isNight: grimoire.isNight,
|
||||||
});
|
nomination: session.nomination,
|
||||||
|
votingSpeed: session.votingSpeed,
|
||||||
|
lockedVote: session.lockedVote,
|
||||||
|
isVoteInProgress: session.isVoteInProgress,
|
||||||
|
fabled: fabled.map(({ id }) => id),
|
||||||
|
...(session.nomination ? { votes: session.votes } : {})
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -223,13 +253,17 @@ class LiveSession {
|
||||||
*/
|
*/
|
||||||
_updateGamestate(data) {
|
_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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
|
||||||
;
|
|
||||||
|
|
Loading…
Reference in New Issue