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 @@
: ''
}"
>
<Intro v-if="!players.length"></Intro>
<TownInfo v-if="players.length"></TownInfo>
<transition name="zoom">
<Intro v-if="!players.length"></Intro>
<TownInfo v-if="players.length && !session.nomination"></TownInfo>
<Vote v-if="session.nomination"></Vote>
</transition>
<TownSquare @screenshot="takeScreenshot"></TownSquare>
<Menu ref="menu"></Menu>
<EditionModal />
<RolesModal />
<ReferenceModal />
<Gradients />
</div>
</template>
@ -29,16 +33,20 @@ import RolesModal from "./components/modals/RolesModal";
import EditionModal from "./components/modals/EditionModal";
import Intro from "./components/Intro";
import ReferenceModal from "./components/modals/ReferenceModal";
import Vote from "./components/Vote";
import Gradients from "./components/Gradients";
export default {
components: {
Vote,
ReferenceModal,
Intro,
TownInfo,
TownSquare,
Menu,
EditionModal,
RolesModal
RolesModal,
Gradients
},
computed: {
...mapState(["grimoire", "session"]),
@ -157,6 +165,17 @@ ul {
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
.button-group {
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"
@click="tab = 'players'"
/>
<font-awesome-icon
icon="theater-masks"
v-if="!session.isSpectator"
@click="tab = 'characters'"
/>
<font-awesome-icon icon="theater-masks" @click="tab = 'characters'" />
<font-awesome-icon icon="question" @click="tab = 'help'" />
</li>
@ -114,14 +110,17 @@
</li>
</template>
<template v-if="tab === 'characters' && !session.isSpectator">
<template v-if="tab === 'characters'">
<!-- Characters -->
<li class="headline">Characters</li>
<li @click="toggleModal('edition')">
<li v-if="!session.isSpectator" @click="toggleModal('edition')">
<em>[E]</em>
Select Edition
</li>
<li @click="toggleModal('roles')" v-if="players.length > 4">
<li
@click="toggleModal('roles')"
v-if="!session.isSpectator && players.length > 4"
>
<em>[C]</em>
Choose & Assign
</li>
@ -190,9 +189,9 @@ export default {
Math.round(Math.random() * 10000)
);
if (sessionId) {
this.$store.commit("setSpectator", false);
this.$store.commit("session/setSpectator", false);
this.$store.commit(
"setSessionId",
"session/setSessionId",
sessionId.replace(/[^0-9a-z]/g, "").substr(0, 5)
);
this.copySessionUrl();
@ -215,17 +214,17 @@ export default {
"Enter the channel number / name of the session you want to join"
);
if (sessionId) {
this.$store.commit("setSpectator", true);
this.$store.commit("session/setSpectator", true);
this.$store.commit(
"setSessionId",
"session/setSessionId",
sessionId.replace(/[^0-9a-z]/g, "").substr(0, 5)
);
}
},
leaveSession() {
if (confirm("Are you sure you want to leave the active live game?")) {
this.$store.commit("setSpectator", false);
this.$store.commit("setSessionId", "");
this.$store.commit("session/setSpectator", false);
this.$store.commit("session/setSessionId", "");
}
},
addPlayer() {
@ -249,7 +248,6 @@ export default {
}
},
clearRoles() {
if (this.session.isSpectator) return;
if (confirm("Are you sure you want to remove all player roles?")) {
this.$store.dispatch("players/clearRoles");
this.$store.commit("setBluff");

View File

@ -3,11 +3,16 @@
<div
ref="player"
class="player"
:class="{
dead: player.isDead,
'no-vote': player.isVoteless,
traveler: player.role && player.role.team === 'traveler'
}"
:class="[
{
dead: player.isDead,
'no-vote': player.isVoteless,
you: player.id === session.playerId,
'vote-yes': session.votes[index],
'vote-lock': voteLocked
},
player.role.team
]"
>
<div class="shroud" @click="toggleStatus()"></div>
<div class="life" @click="toggleStatus()"></div>
@ -31,8 +36,24 @@
}}</span>
</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
icon="times-circle"
class="cancel"
@ -51,10 +72,20 @@
@click="movePlayer(player)"
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
icon="vote-yea"
class="vote"
class="has-vote"
v-if="player.isDead && !player.isVoteless"
@click="updatePlayer('isVoteless', true)"
title="Ghost vote"
@ -69,29 +100,38 @@
</div>
<transition name="fold">
<ul class="menu" v-if="isMenuOpen && !session.isSpectator">
<li @click="changeName">
<font-awesome-icon icon="user-edit" />Rename
</li>
<!--<li @click="nomination">
<font-awesome-icon icon="hand-point-right" />
Nomination
</li>-->
<li @click="movePlayer()">
<font-awesome-icon icon="redo-alt" />
Move player
</li>
<li @click="swapPlayer()">
<font-awesome-icon icon="exchange-alt" />
Swap seats
</li>
<li @click="takeScreenshot">
<font-awesome-icon icon="camera" />
Screenshot
</li>
<li @click="$emit('remove-player')">
<font-awesome-icon icon="times-circle" />
Remove
<ul class="menu" v-if="isMenuOpen">
<template v-if="!session.isSpectator">
<li @click="changeName">
<font-awesome-icon icon="user-edit" />Rename
</li>
<li v-if="!session.nomination" @click="nominatePlayer()">
<font-awesome-icon icon="hand-point-right" />
Nomination
</li>
<li @click="movePlayer()">
<font-awesome-icon icon="redo-alt" />
Move player
</li>
<li @click="swapPlayer()">
<font-awesome-icon icon="exchange-alt" />
Swap seats
</li>
<li @click="takeScreenshot">
<font-awesome-icon icon="camera" />
Screenshot
</li>
<li @click="$emit('trigger', ['removePlayer'])">
<font-awesome-icon icon="times-circle" />
Remove
</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>
</transition>
@ -116,7 +156,7 @@
{{ reminder.name }}
</div>
</template>
<div class="reminder add" @click="$emit('add-reminder')">
<div class="reminder add" @click="$emit('trigger', ['openReminderModal'])">
<span class="icon"></span>
</div>
</li>
@ -137,8 +177,20 @@ export default {
}
},
computed: {
...mapState("players", ["players"]),
...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() {
return {
@ -193,14 +245,26 @@ export default {
},
swapPlayer(player) {
this.isMenuOpen = false;
this.$emit("swap-player", player);
this.$emit("trigger", ["swapPlayer", player]);
},
movePlayer(player) {
this.isMenuOpen = false;
this.$emit("move-player", player);
this.$emit("trigger", ["movePlayer", player]);
},
nominatePlayer(player) {
this.isMenuOpen = false;
this.$emit("trigger", ["nominatePlayer", player]);
},
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;
&.swap,
&.move,
&.nominate,
&.vote,
&.cancel {
top: 9%;
left: 20%;
width: 60%;
left: 25%;
width: 50%;
height: 60%;
opacity: 0;
pointer-events: none;
transition: all 250ms;
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;
transform: scale(1);
pointer-events: all;
}
li.swap:not(.from) .player > svg.swap,
li.nominate .player > svg.nominate,
li.move:not(.from) .player > svg.move {
opacity: 1;
transform: scale(1);
@ -396,13 +484,12 @@ li.move:not(.from) .player > svg.move {
}
/****** Vote icon ********/
.player .vote {
.player .has-vote {
position: absolute;
right: 2px;
bottom: 45px;
color: #fff;
filter: drop-shadow(0 0 3px black);
cursor: pointer;
transition: opacity 250ms;
#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 {
font-size: 120%;

View File

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

View File

@ -4,7 +4,8 @@
class="square"
v-bind:class="{
public: grimoire.isPublic,
spectator: session.isSpectator
spectator: session.isSpectator,
vote: session.nomination
}"
v-bind:style="{ zoom: grimoire.zoom }"
>
@ -13,13 +14,8 @@
v-for="(player, index) in players"
:key="index"
: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)"
@trigger="handleTrigger(index, $event)"
v-bind:class="{
from: Math.max(swap, move, nominate) === index,
swap: swap > -1,
@ -80,6 +76,19 @@ export default {
const { width, height, x, y } = this.$refs.bluffs.getBoundingClientRect();
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) {
this.selectedPlayer = playerIndex;
this.$store.commit("toggleModal", "reminder");
@ -125,6 +134,20 @@ export default {
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() {
this.move = -1;
this.swap = -1;
@ -221,6 +244,10 @@ export default {
height: 100%;
border-radius: 50%;
padding: 20px;
display: flex;
align-items: center;
align-content: center;
justify-content: center;
}
/***** 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",
"BroadcastTower",
"Camera",
"Chair",
"CheckSquare",
"Cog",
"Copy",
@ -26,8 +27,10 @@ const faIcons = [
"RedoAlt",
"SearchMinus",
"SearchPlus",
"Skull",
"Square",
"TheaterMasks",
"Times",
"TimesCircle",
"TrashAlt",
"Undo",

View File

@ -1,8 +1,9 @@
import Vue from "vue";
import Vuex from "vuex";
import persistence from "./persistence";
import session from "./session";
import socket from "./socket";
import players from "./modules/players";
import session from "./modules/session";
import editionJSON from "../editions.json";
import rolesJSON from "../roles.json";
@ -23,7 +24,8 @@ const getRolesByEdition = (edition = "tb") => {
export default new Vuex.Store({
modules: {
players
players,
session
},
state: {
grimoire: {
@ -36,12 +38,6 @@ export default new Vuex.Store({
background: "",
bluffs: []
},
session: {
sessionId: "",
isSpectator: false,
playerCount: 0,
playerId: ""
},
modals: {
reference: false,
edition: false,
@ -75,18 +71,6 @@ export default new Vuex.Store({
setBackground({ grimoire }, 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 } = {}) {
if (index !== undefined) {
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 = {
name: "",
id: "",
role: {},
reminders: [],
isVoteless: false,
@ -10,6 +12,9 @@ const state = () => ({
});
const getters = {
alive({ players }) {
return players.filter(player => !player.isDead).length;
},
nonTravelers({ players }) {
const nonTravelers = players.filter(
player => player.role.team !== "traveler"
@ -48,11 +53,23 @@ const actions = {
.map(a => a[1]);
commit("set", players);
},
clearRoles({ state, commit }) {
const players = state.players.map(({ name }) => ({
name,
...NEWPLAYER
}));
clearRoles({ state, commit, rootState }) {
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,
id
}));
}
commit("set", players);
}
};
@ -72,8 +89,8 @@ const mutations = {
},
add(state, name) {
state.players.push({
name,
...NEWPLAYER
...NEWPLAYER,
name
});
},
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 *****/
if (localStorage.getItem("playerId")) {
store.commit("setPlayerId", localStorage.getItem("playerId"));
store.commit("session/setPlayerId", localStorage.getItem("playerId"));
}
if (localStorage.getItem("session")) {
const [spectator, sessionId] = JSON.parse(localStorage.getItem("session"));
store.commit("setSpectator", spectator);
store.commit("setSessionId", sessionId);
store.commit("session/setSpectator", spectator);
store.commit("session/setSessionId", sessionId);
}
// listen to mutations
@ -99,7 +99,7 @@ module.exports = store => {
localStorage.removeItem("players");
}
break;
case "setSessionId":
case "session/setSessionId":
if (payload) {
localStorage.setItem(
"session",
@ -109,11 +109,11 @@ module.exports = store => {
localStorage.removeItem("session");
}
break;
case "setPlayerId":
case "session/setPlayerId":
if (payload) {
localStorage.setItem("playerId", payload);
} else {
localStorage.removeItem("setPlayerId");
localStorage.removeItem("playerId");
}
break;
}

View File

@ -27,7 +27,7 @@ class LiveSession {
this._socket.onopen = this._onOpen.bind(this);
this._socket.onclose = () => {
this._socket = null;
this._store.commit("setSessionId", "");
this._store.commit("session/setSessionId", "");
clearInterval(this._pingTimer);
this._pingTimer = null;
};
@ -93,9 +93,21 @@ class LiveSession {
case "player":
this._updatePlayer(params);
break;
case "claim":
this._updateSeat(params);
break;
case "ping":
this._handlePing(params);
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":
this._handleBye(params);
break;
@ -110,13 +122,13 @@ class LiveSession {
connect(channel) {
if (!this._store.state.session.playerId) {
this._store.commit(
"setPlayerId",
"session/setPlayerId",
Math.random()
.toString(36)
.substr(2)
);
}
this._store.commit("setPlayerCount", 0);
this._store.commit("session/setPlayerCount", 0);
this._isSpectator = this._store.state.session.isSpectator;
this._open(channel);
}
@ -125,7 +137,7 @@ class LiveSession {
* Close the current session, if any.
*/
disconnect() {
this._store.commit("setPlayerCount", 0);
this._store.commit("session/setPlayerCount", 0);
if (this._socket) {
this._send("bye", this._store.state.session.playerId);
this._socket.close();
@ -140,6 +152,7 @@ class LiveSession {
if (this._isSpectator) return;
this._gamestate = this._store.state.players.players.map(player => ({
name: player.name,
id: player.id,
isDead: player.isDead,
isVoteless: player.isVoteless,
...(player.role && player.role.team === "traveler"
@ -154,7 +167,8 @@ class LiveSession {
}));
this._send("gs", {
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
* @private
*/
_updateGamestate({ gamestate, edition }) {
_updateGamestate({ gamestate, edition, nomination }) {
if (!this._isSpectator) return;
this._store.commit("setEdition", edition);
this._store.commit("session/nomination", nomination);
const players = this._store.state.players.players;
// adjust number of players
if (players.length < gamestate.length) {
@ -181,28 +196,15 @@ class LiveSession {
// update status for each player
gamestate.forEach((state, x) => {
const player = players[x];
const { name, isDead, isVoteless, role } = state;
if (player.name !== name) {
this._store.commit("players/update", {
player,
property: "name",
value: name
});
}
if (player.isDead !== isDead) {
this._store.commit("players/update", {
player,
property: "isDead",
value: isDead
});
}
if (player.isVoteless !== isVoteless) {
this._store.commit("players/update", {
player,
property: "isVoteless",
value: isVoteless
});
}
const { role } = state;
// update relevant properties
["name", "id", "isDead", "isVoteless"].forEach(property => {
const value = state[property];
if (player[property] !== value) {
this._store.commit("players/update", { player, property, value });
}
});
// roles are special, because of travelers
if (role && player.role.id !== role.id) {
this._store.commit("players/update", {
player,
@ -298,7 +300,20 @@ class LiveSession {
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) {
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
store.subscribe(({ type, payload }) => {
switch (type) {
case "setSessionId":
case "session/setSessionId":
if (payload) {
session.connect(payload);
} else {
@ -327,6 +417,18 @@ module.exports = store => {
session.disconnect();
}
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/swap":
case "players/move":
@ -345,7 +447,7 @@ module.exports = store => {
// check for session Id in hash
const [command, param] = window.location.hash.substr(1).split("/");
if (command === "play") {
store.commit("setSpectator", true);
store.commit("setSessionId", param);
store.commit("session/setSpectator", true);
store.commit("session/setSessionId", param);
}
};