Merge pull request #10 from bra1n/voting

Added live play session, player swapping and more
This commit is contained in:
Steffen 2020-05-12 22:27:08 +02:00 committed by GitHub
commit 78a32d3581
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 667 additions and 117 deletions

View File

@ -37,10 +37,10 @@ export default {
EditionModal, EditionModal,
RolesModal RolesModal
}, },
computed: mapState({ computed: {
grimoire: state => state.grimoire, ...mapState(["grimoire", "session"]),
players: state => state.players.players ...mapState("players", ["players"])
}), },
methods: { methods: {
takeScreenshot(dimensions) { takeScreenshot(dimensions) {
this.$refs.menu.takeScreenshot(dimensions); this.$refs.menu.takeScreenshot(dimensions);
@ -57,9 +57,11 @@ export default {
this.$refs.menu.randomizeSeatings(); this.$refs.menu.randomizeSeatings();
break; break;
case "e": case "e":
if (this.session.isSpectator) return;
this.$store.commit("toggleModal", "edition"); this.$store.commit("toggleModal", "edition");
break; break;
case "c": case "c":
if (this.session.isSpectator) return;
this.$store.commit("toggleModal", "roles"); this.$store.commit("toggleModal", "roles");
break; break;
case "Escape": case "Escape":

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 192 KiB

View File

@ -1,9 +1,17 @@
<template> <template>
<div id="controls"> <div id="controls">
<Screenshot ref="screenshot"></Screenshot> <Screenshot ref="screenshot"></Screenshot>
<font-awesome-icon
@click="leaveSession"
icon="broadcast-tower"
v-if="session.sessionId"
v-bind:class="{ spectator: session.isSpectator }"
title="You're currently in a live game!"
/>
<font-awesome-icon <font-awesome-icon
icon="camera" icon="camera"
@click="takeScreenshot()" @click="takeScreenshot()"
title="Take a screenshot"
v-bind:class="{ success: grimoire.isScreenshotSuccess }" v-bind:class="{ success: grimoire.isScreenshotSuccess }"
/> />
<div class="menu" v-bind:class="{ open: grimoire.isMenuOpen }"> <div class="menu" v-bind:class="{ open: grimoire.isMenuOpen }">
@ -37,36 +45,58 @@
<li @click="setBackground"> <li @click="setBackground">
Background image Background image
</li> </li>
<li @click="hostSession" v-if="!session.sessionId">
<!-- Users --> Host Live Session
<li class="headline">
<font-awesome-icon icon="users" />
Players
</li> </li>
<li @click="addPlayer" v-if="players.length < 20"><em>[A]</em> Add</li> <li @click="joinSession" v-if="!session.sessionId">
<li @click="randomizeSeatings" v-if="players.length > 2"> Join Live Session
<em>[R]</em> Randomize
</li> </li>
<li @click="clearPlayers" v-if="players.length"> <li class="headline" v-if="session.sessionId">
Remove all <font-awesome-icon icon="broadcast-tower" />
{{ session.isSpectator ? "Playing" : "Hosting" }}
</li>
<li v-if="session.sessionId" @click="copySessionUrl">
<em><font-awesome-icon icon="copy"/></em>
Copy player link
</li>
<li @click="leaveSession" v-if="session.sessionId">
<em>{{ session.sessionId }}</em>
Leave Session
</li> </li>
<!-- Characters --> <template v-if="!session.isSpectator">
<li class="headline"> <!-- Users -->
<font-awesome-icon icon="theater-masks" /> <li class="headline">
Characters <font-awesome-icon icon="users" />
</li> Players
<li @click="toggleModal('edition')"> </li>
<em>[E]</em> <li @click="addPlayer" v-if="players.length < 20">
Select Edition <em>[A]</em> Add
</li> </li>
<li @click="toggleModal('roles')" v-if="players.length > 4"> <li @click="randomizeSeatings" v-if="players.length > 2">
<em>[C]</em> <em>[R]</em> Randomize
Choose & Assign </li>
</li> <li @click="clearPlayers" v-if="players.length">
<li @click="clearRoles" v-if="players.length"> Remove all
Remove all </li>
</li>
<!-- Characters -->
<li class="headline">
<font-awesome-icon icon="theater-masks" />
Characters
</li>
<li @click="toggleModal('edition')">
<em>[E]</em>
Select Edition
</li>
<li @click="toggleModal('roles')" v-if="players.length > 4">
<em>[C]</em>
Choose & Assign
</li>
<li @click="clearRoles" v-if="players.length">
Remove all
</li>
</template>
</ul> </ul>
</div> </div>
</div> </div>
@ -80,10 +110,10 @@ export default {
components: { components: {
Screenshot Screenshot
}, },
computed: mapState({ computed: {
grimoire: state => state.grimoire, ...mapState(["grimoire", "session"]),
players: state => state.players.players ...mapState("players", ["players"])
}), },
methods: { methods: {
takeScreenshot(dimensions = {}) { takeScreenshot(dimensions = {}) {
this.$store.commit("updateScreenshot"); this.$store.commit("updateScreenshot");
@ -95,26 +125,75 @@ export default {
prompt("Enter custom background URL") prompt("Enter custom background URL")
); );
}, },
hostSession() {
const sessionId = prompt(
"Enter a channel number for your session",
Math.round(Math.random() * 10000)
);
if (sessionId) {
this.$store.commit("setSpectator", false);
this.$store.commit(
"setSessionId",
sessionId.replace(/[^0-9]/g, "").substr(0, 5)
);
this.copySessionUrl();
}
},
copySessionUrl() {
// check for clipboard permissions
navigator.permissions
.query({ name: "clipboard-write" })
.then(({ state }) => {
if (state === "granted" || state === "prompt") {
const url = window.location.href.split("#")[0];
const link = url + "#play/" + this.session.sessionId;
navigator.clipboard.writeText(link);
}
});
},
joinSession() {
const sessionId = prompt(
"Enter the channel number of the session you want to join"
);
if (sessionId) {
this.$store.commit("setSpectator", true);
this.$store.commit(
"setSessionId",
sessionId.replace(/[^0-9]/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", "");
}
},
addPlayer() { addPlayer() {
if (this.session.isSpectator) return;
const name = prompt("Player name"); const name = prompt("Player name");
if (name) { if (name) {
this.$store.commit("players/add", name); this.$store.commit("players/add", name);
} }
}, },
randomizeSeatings() { randomizeSeatings() {
if (this.session.isSpectator) return;
if (confirm("Are you sure you want to randomize seatings?")) { if (confirm("Are you sure you want to randomize seatings?")) {
this.$store.dispatch("players/randomize"); this.$store.dispatch("players/randomize");
} }
}, },
clearPlayers() { clearPlayers() {
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() {
this.$store.commit("showGrimoire"); 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");
} }
}, },
...mapMutations([ ...mapMutations([
@ -148,13 +227,13 @@ export default {
right: 3px; right: 3px;
top: 3px; top: 3px;
text-align: right; text-align: right;
padding-right: 50px;
#app.screenshot & { #app.screenshot & {
display: none; display: none;
} }
svg { svg {
cursor: pointer;
filter: drop-shadow(0 0 5px rgba(0, 0, 0, 1)); filter: drop-shadow(0 0 5px rgba(0, 0, 0, 1));
&.success { &.success {
animation: greenToWhite 1s normal forwards; animation: greenToWhite 1s normal forwards;
@ -162,11 +241,18 @@ export default {
} }
} }
.fa-camera { > svg {
position: absolute; cursor: pointer;
right: 50px;
top: 10px;
z-index: 5; z-index: 5;
margin-top: 10px;
margin-left: 10px;
}
> .fa-broadcast-tower {
color: $demon;
&.spectator {
color: $townsfolk;
}
} }
} }
@ -175,12 +261,16 @@ export default {
transform-origin: 190px 22px; transform-origin: 190px 22px;
transition: transform 500ms cubic-bezier(0.68, -0.55, 0.27, 1.55); transition: transform 500ms cubic-bezier(0.68, -0.55, 0.27, 1.55);
transform: rotate(-90deg); transform: rotate(-90deg);
position: absolute;
right: 0;
top: 0;
&.open { &.open {
transform: rotate(0deg); transform: rotate(0deg);
} }
> svg { > svg {
cursor: pointer;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
border: 3px solid black; border: 3px solid black;
width: 40px; width: 40px;

View File

@ -4,8 +4,8 @@
ref="player" ref="player"
class="player" class="player"
:class="{ :class="{
dead: player.hasDied, dead: player.isDead,
'no-vote': player.hasVoted, 'no-vote': player.isVoteless,
traveler: player.role && player.role.team === 'traveler' traveler: player.role && player.role.team === 'traveler'
}" }"
> >
@ -33,26 +33,61 @@
<Token :role="player.role" @set-role="$emit('set-role')" /> <Token :role="player.role" @set-role="$emit('set-role')" />
<font-awesome-icon
icon="times-circle"
class="cancel"
title="Cancel"
@click="doSwap(true)"
/>
<font-awesome-icon
icon="exchange-alt"
class="swap"
@click="doSwap()"
title="Swap seats with this player"
/>
<font-awesome-icon <font-awesome-icon
icon="vote-yea" icon="vote-yea"
class="vote" class="vote"
v-if="player.hasDied && !player.hasVoted" v-if="player.isDead && !player.isVoteless"
@click="updatePlayer('hasVoted', true)" @click="updatePlayer('isVoteless', true)"
title="Ghost vote" title="Ghost vote"
/> />
<div class="name" @click="changeName"> <div
<span class="screenshot" @click.stop="takeScreenshot"> class="name"
<font-awesome-icon icon="camera" /> @click="isMenuOpen = !isMenuOpen"
</span> v-bind:class="{ active: isMenuOpen }"
<span class="name"> >
{{ player.name }} {{ player.name }}
</span>
<span class="remove" @click.stop="$emit('remove-player')">
<font-awesome-icon icon="times-circle" />
</span>
</div> </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="initSwap">
<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
</li>
</ul>
</transition>
</div> </div>
<template v-if="player.reminders"> <template v-if="player.reminders">
<div <div
class="reminder" class="reminder"
@ -93,11 +128,14 @@ export default {
} }
}, },
computed: { computed: {
...mapState(["grimoire"]), ...mapState(["grimoire", "session"]),
...mapGetters({ nightOrder: "players/nightOrder" }) ...mapGetters({ nightOrder: "players/nightOrder" })
}, },
data() { data() {
return {}; return {
isMenuOpen: false,
isSwap: false
};
}, },
filters: { filters: {
handleEmojis: text => text.replace(/:([^: ]+?):/g, "").replace(/ •/g, "\n•") handleEmojis: text => text.replace(/:([^: ]+?):/g, "").replace(/ •/g, "\n•")
@ -106,25 +144,30 @@ export default {
takeScreenshot() { takeScreenshot() {
const { width, height, x, y } = this.$refs.player.getBoundingClientRect(); const { width, height, x, y } = this.$refs.player.getBoundingClientRect();
this.$emit("screenshot", { width, height, x, y }); this.$emit("screenshot", { width, height, x, y });
this.isMenuOpen = false;
}, },
toggleStatus() { toggleStatus() {
if (this.grimoire.isPublic) { if (this.grimoire.isPublic) {
if (!this.player.hasDied) { if (!this.player.isDead) {
this.updatePlayer("hasDied", true); this.updatePlayer("isDead", true);
} else if (this.player.hasVoted) { } else if (this.player.isVoteless) {
this.updatePlayer("hasVoted", false); this.updatePlayer("isVoteless", false);
this.updatePlayer("hasDied", false); this.updatePlayer("isDead", false);
} else { } else {
this.updatePlayer("hasVoted", true); this.updatePlayer("isVoteless", true);
} }
} else { } else {
this.updatePlayer("hasDied", !this.player.hasDied); this.updatePlayer("isDead", !this.player.isDead);
this.updatePlayer("hasVoted", false); if (this.player.isVoteless) {
this.updatePlayer("isVoteless", false);
}
} }
}, },
changeName() { changeName() {
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);
this.isMenuOpen = false;
}, },
removeReminder(reminder) { removeReminder(reminder) {
const reminders = [...this.player.reminders]; const reminders = [...this.player.reminders];
@ -132,11 +175,19 @@ export default {
this.updatePlayer("reminders", reminders); this.updatePlayer("reminders", reminders);
}, },
updatePlayer(property, value) { updatePlayer(property, value) {
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
}); });
},
initSwap() {
this.isMenuOpen = false;
this.$emit("swap-seats");
},
doSwap(cancel) {
this.$emit("swap-seats", cancel ? false : this.player);
} }
} }
}; };
@ -145,6 +196,17 @@ export default {
<style lang="scss"> <style lang="scss">
@import "../vars.scss"; @import "../vars.scss";
.fold-enter-active,
.fold-leave-active {
transition: transform 250ms ease-in-out;
transform-origin: left center;
transform: perspective(200px);
}
.fold-enter,
.fold-leave-to {
transform: perspective(200px) rotateY(90deg);
}
/***** Player token *****/ /***** Player token *****/
.circle .player { .circle .player {
margin-bottom: 10px; margin-bottom: 10px;
@ -181,7 +243,7 @@ export default {
pointer-events: none; pointer-events: none;
} }
&:hover:before { #townsquare:not(.spectator) &:hover:before {
opacity: 0.5; opacity: 0.5;
top: -10px; top: -10px;
transform: scale(1); transform: scale(1);
@ -280,6 +342,40 @@ export default {
transform: perspective(400px) rotateY(-180deg); transform: perspective(400px) rotateY(-180deg);
} }
/****** Player choice icons *******/
.player > svg {
position: absolute;
filter: drop-shadow(0 0 3px black);
z-index: 2;
cursor: pointer;
&.swap,
&.cancel {
top: 9%;
left: 20%;
width: 60%;
height: 60%;
opacity: 0;
pointer-events: none;
transition: all 250ms;
transform: scale(0.2);
&:hover {
color: red;
}
}
}
li.swap-from .player > svg.cancel {
opacity: 1;
transform: scale(1);
pointer-events: all;
}
li.swap:not(.swap-from) .player > svg.swap {
opacity: 1;
transform: scale(1);
pointer-events: all;
}
/****** Vote icon ********/ /****** Vote icon ********/
.player .vote { .player .vote {
position: absolute; position: absolute;
@ -310,37 +406,12 @@ export default {
border-radius: 10px; border-radius: 10px;
top: 5px; top: 5px;
box-shadow: 0 0 5px black; box-shadow: 0 0 5px black;
text-overflow: ellipsis;
overflow: hidden;
span.screenshot, #townsquare:not(.spectator) &:hover,
span.remove { &.active {
display: none;
position: absolute;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5));
#app.screenshot & {
display: none;
}
}
span.screenshot {
right: 100%;
}
span.remove {
left: 100%;
}
span.name {
flex-shrink: 1;
text-overflow: ellipsis;
overflow: hidden;
}
&:hover {
color: red; color: red;
span {
display: block;
color: white;
&:hover {
color: red;
}
}
} }
} }
@ -348,11 +419,47 @@ export default {
opacity: 0.5; opacity: 0.5;
} }
/***** Player menu *****/
.player > .menu {
position: absolute;
left: 100%;
bottom: 0;
text-align: left;
white-space: nowrap;
background: rgba(0, 0, 0, 0.5);
padding: 0 5px;
border-radius: 5px;
border: 3px solid black;
margin-left: 15px;
cursor: pointer;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
&:before {
content: " ";
width: 0;
height: 0;
position: absolute;
border: 10px solid transparent;
border-right-color: black;
right: 100%;
bottom: 7px;
margin-right: 2px;
}
li:hover {
color: red;
}
svg {
margin-right: 2px;
}
}
/***** Ability text *****/ /***** Ability text *****/
#townsquare.public .ability { #townsquare.public .ability {
display: none; display: none;
} }
.circle .player:hover .ability { .circle .player .shroud:hover ~ .token .ability,
.circle .player .token:hover .ability {
opacity: 1; opacity: 1;
} }
@ -366,7 +473,7 @@ export default {
opacity: 1; opacity: 1;
transition: opacity 200ms; transition: opacity 200ms;
display: flex; display: flex;
top: -16px; top: -20px;
align-items: center; align-items: center;
pointer-events: none; pointer-events: none;

View File

@ -47,7 +47,7 @@ export default {
teams: function() { teams: function() {
const { players } = this.$store.state.players; const { players } = this.$store.state.players;
const nonTravelers = this.$store.getters["players/nonTravelers"]; const nonTravelers = this.$store.getters["players/nonTravelers"];
const alive = players.filter(player => player.hasDied !== true).length; const alive = players.filter(player => player.isDead !== true).length;
return { return {
...gameJSON[nonTravelers - 5], ...gameJSON[nonTravelers - 5],
traveler: players.length - nonTravelers, traveler: players.length - nonTravelers,
@ -55,7 +55,7 @@ export default {
votes: votes:
alive + alive +
players.filter( players.filter(
player => player.hasDied === true && player.hasVoted !== true player => player.isDead === true && player.isVoteless !== true
).length ).length
}; };
}, },

View File

@ -2,7 +2,10 @@
<div <div
id="townsquare" id="townsquare"
class="square" class="square"
v-bind:class="{ public: grimoire.isPublic }" v-bind:class="{
public: grimoire.isPublic,
spectator: session.isSpectator
}"
v-bind:style="{ zoom: grimoire.zoom }" v-bind:style="{ zoom: grimoire.zoom }"
> >
<ul class="circle" v-bind:class="['size-' + players.length]"> <ul class="circle" v-bind:class="['size-' + players.length]">
@ -13,7 +16,12 @@
@add-reminder="openReminderModal(index)" @add-reminder="openReminderModal(index)"
@set-role="openRoleModal(index)" @set-role="openRoleModal(index)"
@remove-player="removePlayer(index)" @remove-player="removePlayer(index)"
@swap-seats="swapSeats(index, $event)"
@screenshot="$emit('screenshot', $event)" @screenshot="$emit('screenshot', $event)"
v-bind:class="{
'swap-from': swapFrom === index,
swap: swapFrom > -1
}"
></Player> ></Player>
</ul> </ul>
@ -51,13 +59,14 @@ export default {
ReminderModal ReminderModal
}, },
computed: { computed: {
...mapState(["grimoire", "roles"]), ...mapState(["grimoire", "roles", "session"]),
...mapState("players", ["players"]) ...mapState("players", ["players"])
}, },
data() { data() {
return { return {
selectedPlayer: 0, selectedPlayer: 0,
bluffs: 3 bluffs: 3,
swapFrom: -1
}; };
}, },
methods: { methods: {
@ -70,10 +79,13 @@ export default {
this.$store.commit("toggleModal", "reminder"); this.$store.commit("toggleModal", "reminder");
}, },
openRoleModal(playerIndex) { openRoleModal(playerIndex) {
const player = this.players[playerIndex];
if (this.session.isSpectator && player.role.team === "traveler") return;
this.selectedPlayer = playerIndex; this.selectedPlayer = playerIndex;
this.$store.commit("toggleModal", "role"); this.$store.commit("toggleModal", "role");
}, },
removePlayer(playerIndex) { removePlayer(playerIndex) {
if (this.session.isSpectator) return;
if ( if (
confirm( confirm(
`Do you really want to remove ${this.players[playerIndex].name}?` `Do you really want to remove ${this.players[playerIndex].name}?`
@ -81,6 +93,19 @@ export default {
) { ) {
this.$store.commit("players/remove", playerIndex); this.$store.commit("players/remove", playerIndex);
} }
},
swapSeats(from, to) {
if (to === undefined) {
this.swapFrom = from;
} else if (to === false) {
this.swapFrom = -1;
} else {
this.$store.commit("players/swap", [
this.swapFrom,
this.players.indexOf(to)
]);
this.swapFrom = -1;
}
} }
} }
}; };
@ -94,7 +119,7 @@ export default {
list-style: none; list-style: none;
margin: 0; margin: 0;
li { > li {
position: absolute; position: absolute;
top: 0; top: 0;
left: 50%; left: 50%;
@ -163,7 +188,7 @@ export default {
} }
@for $i from 1 through 20 { @for $i from 1 through 20 {
.circle.size-#{$i} li { .circle.size-#{$i} > li {
@include on-circle($item-count: $i); @include on-circle($item-count: $i);
} }
} }

View File

@ -3,7 +3,14 @@
v-show="modals.role && availableRoles.length" v-show="modals.role && availableRoles.length"
@close="toggleModal('role')" @close="toggleModal('role')"
> >
<h3>Choose a new character for {{ playerIndex >= 0 ? players[playerIndex].name : "bluffing" }}</h3> <h3>
Choose a new character for
{{
playerIndex >= 0 && players.length
? players[playerIndex].name
: "bluffing"
}}
</h3>
<ul class="tokens"> <ul class="tokens">
<li <li
v-for="role in availableRoles" v-for="role in availableRoles"
@ -74,8 +81,7 @@ export default {
ul.tokens li { ul.tokens li {
border-radius: 50%; border-radius: 50%;
height: 120px; width: 6vw;
width: 120px;
margin: 5px; margin: 5px;
transition: transform 500ms ease; transition: transform 500ms ease;

View File

@ -149,8 +149,7 @@ ul.tokens {
padding-left: 55px; padding-left: 55px;
li { li {
border-radius: 50%; border-radius: 50%;
height: 120px; width: 6vw;
width: 120px;
margin: 5px; margin: 5px;
opacity: 0.5; opacity: 0.5;
transition: all 250ms; transition: all 250ms;
@ -181,7 +180,6 @@ ul.tokens {
opacity: 1; opacity: 1;
position: absolute; position: absolute;
left: 0; left: 0;
top: 40px;
font-weight: bold; font-weight: bold;
line-height: 50px; line-height: 50px;
text-align: center; text-align: center;

View File

@ -12,13 +12,18 @@ import {
faTheaterMasks, faTheaterMasks,
faTimesCircle, faTimesCircle,
faUser, faUser,
faUserEdit,
faUserFriends, faUserFriends,
faUsers, faUsers,
faVoteYea, faVoteYea,
faCheckSquare, faCheckSquare,
faSquare, faSquare,
faRandom, faRandom,
faPeopleArrows faPeopleArrows,
faBroadcastTower,
faCopy,
faExchangeAlt,
faHandPointRight
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
@ -32,13 +37,18 @@ library.add(
faTheaterMasks, faTheaterMasks,
faTimesCircle, faTimesCircle,
faUser, faUser,
faUserEdit,
faUserFriends, faUserFriends,
faUsers, faUsers,
faVoteYea, faVoteYea,
faCheckSquare, faCheckSquare,
faSquare, faSquare,
faRandom, faRandom,
faPeopleArrows faPeopleArrows,
faBroadcastTower,
faCopy,
faExchangeAlt,
faHandPointRight
); );
Vue.component("font-awesome-icon", FontAwesomeIcon); Vue.component("font-awesome-icon", FontAwesomeIcon);

View File

@ -1,6 +1,7 @@
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 players from "./modules/players"; import players from "./modules/players";
import editionJSON from "../editions.json"; import editionJSON from "../editions.json";
import rolesJSON from "../roles.json"; import rolesJSON from "../roles.json";
@ -35,6 +36,10 @@ export default new Vuex.Store({
background: "", background: "",
bluffs: [] bluffs: []
}, },
session: {
sessionId: "",
isSpectator: false
},
modals: { modals: {
edition: false, edition: false,
roles: false, roles: false,
@ -67,8 +72,18 @@ export default new Vuex.Store({
setBackground({ grimoire }, background) { setBackground({ grimoire }, background) {
grimoire.background = background; grimoire.background = background;
}, },
setBluff({ grimoire }, { index, role }) { setSessionId({ session }, sessionId) {
grimoire.bluffs.splice(index, 1, role); session.sessionId = sessionId;
},
setSpectator({ session }, spectator) {
session.isSpectator = spectator;
},
setBluff({ grimoire }, { index, role } = {}) {
if (index !== undefined) {
grimoire.bluffs.splice(index, 1, role);
} else {
grimoire.bluffs = [];
}
}, },
toggleModal({ modals }, name) { toggleModal({ modals }, name) {
modals[name] = !modals[name]; modals[name] = !modals[name];
@ -88,5 +103,5 @@ export default new Vuex.Store({
state.roles = getRolesByEdition(edition); state.roles = getRolesByEdition(edition);
} }
}, },
plugins: [persistence] plugins: [persistence, session]
}); });

View File

@ -1,8 +1,8 @@
const NEWPLAYER = { const NEWPLAYER = {
role: {}, role: {},
reminders: [], reminders: [],
hasVoted: false, isVoteless: false,
hasDied: false isDead: false
}; };
const state = () => ({ const state = () => ({
@ -78,6 +78,12 @@ const mutations = {
}, },
remove(state, index) { remove(state, index) {
state.players.splice(index, 1); state.players.splice(index, 1);
},
swap(state, [from, to]) {
[state.players[from], state.players[to]] = [
state.players[to],
state.players[from]
];
} }
}; };

View File

@ -27,12 +27,16 @@ module.exports = store => {
})) }))
); );
} }
if (localStorage.getItem("session")) {
const [spectator, sessionId] = JSON.parse(localStorage.getItem("session"));
store.commit("setSpectator", spectator);
store.commit("setSessionId", sessionId);
}
// listen to mutations // listen to mutations
store.subscribe(({ type, payload }, state) => { store.subscribe(({ type, payload }, state) => {
switch (type) { switch (type) {
case "toggleGrimoire": case "toggleGrimoire":
case "showGrimoire":
localStorage.setItem( localStorage.setItem(
"isPublic", "isPublic",
JSON.stringify(state.grimoire.isPublic) JSON.stringify(state.grimoire.isPublic)
@ -54,11 +58,22 @@ module.exports = store => {
JSON.stringify(state.grimoire.bluffs.map(({ id }) => id)) JSON.stringify(state.grimoire.bluffs.map(({ id }) => id))
); );
break; break;
case "setSessionId":
if (payload) {
localStorage.setItem(
"session",
JSON.stringify([state.session.isSpectator, payload])
);
} else {
localStorage.removeItem("session");
}
break;
case "players/add": case "players/add":
case "players/update": case "players/update":
case "players/remove": case "players/remove":
case "players/clear": case "players/clear":
case "players/set": case "players/set":
case "players/swap":
if (state.players.players.length) { if (state.players.players.length) {
localStorage.setItem( localStorage.setItem(
"players", "players",

276
src/store/session.js Normal file
View File

@ -0,0 +1,276 @@
class LiveSession {
constructor(store) {
this.wss = "wss://connect.websocket.in/v3/";
this.key = "zXzDomOphNQ94tWXrHfT8E8gkxjUMSXOQt0ypZetKoFsIUiEBegqWNAlExyd";
this.socket = null;
this.isSpectator = true;
this.gamestate = [];
this.store = store;
}
/**
* Open a new session for the passed channel.
* @param channel
* @private
*/
_open(channel) {
this.disconnect();
this.socket = new WebSocket(this.wss + channel + "?apiKey=" + this.key);
this.socket.addEventListener("message", this._handleMessage.bind(this));
this.socket.onopen = this._onOpen.bind(this);
this.socket.onclose = () => {
this.socket = null;
this.store.commit("setSessionId", "");
};
}
/**
* Send a message through the socket.
* @param command
* @param params
* @private
*/
_send(command, params) {
if (this.socket) {
this.socket.send(JSON.stringify([command, params]));
}
}
/**
* Open event handler for socket.
* @private
*/
_onOpen() {
if (this.isSpectator) {
this._send("req", "gs");
} else {
this.sendGamestate();
}
}
/**
* Handle an incoming socket message.
* @param data
* @private
*/
_handleMessage({ data }) {
let command, params;
try {
[command, params] = JSON.parse(data);
} catch (err) {
console.log("unsupported socket message", data);
}
switch (command) {
case "req":
if (params === "gs") {
this.sendGamestate();
}
break;
case "gs":
this._updateGamestate(params);
break;
case "player":
this._updatePlayer(params);
}
}
/**
* Connect to a new live session, either as host or spectator.
* @param channel
*/
connect(channel) {
this.isSpectator = this.store.state.session.isSpectator;
this._open(channel);
}
/**
* Close the current session, if any.
*/
disconnect() {
if (this.socket) {
this.socket.close();
this.socket = null;
}
}
/**
* Publish the current gamestate.
*/
sendGamestate() {
if (this.isSpectator) return;
this.gamestate = this.store.state.players.players.map(player => ({
name: player.name,
isDead: player.isDead,
isVoteless: player.isVoteless,
...(player.role && player.role.team === "traveler"
? {
role: {
id: player.role.id,
team: "traveler",
name: player.role.name
}
}
: {})
}));
this._send("gs", {
gamestate: this.gamestate,
edition: this.store.state.edition
});
}
/**
* Update the gamestate based on incoming data.
* @param gamestate
* @param edition
* @private
*/
_updateGamestate({ gamestate, edition }) {
this.store.commit("setEdition", edition);
const players = this.store.state.players.players;
// adjust number of players
if (players.length < gamestate.length) {
for (let x = players.length; x < gamestate.length; x++) {
this.store.commit("players/add", gamestate[x].name);
}
} else if (players.length > gamestate.length) {
for (let x = players.length; x > gamestate.length; x--) {
this.store.commit("players/remove", x - 1);
}
}
// 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
});
}
if (role && player.role.id !== role.id) {
this.store.commit("players/update", {
player,
property: "role",
value: role
});
} else if (!role && player.role.team === "traveler") {
this.store.commit("players/update", {
player,
property: "role",
value: {}
});
}
});
}
/**
* Publish a player update.
* @param player
* @param property
* @param value
*/
sendPlayer({ player, property, value }) {
if (this.isSpectator || property === "reminders") return;
const index = this.store.state.players.players.indexOf(player);
if (property === "role") {
if (value.team && value.team === "traveler") {
// update local gamestate to remember this player as a traveler
this.gamestate[index].role = {
id: player.role.id,
team: "traveler",
name: player.role.name
};
this._send("player", {
index,
property,
value: this.gamestate[index].role
});
} else if (this.gamestate[index].role) {
delete this.gamestate[index].role;
this._send("player", { index, property, value: {} });
}
} else {
this._send("player", { index, property, value });
}
}
/**
* Update a player based on incoming data.
* @param index
* @param property
* @param value
* @private
*/
_updatePlayer({ index, property, value }) {
const player = this.store.state.players.players[index];
if (!player) return;
// special case where a player stops being a traveler
if (
property === "role" &&
value.team !== "traveler" &&
player.role.team === "traveler"
) {
// reset to an unknown role
this.store.commit("players/update", {
player,
property: "role",
value: {}
});
} else {
// just update the player otherwise
this.store.commit("players/update", { player, property, value });
}
}
}
module.exports = store => {
// setup
const session = new LiveSession(store);
// listen to mutations
store.subscribe(({ type, payload }) => {
switch (type) {
case "setSessionId":
if (payload) {
session.connect(payload);
} else {
window.location.hash = "";
session.disconnect();
}
break;
case "players/set":
case "players/swap":
case "players/clear":
case "players/remove":
case "players/add":
case "setEdition":
session.sendGamestate();
break;
case "players/update":
session.sendPlayer(payload);
break;
}
});
// 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);
}
};