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
### Version 2.15.4
- add timer
### Version 2.15.3
- add Huntsman/Damsel to list of available characters

View file

@ -121,6 +121,10 @@ export default {
if (this.session.isSpectator) return;
this.$refs.menu.toggleNight();
break;
case "t":
if (this.session.isSpectator) return;
this.$refs.menu.toggleTimer();
break;
case "escape":
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
<em><font-awesome-icon icon="theater-masks"/></em>
</li>
<li v-if="!session.isSpectator" @click="toggleTimer">
Toggle timer
<em>[T]</em>
</li>
<li
v-if="session.voteHistory.length || !session.isSpectator"
@click="toggleModal('voteHistory')"
@ -345,6 +349,9 @@ export default {
this.$store.commit("session/setMarkedPlayer", -1);
}
},
toggleTimer() {
this.$store.commit("toggleTimer");
},
...mapMutations([
"toggleGrimoire",
"toggleMenu",

View file

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

View file

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

View file

@ -105,9 +105,15 @@ export default new Vuex.Store({
isStatic: false,
isMuted: false,
isImageOptIn: false,
isTimerEnabled: false,
zoom: 0,
background: ""
},
countdownTimer: {
totalSeconds: 300,
remainingSeconds: 300,
isTicking: false
},
modals: {
edition: false,
fabled: false,
@ -171,6 +177,7 @@ export default new Vuex.Store({
toggleNight: toggle("isNight"),
toggleGrimoire: toggle("isPublic"),
toggleImageOptIn: toggle("isImageOptIn"),
toggleTimer: toggle("isTimerEnabled"),
toggleModal({ modals }, name) {
if (name) {
modals[name] = !modals[name];
@ -260,6 +267,14 @@ export default new Vuex.Store({
state.edition = edition;
}
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]

View file

@ -27,7 +27,12 @@ const state = () => ({
voteHistory: [],
markedPlayer: -1,
isVoteHistoryAllowed: true,
isRolesDistributed: false
isRolesDistributed: false,
countdownTimer: {
totalSeconds: 300,
remainingSeconds: 300,
isTicking: false
}
});
const getters = {};
@ -94,6 +99,12 @@ const mutations = {
clearVoteHistory(state) {
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.
* This is necessary in order to prevent infinite voting loops.

View file

@ -205,6 +205,13 @@ class LiveSession {
case "pronouns":
this._updatePlayerPronouns(params);
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,
markedPlayer: session.markedPlayer,
fabled: fabled.map(f => (f.isCustom ? f : { id: f.id })),
isTimerEnabled: grimoire.isTimerEnabled,
countdownTimer: session.countdownTimer,
...(session.nomination ? { votes: session.votes } : {})
});
}
@ -307,7 +316,9 @@ class LiveSession {
lockedVote,
isVoteInProgress,
markedPlayer,
fabled
fabled,
isTimerEnabled,
countdownTimer
} = data;
const players = this._store.state.players.players;
// adjust number of players
@ -365,6 +376,8 @@ class LiveSession {
this._store.commit("players/setFabled", {
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
* This also syncs the voting speed to the players.
@ -703,6 +735,14 @@ class LiveSession {
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
*/
@ -859,6 +899,11 @@ export default store => {
session.distributeRoles();
}
break;
case "session/distributeTimerAction":
if (payload) {
session.distributeTimerAction(payload);
}
break;
case "session/nomination":
case "session/setNomination":
session.nomination(payload);
@ -914,6 +959,9 @@ export default store => {
session.sendPlayer(payload);
}
break;
case "toggleTimer":
session.setIsTimerEnabled();
break;
}
});