Add a timer

This commit is contained in:
chris-mclennon 2021-08-01 17:57:44 -04:00
parent 9bafcc2c61
commit 045f7112e0
9 changed files with 330 additions and 38 deletions

View file

@ -1,5 +1,8 @@
# Release Notes # Release Notes
### Version 2.15.4
- add timer
### Version 2.15.3 ### Version 2.15.3
- add Huntsman/Damsel to list of available characters - add Huntsman/Damsel to list of available characters

View file

@ -121,6 +121,10 @@ export default {
if (this.session.isSpectator) return; if (this.session.isSpectator) return;
this.$refs.menu.toggleNight(); this.$refs.menu.toggleNight();
break; break;
case "t":
if (this.session.isSpectator) return;
this.$refs.menu.toggleTimer();
break;
case "escape": case "escape":
this.$store.commit("toggleModal"); this.$store.commit("toggleModal");
} }

View file

@ -0,0 +1,173 @@
<template>
<div class="countdown-timer">
<div
id="timer"
v-bind:style="[
remainingSeconds <= 30 ? { color: 'red' } : { color: 'white' }
]"
>
{{ formattedMinutes }}:{{ formattedSeconds }}
</div>
<div class="timer-control" v-if="!session.isSpectator">
<div class="timer-button" @click="startTimer" v-if="!isTicking">
<font-awesome-icon icon="play"></font-awesome-icon>
</div>
<div class="timer-button" @click="pauseTimer" v-if="isTicking">
<font-awesome-icon icon="pause"></font-awesome-icon>
</div>
<div class="timer-button" @click="stopTimer">
<font-awesome-icon icon="stop"></font-awesome-icon>
</div>
<div class="timer-button" @click="addMinute" v-if="!isTicking">
<font-awesome-icon icon="plus"></font-awesome-icon>
</div>
<div class="timer-button" @click="subtractMinute" v-if="!isTicking">
<font-awesome-icon icon="minus"></font-awesome-icon>
</div>
</div>
</div>
</template>
<script>
import { mapState } from "vuex";
export default {
name: "CountdownTimer",
computed: {
...mapState(["session"]),
remainingSeconds: {
get: function() {
return this.$store.state.countdownTimer.remainingSeconds;
},
set: function(newValue) {
this.$store.state.countdownTimer.remainingSeconds = newValue;
}
},
totalSeconds: {
get: function() {
return this.$store.state.countdownTimer.totalSeconds;
},
set: function(newValue) {
this.$store.state.countdownTimer.totalSeconds = newValue;
}
},
isTicking: {
get: function() {
return this.$store.state.countdownTimer.isTicking;
},
set: function(newValue) {
this.$store.state.countdownTimer.isTicking = newValue;
}
},
formattedMinutes() {
let minutes = Math.floor(this.remainingSeconds / 60);
if (minutes < 10) {
minutes = "0" + minutes;
}
return minutes;
},
formattedSeconds() {
let seconds = this.remainingSeconds % 60;
if (seconds < 10) {
seconds = "0" + seconds;
}
return seconds;
}
},
updated() {
clearInterval(this.timerInterval);
if (this.isTicking) {
this.timerInterval = setInterval(this.elapseTimer, 1000);
}
},
data: () => {
return {
timerInterval: null
};
},
methods: {
startTimer() {
clearInterval(this.timerInterval);
if (this.remainingSeconds === 0) {
this.remainingSeconds = this.totalSeconds;
}
this.isTicking = true;
this.sendTimerUpdate();
this.timerInterval = setInterval(this.elapseTimer, 1000);
},
pauseTimer() {
clearInterval(this.timerInterval);
this.isTicking = false;
this.sendTimerUpdate();
},
stopTimer() {
clearInterval(this.timerInterval);
this.remainingSeconds = this.totalSeconds;
this.pauseTimer();
},
sendTimerUpdate() {
if (this.session.isSpectator) return;
let payload = {
remainingSeconds: this.remainingSeconds,
totalSeconds: this.totalSeconds,
isTicking: this.isTicking
};
this.$store.commit("session/distributeTimerAction", payload);
},
elapseTimer() {
if (this.remainingSeconds <= 0) {
clearInterval(this.timerInterval);
return;
}
this.remainingSeconds--;
// Update local state on ST, so if a player joins while timer is ticking, they will get updated timer state.
if (!this.session.isSpectator) {
let payload = {
remainingSeconds: this.remainingSeconds,
totalSeconds: this.totalSeconds,
isTicking: this.isTicking
};
this.$store.commit("session/updateTimerState", payload);
}
},
addMinute() {
this.totalSeconds += 60;
this.remainingSeconds += 60;
this.sendTimerUpdate();
},
subtractMinute() {
this.totalSeconds = Math.max(0, this.totalSeconds - 60);
this.remainingSeconds = Math.max(0, this.remainingSeconds - 60);
this.sendTimerUpdate();
}
}
};
</script>
<style scoped>
.countdown-timer {
border-color: white;
border-style: solid;
border-width: thick;
border-radius: 10px;
padding: 5px 5px 5px;
}
.timer-control {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-around;
}
.timer-button {
margin: 0 5px 0 5px;
filter: drop-shadow(0 2px 1px black);
}
#timer {
font-weight: bold;
text-align: center;
text-shadow: 0 2px 1px black, 0 -2px 1px black, 2px 0 1px black,
-2px 0 1px black;
width: 100%;
}
</style>

