Merge pull request #20 from bra1n/voting

Live voting
This commit is contained in:
Steffen 2020-06-05 20:21:09 +02:00 committed by GitHub
commit 8f09315659
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 739 additions and 143 deletions

View File

@ -10,13 +10,17 @@
: '' : ''
}" }"
> >
<transition name="zoom">
<Intro v-if="!players.length"></Intro> <Intro v-if="!players.length"></Intro>
<TownInfo v-if="players.length"></TownInfo> <TownInfo v-if="players.length && !session.nomination"></TownInfo>
<Vote v-if="session.nomination"></Vote>
</transition>
<TownSquare @screenshot="takeScreenshot"></TownSquare> <TownSquare @screenshot="takeScreenshot"></TownSquare>
<Menu ref="menu"></Menu> <Menu ref="menu"></Menu>
<EditionModal /> <EditionModal />
<RolesModal /> <RolesModal />
<ReferenceModal /> <ReferenceModal />
<Gradients />
</div> </div>
</template> </template>
@ -29,16 +33,20 @@ import RolesModal from "./components/modals/RolesModal";
import EditionModal from "./components/modals/EditionModal"; import EditionModal from "./components/modals/EditionModal";
import Intro from "./components/Intro"; import Intro from "./components/Intro";
import ReferenceModal from "./components/modals/ReferenceModal"; import ReferenceModal from "./components/modals/ReferenceModal";
import Vote from "./components/Vote";
import Gradients from "./components/Gradients";
export default { export default {
components: { components: {
Vote,
ReferenceModal, ReferenceModal,
Intro, Intro,
TownInfo, TownInfo,
TownSquare, TownSquare,
Menu, Menu,
EditionModal, EditionModal,
RolesModal RolesModal,
Gradients
}, },
computed: { computed: {
...mapState(["grimoire", "session"]), ...mapState(["grimoire", "session"]),
@ -157,6 +165,17 @@ ul {
justify-content: center; justify-content: center;
} }
.zoom-enter-active,
.zoom-leave-active {
transition: all 250ms;
filter: blur(0);
}
.zoom-enter,
.zoom-leave-to {
opacity: 0;
filter: blur(20px)
}
// Buttons // Buttons
.button-group { .button-group {
display: flex; display: flex;

BIN
src/assets/clock-big.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
src/assets/clock-small.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,43 @@
<template>
<!-- SVG Gradients -->
<div id="gradients">
<svg
width="0"
height="0"
v-for="(gradient, index) in gradients"
:key="index"
>
<linearGradient :id="gradient[0]" x1="50%" y1="100%" x2="50%" y2="0%">
<stop
offset="0%"
:style="{ 'stop-color': gradient[2], 'stop-opacity': 1 }"
></stop>
<stop
offset="100%"
:style="{ 'stop-color': gradient[1], 'stop-opacity': 1 }"
></stop>
</linearGradient>
</svg>
</div>
</template>
<script>
export default {
data() {
return {
gradients: [
["demon", "#ce0100", "#000"],
["townsfolk", "#1f65ff", "#000"],
["default", "#4E4E4E", "#000"]
]
};
}
};
</script>
<style lang="scss" scoped>
svg {
position: absolute;
z-index: -1;
}
</style>

View File

@ -32,11 +32,7 @@
v-if="!session.isSpectator" v-if="!session.isSpectator"
@click="tab = 'players'" @click="tab = 'players'"
/> />
<font-awesome-icon <font-awesome-icon icon="theater-masks" @click="tab = 'characters'" />
icon="theater-masks"
v-if="!session.isSpectator"
@click="tab = 'characters'"
/>
<font-awesome-icon icon="question" @click="tab = 'help'" /> <font-awesome-icon icon="question" @click="tab = 'help'" />
</li> </li>
@ -114,14 +110,17 @@
</li> </li>
</template> </template>
<template v-if="tab === 'characters' && !session.isSpectator"> <template v-if="tab === 'characters'">
<!-- Characters --> <!-- Characters -->
<li class="headline">Characters</li> <li class="headline">Characters</li>
<li @click="toggleModal('edition')"> <li v-if="!session.isSpectator" @click="toggleModal('edition')">
<em>[E]</em> <em>[E]</em>
Select Edition Select Edition
</li> </li>
<li @click="toggleModal('roles')" v-if="players.length > 4"> <li
@click="toggleModal('roles')"
v-if="!session.isSpectator && players.length > 4"
>
<em>[C]</em> <em>[C]</em>
Choose & Assign Choose & Assign
</li> </li>
@ -190,9 +189,9 @@ export default {
Math.round(Math.random() * 10000) Math.round(Math.random() * 10000)
); );
if (sessionId) { if (sessionId) {
this.$store.commit("setSpectator", false); this.$store.commit("session/setSpectator", false);
this.$store.commit( this.$store.commit(
"setSessionId", "session/setSessionId",
sessionId.replace(/[^0-9a-z]/g, "").substr(0, 5) sessionId.replace(/[^0-9a-z]/g, "").substr(0, 5)
); );
this.copySessionUrl(); this.copySessionUrl();
@ -215,17 +214,17 @@ export default {
"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("setSpectator", true); this.$store.commit("session/setSpectator", true);
this.$store.commit( this.$store.commit(
"setSessionId", "session/setSessionId",
sessionId.replace(/[^0-9a-z]/g, "").substr(0, 5) sessionId.replace(/[^0-9a-z]/g, "").substr(0, 5)
); );
} }
}, },
leaveSession() { leaveSession() {
if (confirm("Are you sure you want to leave the active live game?")) { if (confirm("Are you sure you want to leave the active live game?")) {
this.$store.commit("setSpectator", false); this.$store.commit("session/setSpectator", false);
this.$store.commit("setSessionId", ""); this.$store.commit("session/setSessionId", "");
} }
}, },
addPlayer() { addPlayer() {
@ -249,7 +248,6 @@ export default {
} }
}, },
clearRoles() { clearRoles() {
if (this.session.isSpectator) return;
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"); this.$store.commit("setBluff");

View File

@ -3,11 +3,16 @@
<div <div
ref="player" ref="player"
class="player" class="player"
:class="{ :class="[
{
dead: player.isDead, dead: player.isDead,
'no-vote': player.isVoteless, 'no-vote': player.isVoteless,
traveler: player.role && player.role.team === 'traveler' you: player.id === session.playerId,
}" 'vote-yes': session.votes[index],
'vote-lock': voteLocked
},
player.role.team
]"
> >
<div class="shroud" @click="toggleStatus()"></div> <div class="shroud" @click="toggleStatus()"></div>
<div class="life" @click="toggleStatus()"></div> <div class="life" @click="toggleStatus()"></div>
@ -31,8 +36,24 @@
}}</span> }}</span>
</div> </div>
<Token :role="player.role" @set-role="$emit('set-role')" /> <Token
:role="player.role"
@set-role="$emit('trigger', ['openRoleModal'])"
/>
<!-- Overlay icons -->
<font-awesome-icon
icon="skull"
class="vote"
title="Voted YES"
@click="vote()"
/>
<font-awesome-icon
icon="times"
class="vote"
title="Voted NO"
@click="vote()"
/>
<font-awesome-icon <font-awesome-icon
icon="times-circle" icon="times-circle"
class="cancel" class="cancel"
@ -51,10 +72,20 @@
@click="movePlayer(player)" @click="movePlayer(player)"
title="Move player to this seat" title="Move player to this seat"
/> />
<font-awesome-icon
icon="hand-point-right"
class="nominate"
@click="nominatePlayer(player)"
title="Nominate this player"
/>
<!-- Claimed seat icon -->
<font-awesome-icon icon="chair" v-if="player.id" class="seat" />
<!-- Ghost vote icon -->
<font-awesome-icon <font-awesome-icon
icon="vote-yea" icon="vote-yea"
class="vote" class="has-vote"
v-if="player.isDead && !player.isVoteless" v-if="player.isDead && !player.isVoteless"
@click="updatePlayer('isVoteless', true)" @click="updatePlayer('isVoteless', true)"
title="Ghost vote" title="Ghost vote"
@ -69,14 +100,15 @@
</div> </div>
<transition name="fold"> <transition name="fold">
<ul class="menu" v-if="isMenuOpen && !session.isSpectator"> <ul class="menu" v-if="isMenuOpen">
<template v-if="!session.isSpectator">
<li @click="changeName"> <li @click="changeName">
<font-awesome-icon icon="user-edit" />Rename <font-awesome-icon icon="user-edit" />Rename
</li> </li>
<!--<li @click="nomination"> <li v-if="!session.nomination" @click="nominatePlayer()">
<font-awesome-icon icon="hand-point-right" /> <font-awesome-icon icon="hand-point-right" />
Nomination Nomination
</li>--> </li>
<li @click="movePlayer()"> <li @click="movePlayer()">
<font-awesome-icon icon="redo-alt" /> <font-awesome-icon icon="redo-alt" />
Move player Move player
@ -89,10 +121,18 @@
<font-awesome-icon icon="camera" /> <font-awesome-icon icon="camera" />
Screenshot Screenshot
</li> </li>
<li @click="$emit('remove-player')"> <li @click="$emit('trigger', ['removePlayer'])">
<font-awesome-icon icon="times-circle" /> <font-awesome-icon icon="times-circle" />
Remove Remove
</li> </li>
</template>
<li @click="claimSeat" v-if="session.isSpectator">
<font-awesome-icon icon="chair" />
<template v-if="player.id !== session.playerId">
Claim seat
</template>
<template v-else> Vacate seat </template>
</li>
</ul> </ul>
</transition> </transition>
</div> </div>
@ -116,7 +156,7 @@
{{ reminder.name }} {{ reminder.name }}
</div> </div>
</template> </template>
<div class="reminder add" @click="$emit('add-reminder')"> <div class="reminder add" @click="$emit('trigger', ['openReminderModal'])">
<span class="icon"></span> <span class="icon"></span>
</div> </div>
</li> </li>
@ -137,8 +177,20 @@ export default {
} }
}, },
computed: { computed: {
...mapState("players", ["players"]),
...mapState(["grimoire", "session"]), ...mapState(["grimoire", "session"]),
...mapGetters({ nightOrder: "players/nightOrder" }) ...mapGetters({ nightOrder: "players/nightOrder" }),
index: function() {
return this.players.indexOf(this.player);
},
voteLocked: function() {
const session = this.session;
const players = this.players.length;
if (!session.nomination) return false;
const indexAdjusted =
(this.index - 1 + players - session.nomination[1]) % players;
return indexAdjusted < session.lockedVote - 1;
}
}, },
data() { data() {
return { return {
@ -193,14 +245,26 @@ export default {
}, },
swapPlayer(player) { swapPlayer(player) {
this.isMenuOpen = false; this.isMenuOpen = false;
this.$emit("swap-player", player); this.$emit("trigger", ["swapPlayer", player]);
}, },
movePlayer(player) { movePlayer(player) {
this.isMenuOpen = false; this.isMenuOpen = false;
this.$emit("move-player", player); this.$emit("trigger", ["movePlayer", player]);
},
nominatePlayer(player) {
this.isMenuOpen = false;
this.$emit("trigger", ["nominatePlayer", player]);
}, },
cancel() { cancel() {
this.$emit("cancel"); this.$emit("trigger", ["cancel"]);
},
claimSeat() {
this.isMenuOpen = false;
this.$emit("trigger", ["claimSeat"]);
},
vote() {
if (this.player.id !== this.session.playerId) return;
this.$store.commit("session/vote", [this.index]);
} }
} }
}; };
@ -367,28 +431,52 @@ export default {
cursor: pointer; cursor: pointer;
&.swap, &.swap,
&.move, &.move,
&.nominate,
&.vote,
&.cancel { &.cancel {
top: 9%; top: 9%;
left: 20%; left: 25%;
width: 60%; width: 50%;
height: 60%; height: 60%;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: all 250ms; transition: all 250ms;
transform: scale(0.2); transform: scale(0.2);
&:hover { * {
color: red; stroke-width: 10px;
stroke: white;
fill: url(#default);
}
&:hover *,
&.fa-skull * {
fill: url(#demon);
}
&.fa-times * {
fill: url(#townsfolk);
} }
} }
} }
li.from .player > svg.cancel { #townsquare.vote .player.vote-yes > svg.vote.fa-skull {
opacity: 0.5;
transform: scale(1);
}
#townsquare.vote .player.you.vote-yes > svg.vote.fa-skull,
#townsquare.vote .player.vote-lock.vote-yes > svg.vote.fa-skull,
#townsquare.vote .player.vote-lock:not(.vote-yes) > svg.vote.fa-times {
opacity: 1;
transform: scale(1);
}
li.from:not(.nominate) .player > svg.cancel {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
pointer-events: all; pointer-events: all;
} }
li.swap:not(.from) .player > svg.swap, li.swap:not(.from) .player > svg.swap,
li.nominate .player > svg.nominate,
li.move:not(.from) .player > svg.move { li.move:not(.from) .player > svg.move {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
@ -396,13 +484,12 @@ li.move:not(.from) .player > svg.move {
} }
/****** Vote icon ********/ /****** Vote icon ********/
.player .vote { .player .has-vote {
position: absolute; position: absolute;
right: 2px; right: 2px;
bottom: 45px; bottom: 45px;
color: #fff; color: #fff;
filter: drop-shadow(0 0 3px black); filter: drop-shadow(0 0 3px black);
cursor: pointer;
transition: opacity 250ms; transition: opacity 250ms;
#townsquare.public & { #townsquare.public & {
@ -411,6 +498,50 @@ li.move:not(.from) .player > svg.move {
} }
} }
@mixin glow($name, $color) {
@keyframes #{$name}-glow {
0% {
box-shadow: 0 0 rgba($color, 1);
border-color: $color;
}
50% {
border-color: black;
}
100% {
box-shadow: 0 0 20px 16px transparent;
border-color: $color;
}
}
.player.you.#{$name} .token {
animation: #{$name}-glow 2s ease-in-out infinite;
}
}
@include glow("townsfolk", $townsfolk);
@include glow("outsider", $outsider);
@include glow("demon", $demon);
@include glow("minion", $minion);
@include glow("traveler", $traveler);
.player.you .token {
animation: townsfolk-glow 2s ease-in-out infinite;
}
/****** Seat icon ********/
.player .seat {
position: absolute;
left: 2px;
bottom: 45px;
color: #fff;
filter: drop-shadow(0 0 3px black);
cursor: default;
}
.player.you .seat {
color: $townsfolk;
}
/***** Player name *****/ /***** Player name *****/
.player > .name { .player > .name {
font-size: 120%; font-size: 120%;

View File

@ -59,15 +59,13 @@ export default {
).length ).length
}; };
}, },
...mapState({ ...mapState(["edition"]),
edition: state => state.edition, ...mapState("players", ["players"])
players: state => state.players.players
})
} }
}; };
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
@import "../vars.scss"; @import "../vars.scss";
// Editions // Editions
@ -94,12 +92,8 @@ export default {
.info { .info {
position: absolute; position: absolute;
display: flex; display: flex;
left: 50%;
top: 50%;
width: 20%; width: 20%;
height: 20%; height: 20%;
margin-left: -10%;
margin-top: -5%;
padding: 50px 0 0; padding: 50px 0 0;
align-items: center; align-items: center;
align-content: center; align-content: center;

View File

@ -4,7 +4,8 @@
class="square" class="square"
v-bind:class="{ v-bind:class="{
public: grimoire.isPublic, public: grimoire.isPublic,
spectator: session.isSpectator spectator: session.isSpectator,
vote: session.nomination
}" }"
v-bind:style="{ zoom: grimoire.zoom }" v-bind:style="{ zoom: grimoire.zoom }"
> >
@ -13,13 +14,8 @@
v-for="(player, index) in players" v-for="(player, index) in players"
:key="index" :key="index"
:player="player" :player="player"
@add-reminder="openReminderModal(index)"
@set-role="openRoleModal(index)"
@remove-player="removePlayer(index)"
@cancel="cancel(index)"
@swap-player="swapPlayer(index, $event)"
@move-player="movePlayer(index, $event)"
@screenshot="$emit('screenshot', $event)" @screenshot="$emit('screenshot', $event)"
@trigger="handleTrigger(index, $event)"
v-bind:class="{ v-bind:class="{
from: Math.max(swap, move, nominate) === index, from: Math.max(swap, move, nominate) === index,
swap: swap > -1, swap: swap > -1,
@ -80,6 +76,19 @@ export default {
const { width, height, x, y } = this.$refs.bluffs.getBoundingClientRect(); const { width, height, x, y } = this.$refs.bluffs.getBoundingClientRect();
this.$emit("screenshot", { width, height, x, y }); this.$emit("screenshot", { width, height, x, y });
}, },
handleTrigger(playerIndex, [method, params]) {
if (typeof this[method] === "function") {
this[method](playerIndex, params);
}
},
claimSeat(playerIndex) {
if (!this.session.isSpectator) return;
if (this.session.playerId === this.players[playerIndex].id) {
this.$store.commit("session/claimSeat", -1);
} else {
this.$store.commit("session/claimSeat", playerIndex);
}
},
openReminderModal(playerIndex) { openReminderModal(playerIndex) {
this.selectedPlayer = playerIndex; this.selectedPlayer = playerIndex;
this.$store.commit("toggleModal", "reminder"); this.$store.commit("toggleModal", "reminder");
@ -125,6 +134,20 @@ export default {
this.cancel(); this.cancel();
} }
}, },
nominatePlayer(from, to) {
if (to === undefined && from !== this.nominate) {
this.cancel();
if (from !== this.nominate) {
this.nominate = from;
}
} else {
this.$store.commit("session/nomination", [
this.nominate,
this.players.indexOf(to)
]);
this.cancel();
}
},
cancel() { cancel() {
this.move = -1; this.move = -1;
this.swap = -1; this.swap = -1;
@ -221,6 +244,10 @@ export default {
height: 100%; height: 100%;
border-radius: 50%; border-radius: 50%;
padding: 20px; padding: 20px;
display: flex;
align-items: center;
align-content: center;
justify-content: center;
} }
/***** Demon bluffs *******/ /***** Demon bluffs *******/

225
src/components/Vote.vue Normal file
View File

@ -0,0 +1,225 @@
<template>
<div id="vote">
<div class="arrows">
<span class="nominee" :style="nomineeStyle"></span>
<span class="nominator" :style="nominatorStyle"></span>
</div>
<div class="overlay">
<em class="blue">{{ nominator.name }}</em> nominated
<em>{{ nominee.name }}</em
>!
<br />
<template v-if="nominee.role.team !== 'traveler'">
<em class="blue">{{ Math.ceil(alive / 2) }} votes</em> required to
<em>execute</em>.
</template>
<template v-else>
<em>{{ Math.ceil(players.length / 2) }} votes</em> required to
<em>exile</em>.
</template>
<div class="button-group" v-if="!session.isSpectator">
<div class="button" v-if="!session.lockedVote" @click="start">
Start Vote
</div>
<div class="button" v-else @click="stop">
Reset Vote
</div>
<div class="button" @click="finish">Finish</div>
</div>
<div class="button-group" v-else-if="canVote">
<div class="button vote-no" @click="vote(false)">Vote NO</div>
<div class="button vote-yes" @click="vote(true)">Vote YES</div>
</div>
<div v-else-if="!player">
Please claim a seat to vote.
</div>
</div>
</div>
</template>
<script>
import { mapGetters, mapState } from "vuex";
export default {
computed: {
...mapState("players", ["players"]),
...mapState(["session"]),
...mapGetters({ alive: "players/alive" }),
nominator: function() {
return this.players[this.session.nomination[0]];
},
nominatorStyle: function() {
const players = this.players.length;
const nomination = this.session.nomination[0];
return {
transform: `rotate(${Math.round((nomination / players) * 360)}deg)`
};
},
nominee: function() {
return this.players[this.session.nomination[1]];
},
nomineeStyle: function() {
const players = this.players.length;
const nomination = this.session.nomination[1];
const lock = this.session.lockedVote;
const rotation = (360 * (nomination + Math.min(lock, players))) / players;
return {
transform: `rotate(${Math.round(rotation)}deg)`
};
},
player: function() {
return this.players.find(p => p.id === this.session.playerId);
},
canVote: function() {
if (!this.player) return false;
if (this.player.isVoteless && this.nominee.role.team !== "traveler")
return false;
const session = this.session;
const players = this.players.length;
const index = this.players.indexOf(this.player);
const indexAdjusted =
(index - 1 + players - session.nomination[1]) % players;
return indexAdjusted >= session.lockedVote - 1;
}
},
methods: {
start() {
this.$store.commit("session/lockVote");
this.voteTimer = setInterval(() => {
this.$store.commit("session/lockVote");
if (this.session.lockedVote > this.players.length) {
clearInterval(this.voteTimer);
}
}, 3000);
},
stop() {
this.$store.commit("session/lockVote", 0);
clearInterval(this.voteTimer);
},
finish() {
this.$store.commit("session/nomination", false);
},
vote(vote) {
if (!this.canVote) return false;
const index = this.players.findIndex(p => p.id === this.session.playerId);
if (index >= 0 && !!this.session.votes[index] !== vote) {
this.$store.commit("session/vote", [index, vote]);
}
}
}
};
</script>
<style lang="scss" scoped>
@import "../vars.scss";
#vote {
position: absolute;
width: 20%;
z-index: 20;
display: flex;
align-items: center;
align-content: center;
justify-content: center;
background: url("../assets/demon-head.png") center center no-repeat;
background-size: auto 75%;
text-align: center;
text-shadow: 0 1px 2px #000000, 0 -1px 2px #000000, 1px 0 2px #000000,
-1px 0 2px #000000;
&:after {
content: " ";
padding-bottom: 100%;
display: block;
}
em {
color: $demon;
font-style: normal;
font-weight: bold;
&.blue {
color: $townsfolk;
}
}
}
@keyframes arrow-cw {
0% {
opacity: 0;
transform: rotate(-180deg);
}
100% {
opacity: 1;
transform: rotate(0deg);
}
}
@keyframes arrow-ccw {
0% {
opacity: 0;
transform: rotate(180deg);
}
100% {
opacity: 1;
transform: rotate(0deg);
}
}
.arrows {
position: absolute;
display: flex;
height: 150%;
width: 20%;
span {
position: absolute;
width: 100%;
height: 100%;
transition: transform 2.9s ease-in-out;
}
span:before {
content: " ";
width: 100%;
height: 100%;
display: block;
background-size: auto 100%;
background-repeat: no-repeat;
background-position: center center;
position: absolute;
filter: drop-shadow(0px 0px 3px #000);
}
.nominator:before {
background-image: url("../assets/clock-small.png");
animation: arrow-ccw 1s ease-out;
}
.nominee:before {
background-image: url("../assets/clock-big.png");
animation: arrow-cw 1s ease-out;
}
}
.button.vote-no {
background: radial-gradient(
at 0 -15%,
rgba(255, 255, 255, 0.07) 70%,
rgba(255, 255, 255, 0) 71%
)
0 0/80% 90% no-repeat content-box,
linear-gradient(#0031ad, rgba(5, 0, 0, 0.22)) content-box,
linear-gradient(#292929, #001142) border-box;
box-shadow: inset 0 1px 1px #002c9c, 0 0 10px #000;
&:hover {
color: #008cf7;
}
}
.button.vote-yes {
background: radial-gradient(
at 0 -15%,
rgba(255, 255, 255, 0.07) 70%,
rgba(255, 255, 255, 0) 71%
)
0 0/80% 90% no-repeat content-box,
linear-gradient(#ad0000, rgba(5, 0, 0, 0.22)) content-box,
linear-gradient(#292929, #420000) border-box;
box-shadow: inset 0 1px 1px #9c0000, 0 0 10px #000;
}
</style>

View File

@ -10,6 +10,7 @@ const faIcons = [
"BookOpen", "BookOpen",
"BroadcastTower", "BroadcastTower",
"Camera", "Camera",
"Chair",
"CheckSquare", "CheckSquare",
"Cog", "Cog",
"Copy", "Copy",
@ -26,8 +27,10 @@ const faIcons = [
"RedoAlt", "RedoAlt",
"SearchMinus", "SearchMinus",
"SearchPlus", "SearchPlus",
"Skull",
"Square", "Square",
"TheaterMasks", "TheaterMasks",
"Times",
"TimesCircle", "TimesCircle",
"TrashAlt", "TrashAlt",
"Undo", "Undo",

View File

@ -1,8 +1,9 @@
import Vue from "vue"; import Vue from "vue";
import Vuex from "vuex"; import Vuex from "vuex";
import persistence from "./persistence"; import persistence from "./persistence";
import session from "./session"; import socket from "./socket";
import players from "./modules/players"; import players from "./modules/players";
import session from "./modules/session";
import editionJSON from "../editions.json"; import editionJSON from "../editions.json";
import rolesJSON from "../roles.json"; import rolesJSON from "../roles.json";
@ -23,7 +24,8 @@ const getRolesByEdition = (edition = "tb") => {
export default new Vuex.Store({ export default new Vuex.Store({
modules: { modules: {
players players,
session
}, },
state: { state: {
grimoire: { grimoire: {
@ -36,12 +38,6 @@ export default new Vuex.Store({
background: "", background: "",
bluffs: [] bluffs: []
}, },
session: {
sessionId: "",
isSpectator: false,
playerCount: 0,
playerId: ""
},
modals: { modals: {
reference: false, reference: false,
edition: false, edition: false,
@ -75,18 +71,6 @@ export default new Vuex.Store({
setBackground({ grimoire }, background) { setBackground({ grimoire }, background) {
grimoire.background = background; grimoire.background = background;
}, },
setSessionId({ session }, sessionId) {
session.sessionId = sessionId;
},
setPlayerId({ session }, playerId) {
session.playerId = playerId;
},
setSpectator({ session }, spectator) {
session.isSpectator = spectator;
},
setPlayerCount({ session }, playerCount) {
session.playerCount = playerCount;
},
setBluff({ grimoire }, { index, role } = {}) { setBluff({ grimoire }, { index, role } = {}) {
if (index !== undefined) { if (index !== undefined) {
grimoire.bluffs.splice(index, 1, role); grimoire.bluffs.splice(index, 1, role);
@ -128,5 +112,5 @@ export default new Vuex.Store({
} }
} }
}, },
plugins: [persistence, session] plugins: [persistence, socket]
}); });

View File

@ -1,4 +1,6 @@
const NEWPLAYER = { const NEWPLAYER = {
name: "",
id: "",
role: {}, role: {},
reminders: [], reminders: [],
isVoteless: false, isVoteless: false,
@ -10,6 +12,9 @@ const state = () => ({
}); });
const getters = { const getters = {
alive({ players }) {
return players.filter(player => !player.isDead).length;
},
nonTravelers({ players }) { nonTravelers({ players }) {
const nonTravelers = players.filter( const nonTravelers = players.filter(
player => player.role.team !== "traveler" player => player.role.team !== "traveler"
@ -48,11 +53,23 @@ const actions = {
.map(a => a[1]); .map(a => a[1]);
commit("set", players); commit("set", players);
}, },
clearRoles({ state, commit }) { clearRoles({ state, commit, rootState }) {
const players = state.players.map(({ name }) => ({ let players;
if (rootState.session.isSpectator) {
players = state.players.map(player => {
if (player.role.team !== "traveler") {
player.role = {};
}
player.reminders = [];
return player;
});
} else {
players = state.players.map(({ name, id }) => ({
...NEWPLAYER,
name, name,
...NEWPLAYER id
})); }));
}
commit("set", players); commit("set", players);
} }
}; };
@ -72,8 +89,8 @@ const mutations = {
}, },
add(state, name) { add(state, name) {
state.players.push({ state.players.push({
name, ...NEWPLAYER,
...NEWPLAYER name
}); });
}, },
remove(state, index) { remove(state, index) {

View File

@ -0,0 +1,53 @@
const state = () => ({
sessionId: "",
isSpectator: false,
playerCount: 0,
playerId: "",
claimedSeat: -1,
nomination: false,
votes: [],
lockedVote: 0
});
const getters = {};
const actions = {};
const mutations = {
setSessionId(state, sessionId) {
state.sessionId = sessionId;
},
setPlayerId(state, playerId) {
state.playerId = playerId;
},
setSpectator(state, spectator) {
state.isSpectator = spectator;
},
setPlayerCount(state, playerCount) {
state.playerCount = playerCount;
},
claimSeat(state, claimedSeat) {
state.claimedSeat = claimedSeat;
},
nomination(state, nomination) {
state.nomination = nomination;
state.votes = [];
state.lockedVote = 0;
},
vote(state, [index, vote]) {
if (!state.nomination) return;
state.votes = [...state.votes];
state.votes[index] = vote === undefined ? !state.votes[index] : vote;
},
lockVote(state, lock) {
state.lockedVote = lock !== undefined ? lock : state.lockedVote + 1;
}
};
export default {
namespaced: true,
state,
getters,
actions,
mutations
};

View File

@ -32,12 +32,12 @@ module.exports = store => {
} }
/**** Session related data *****/ /**** Session related data *****/
if (localStorage.getItem("playerId")) { if (localStorage.getItem("playerId")) {
store.commit("setPlayerId", localStorage.getItem("playerId")); store.commit("session/setPlayerId", localStorage.getItem("playerId"));
} }
if (localStorage.getItem("session")) { if (localStorage.getItem("session")) {
const [spectator, sessionId] = JSON.parse(localStorage.getItem("session")); const [spectator, sessionId] = JSON.parse(localStorage.getItem("session"));
store.commit("setSpectator", spectator); store.commit("session/setSpectator", spectator);
store.commit("setSessionId", sessionId); store.commit("session/setSessionId", sessionId);
} }
// listen to mutations // listen to mutations
@ -99,7 +99,7 @@ module.exports = store => {
localStorage.removeItem("players"); localStorage.removeItem("players");
} }
break; break;
case "setSessionId": case "session/setSessionId":
if (payload) { if (payload) {
localStorage.setItem( localStorage.setItem(
"session", "session",
@ -109,11 +109,11 @@ module.exports = store => {
localStorage.removeItem("session"); localStorage.removeItem("session");
} }
break; break;
case "setPlayerId": case "session/setPlayerId":
if (payload) { if (payload) {
localStorage.setItem("playerId", payload); localStorage.setItem("playerId", payload);
} else { } else {
localStorage.removeItem("setPlayerId"); localStorage.removeItem("playerId");
} }
break; break;
} }

View File

@ -27,7 +27,7 @@ class LiveSession {
this._socket.onopen = this._onOpen.bind(this); this._socket.onopen = this._onOpen.bind(this);
this._socket.onclose = () => { this._socket.onclose = () => {
this._socket = null; this._socket = null;
this._store.commit("setSessionId", ""); this._store.commit("session/setSessionId", "");
clearInterval(this._pingTimer); clearInterval(this._pingTimer);
this._pingTimer = null; this._pingTimer = null;
}; };
@ -93,9 +93,21 @@ class LiveSession {
case "player": case "player":
this._updatePlayer(params); this._updatePlayer(params);
break; break;
case "claim":
this._updateSeat(params);
break;
case "ping": case "ping":
this._handlePing(params); this._handlePing(params);
break; break;
case "nomination":
this._store.commit("session/nomination", params);
break;
case "vote":
this._store.commit("session/vote", params);
break;
case "lock":
this._store.commit("session/lockVote", params);
break;
case "bye": case "bye":
this._handleBye(params); this._handleBye(params);
break; break;
@ -110,13 +122,13 @@ class LiveSession {
connect(channel) { connect(channel) {
if (!this._store.state.session.playerId) { if (!this._store.state.session.playerId) {
this._store.commit( this._store.commit(
"setPlayerId", "session/setPlayerId",
Math.random() Math.random()
.toString(36) .toString(36)
.substr(2) .substr(2)
); );
} }
this._store.commit("setPlayerCount", 0); this._store.commit("session/setPlayerCount", 0);
this._isSpectator = this._store.state.session.isSpectator; this._isSpectator = this._store.state.session.isSpectator;
this._open(channel); this._open(channel);
} }
@ -125,7 +137,7 @@ class LiveSession {
* Close the current session, if any. * Close the current session, if any.
*/ */
disconnect() { disconnect() {
this._store.commit("setPlayerCount", 0); this._store.commit("session/setPlayerCount", 0);
if (this._socket) { if (this._socket) {
this._send("bye", this._store.state.session.playerId); this._send("bye", this._store.state.session.playerId);
this._socket.close(); this._socket.close();
@ -140,6 +152,7 @@ class LiveSession {
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,
id: player.id,
isDead: player.isDead, isDead: player.isDead,
isVoteless: player.isVoteless, isVoteless: player.isVoteless,
...(player.role && player.role.team === "traveler" ...(player.role && player.role.team === "traveler"
@ -154,7 +167,8 @@ class LiveSession {
})); }));
this._send("gs", { this._send("gs", {
gamestate: this._gamestate, gamestate: this._gamestate,
edition: this._store.state.edition edition: this._store.state.edition,
nomination: this._store.state.session.nomination
}); });
} }
@ -164,9 +178,10 @@ class LiveSession {
* @param edition * @param edition
* @private * @private
*/ */
_updateGamestate({ gamestate, edition }) { _updateGamestate({ gamestate, edition, nomination }) {
if (!this._isSpectator) return; if (!this._isSpectator) return;
this._store.commit("setEdition", edition); this._store.commit("setEdition", edition);
this._store.commit("session/nomination", nomination);
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) {
@ -181,28 +196,15 @@ class LiveSession {
// update status for each player // update status for each player
gamestate.forEach((state, x) => { gamestate.forEach((state, x) => {
const player = players[x]; const player = players[x];
const { name, isDead, isVoteless, role } = state; const { role } = state;
if (player.name !== name) { // update relevant properties
this._store.commit("players/update", { ["name", "id", "isDead", "isVoteless"].forEach(property => {
player, const value = state[property];
property: "name", if (player[property] !== value) {
value: name this._store.commit("players/update", { player, property, value });
});
} }
if (player.isDead !== isDead) {
this._store.commit("players/update", {
player,
property: "isDead",
value: isDead
}); });
} // roles are special, because of travelers
if (player.isVoteless !== isVoteless) {
this._store.commit("players/update", {
player,
property: "isVoteless",
value: isVoteless
});
}
if (role && player.role.id !== role.id) { if (role && player.role.id !== role.id) {
this._store.commit("players/update", { this._store.commit("players/update", {
player, player,
@ -298,7 +300,20 @@ class LiveSession {
delete this._players[player]; delete this._players[player];
} }
} }
this._store.commit("setPlayerCount", Object.keys(this._players).length); // remove claimed seats from players that are no longer connected
this._store.state.players.players.forEach(player => {
if (!this._isSpectator && player.id && !this._players[player.id]) {
this._store.commit("players/update", {
player,
property: "id",
value: ""
});
}
});
this._store.commit(
"session/setPlayerCount",
Object.keys(this._players).length
);
} }
/** /**
@ -308,7 +323,82 @@ class LiveSession {
*/ */
_handleBye(playerId) { _handleBye(playerId) {
delete this._players[playerId]; delete this._players[playerId];
this._store.commit("setPlayerCount", Object.keys(this._players).length); this._store.commit(
"session/setPlayerCount",
Object.keys(this._players).length
);
}
/**
* Claim a seat, needs to be confirmed by the Storyteller.
* @param seat either -1 or the index of the seat claimed
*/
claimSeat(seat) {
if (!this._isSpectator) return;
if (this._store.state.players.players.length > seat) {
this._send("claim", [seat, this._store.state.session.playerId]);
}
}
/**
* Update a player id associated with that seat.
* @param index seat index or -1
* @param value playerId to add / remove
* @private
*/
_updateSeat([index, value]) {
if (this._isSpectator) return;
const property = "id";
const players = this._store.state.players.players;
// remove previous seat
const oldIndex = players.findIndex(({ id }) => id === value);
if (oldIndex >= 0 && oldIndex !== index) {
this._store.commit("players/update", {
player: players[oldIndex],
property,
value: ""
});
}
// add playerId to new seat
if (index >= 0) {
const player = players[index];
if (!player) return;
this._store.commit("players/update", { player, property, value });
}
// update player session list as if this was a ping
this._handlePing([true, value]);
}
/**
* A player nomination. ST only
* @param nomination [nominator, nominee]
*/
nomination(nomination) {
if (this._isSpectator) return;
const players = this._store.state.players.players;
if (
!nomination ||
(players.length > nomination[0] && players.length > nomination[1])
) {
this._send("nomination", nomination);
}
}
/**
* Send a vote. Player only
* @param index
*/
vote([index]) {
if (!this._isSpectator) return;
this._send("vote", [index, this._store.state.session.votes[index]]);
}
/**
* Lock a vote. ST only
*/
lockVote() {
if (this._isSpectator) return;
this._send("lock", this._store.state.session.lockedVote);
} }
} }
@ -319,7 +409,7 @@ module.exports = store => {
// listen to mutations // listen to mutations
store.subscribe(({ type, payload }) => { store.subscribe(({ type, payload }) => {
switch (type) { switch (type) {
case "setSessionId": case "session/setSessionId":
if (payload) { if (payload) {
session.connect(payload); session.connect(payload);
} else { } else {
@ -327,6 +417,18 @@ module.exports = store => {
session.disconnect(); session.disconnect();
} }
break; break;
case "session/claimSeat":
session.claimSeat(payload);
break;
case "session/nomination":
session.nomination(payload);
break;
case "session/vote":
session.vote(payload);
break;
case "session/lockVote":
session.lockVote();
break;
case "players/set": case "players/set":
case "players/swap": case "players/swap":
case "players/move": case "players/move":
@ -345,7 +447,7 @@ module.exports = store => {
// check for session Id in hash // check for session Id in hash
const [command, param] = window.location.hash.substr(1).split("/"); const [command, param] = window.location.hash.substr(1).split("/");
if (command === "play") { if (command === "play") {
store.commit("setSpectator", true); store.commit("session/setSpectator", true);
store.commit("setSessionId", param); store.commit("session/setSessionId", param);
} }
}; };