View file

@ -138,6 +138,10 @@
Send Characters Send Characters
<em><font-awesome-icon icon="theater-masks"/></em> <em><font-awesome-icon icon="theater-masks"/></em>
</li> </li>
<li v-if="!session.isSpectator" @click="toggleTimer">
Toggle timer
<em>[T]</em>
</li>
<li <li
v-if="session.voteHistory.length || !session.isSpectator" v-if="session.voteHistory.length || !session.isSpectator"
@click="toggleModal('voteHistory')" @click="toggleModal('voteHistory')"
@ -345,6 +349,9 @@ export default {
this.$store.commit("session/setMarkedPlayer", -1); this.$store.commit("session/setMarkedPlayer", -1);
} }
}, },
toggleTimer() {
this.$store.commit("toggleTimer");
},
...mapMutations([ ...mapMutations([
"toggleGrimoire", "toggleGrimoire",
"toggleMenu", "toggleMenu",

View file

@ -46,39 +46,47 @@
</ul> </ul>
</div> </div>
<div class="fabled" :class="{ closed: !isFabledOpen }" v-if="fabled.length"> <div id="top-left">
<h3> <div
<span>Fabled</span> class="fabled"
<font-awesome-icon icon="times-circle" @click.stop="toggleFabled" /> :class="{ closed: !isFabledOpen }"
<font-awesome-icon icon="plus-circle" @click.stop="toggleFabled" /> v-if="fabled.length"
</h3> >
<ul> <h3>
<li <span>Fabled</span>
v-for="(role, index) in fabled" <font-awesome-icon icon="times-circle" @click.stop="toggleFabled" />
:key="index" <font-awesome-icon icon="plus-circle" @click.stop="toggleFabled" />
@click="removeFabled(index)" </h3>
> <ul>
<div <li
class="night-order first" v-for="(role, index) in fabled"
v-if="nightOrder.get(role).first && grimoire.isNightOrder" :key="index"
@click="removeFabled(index)"
> >
<em>{{ nightOrder.get(role).first }}.</em> <div
<span v-if="role.firstNightReminder">{{ class="night-order first"
role.firstNightReminder v-if="nightOrder.get(role).first && grimoire.isNightOrder"
}}</span> >
</div> <em>{{ nightOrder.get(role).first }}.</em>
<div <span v-if="role.firstNightReminder">{{
class="night-order other" role.firstNightReminder
v-if="nightOrder.get(role).other && grimoire.isNightOrder" }}</span>
> </div>
<em>{{ nightOrder.get(role).other }}.</em> <div
<span v-if="role.otherNightReminder">{{ class="night-order other"
role.otherNightReminder v-if="nightOrder.get(role).other && grimoire.isNightOrder"
}}</span> >
</div> <em>{{ nightOrder.get(role).other }}.</em>
<Token :role="role"></Token> <span v-if="role.otherNightReminder">{{
</li> role.otherNightReminder
</ul> }}</span>
</div>
<Token :role="role"></Token>
</li>
</ul>
</div>
<CountdownTimer v-if="grimoire.isTimerEnabled" />
</div> </div>
<ReminderModal :player-index="selectedPlayer"></ReminderModal> <ReminderModal :player-index="selectedPlayer"></ReminderModal>
@ -90,6 +98,7 @@
import { mapGetters, mapState } from "vuex"; import { mapGetters, mapState } from "vuex";
import Player from "./Player"; import Player from "./Player";
import Token from "./Token"; import Token from "./Token";
import CountdownTimer from "./CountdownTimer";
import ReminderModal from "./modals/ReminderModal"; import ReminderModal from "./modals/ReminderModal";
import RoleModal from "./modals/RoleModal"; import RoleModal from "./modals/RoleModal";
@ -97,6 +106,7 @@ export default {
components: { components: {
Player, Player,
Token, Token,
CountdownTimer,
RoleModal, RoleModal,
ReminderModal ReminderModal
}, },
@ -394,16 +404,32 @@ export default {
} }
} }
/***** Demon bluffs / Fabled *******/ #top-left {
#townsquare > .bluffs,
#townsquare > .fabled {
position: absolute; position: absolute;
top: 10px;
left: 10px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
}
/***** Demon bluffs / Fabled / Countdown Timer *******/
#townsquare > .bluffs,
#top-left > .fabled,
#top-left > .countdown-timer {
&.bluffs { &.bluffs {
position: absolute;
bottom: 10px; bottom: 10px;
} }
&.fabled { &.fabled {
top: 10px; width: 100%;
} }
&.countdown-timer {
width: 100%;
}
margin: 5px 0 0 0;
left: 10px; left: 10px;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
border-radius: 10px; border-radius: 10px;

View file

@ -27,8 +27,12 @@ const faIcons = [
"Heartbeat", "Heartbeat",
"Image", "Image",
"Link", "Link",
"Minus",
"MinusCircle", "MinusCircle",
"Pause",
"PeopleArrows", "PeopleArrows",
"Play",
"Plus",
"PlusCircle", "PlusCircle",
"Question", "Question",
"Random", "Random",
@ -37,6 +41,7 @@ const faIcons = [
"SearchPlus", "SearchPlus",
"Skull", "Skull",
"Square", "Square",
"Stop",
"TheaterMasks", "TheaterMasks",
"Times", "Times",
"TimesCircle", "TimesCircle",

View file

@ -105,9 +105,15 @@ export default new Vuex.Store({
isStatic: false, isStatic: false,
isMuted: false, isMuted: false,
isImageOptIn: false, isImageOptIn: false,
isTimerEnabled: false,
zoom: 0, zoom: 0,
background: "" background: ""
}, },
countdownTimer: {
totalSeconds: 300,
remainingSeconds: 300,
isTicking: false
},
modals: { modals: {
edition: false, edition: false,
fabled: false, fabled: false,
@ -171,6 +177,7 @@ export default new Vuex.Store({
toggleNight: toggle("isNight"), toggleNight: toggle("isNight"),
toggleGrimoire: toggle("isPublic"), toggleGrimoire: toggle("isPublic"),
toggleImageOptIn: toggle("isImageOptIn"), toggleImageOptIn: toggle("isImageOptIn"),
toggleTimer: toggle("isTimerEnabled"),
toggleModal({ modals }, name) { toggleModal({ modals }, name) {
if (name) { if (name) {
modals[name] = !modals[name]; modals[name] = !modals[name];
@ -260,6 +267,14 @@ export default new Vuex.Store({
state.edition = edition; state.edition = edition;
} }
state.modals.edition = false; state.modals.edition = false;
},
/**
* Set timer state
* @param state
* @param payload Object with keys: `remainingSeconds`, `totalSeconds`, and `isTicking`
*/
setTimerState(state, payload) {
state.countdownTimer = payload;
} }
}, },
plugins: [persistence, socket] plugins: [persistence, socket]

View file

@ -27,7 +27,12 @@ const state = () => ({
voteHistory: [], voteHistory: [],
markedPlayer: -1, markedPlayer: -1,
isVoteHistoryAllowed: true, isVoteHistoryAllowed: true,
isRolesDistributed: false isRolesDistributed: false,
countdownTimer: {
totalSeconds: 300,
remainingSeconds: 300,
isTicking: false
}
}); });
const getters = {}; const getters = {};
@ -94,6 +99,12 @@ const mutations = {
clearVoteHistory(state) { clearVoteHistory(state) {
state.voteHistory = []; state.voteHistory = [];
}, },
updateTimerState(state, payload) {
state.countdownTimer = payload;
},
distributeTimerAction(state, payload) {
state.countdownTimer = payload;
},
/** /**
* Store a vote with and without syncing it to the live session. * Store a vote with and without syncing it to the live session.
* This is necessary in order to prevent infinite voting loops. * This is necessary in order to prevent infinite voting loops.

View file

@ -205,6 +205,13 @@ class LiveSession {
case "pronouns": case "pronouns":
this._updatePlayerPronouns(params); this._updatePlayerPronouns(params);
break; break;
case "timer":
this._handleTimerAction(params);
break;
case "isTimerEnabled":
if (!this._isSpectator) return;
this._store.commit("toggleTimer", params);
break;
} }
} }
@ -284,6 +291,8 @@ class LiveSession {
isVoteInProgress: session.isVoteInProgress, isVoteInProgress: session.isVoteInProgress,
markedPlayer: session.markedPlayer, markedPlayer: session.markedPlayer,
fabled: fabled.map(f => (f.isCustom ? f : { id: f.id })), fabled: fabled.map(f => (f.isCustom ? f : { id: f.id })),
isTimerEnabled: grimoire.isTimerEnabled,
countdownTimer: session.countdownTimer,
...(session.nomination ? { votes: session.votes } : {}) ...(session.nomination ? { votes: session.votes } : {})
}); });
} }
@ -307,7 +316,9 @@ class LiveSession {
lockedVote, lockedVote,
isVoteInProgress, isVoteInProgress,
markedPlayer, markedPlayer,
fabled fabled,
isTimerEnabled,
countdownTimer
} = data; } = data;
const players = this._store.state.players.players; const players = this._store.state.players.players;
// adjust number of players // adjust number of players
@ -365,6 +376,8 @@ class LiveSession {
this._store.commit("players/setFabled", { this._store.commit("players/setFabled", {
fabled: fabled.map(f => this._store.state.fabled.get(f.id) || f) fabled: fabled.map(f => this._store.state.fabled.get(f.id) || f)
}); });
this._store.commit("toggleTimer", isTimerEnabled);
this._store.commit("setTimerState", countdownTimer);
} }
} }
@ -668,6 +681,25 @@ class LiveSession {
} }
} }
/**
* Distribute new timer action to each player.
*/
distributeTimerAction(payload) {
if (this._isSpectator) {
return;
}
this._send("timer", payload);
}
/**
* Handle a timer action.
* @param payload
* @private
*/
_handleTimerAction(payload) {
this._store.commit("setTimerState", payload);
}
/** /**
* A player nomination. ST only * A player nomination. ST only
* This also syncs the voting speed to the players. * This also syncs the voting speed to the players.
@ -703,6 +735,14 @@ class LiveSession {
this._send("isNight", this._store.state.grimoire.isNight); this._send("isNight", this._store.state.grimoire.isNight);
} }
/**
* Send the isTimerEnabled status. ST only
*/
setIsTimerEnabled() {
if (this._isSpectator) return;
this._send("isTimerEnabled", this._store.state.grimoire.isTimerEnabled);
}
/** /**
* Send the isVoteHistoryAllowed state. ST only * Send the isVoteHistoryAllowed state. ST only
*/ */
@ -859,6 +899,11 @@ export default store => {
session.distributeRoles(); session.distributeRoles();
} }
break; break;
case "session/distributeTimerAction":
if (payload) {
session.distributeTimerAction(payload);
}
break;
case "session/nomination": case "session/nomination":
case "session/setNomination": case "session/setNomination":
session.nomination(payload); session.nomination(payload);
@ -914,6 +959,9 @@ export default store => {
session.sendPlayer(payload); session.sendPlayer(payload);
} }
break; break;
case "toggleTimer":
session.setIsTimerEnabled();
break;
} }
}); });