<template> <div id="townsquare" class="square" :class="{ public: grimoire.isPublic, spectator: session.isSpectator, vote: session.nomination, }" > <ul class="circle" :class="['size-' + players.length]"> <Player v-for="(player, index) in players" :key="index" :player="player" @trigger="handleTrigger(index, $event)" :class="{ from: Math.max(swap, move, nominate) === index, swap: swap > -1, move: move > -1, nominate: nominate > -1, }" ></Player> </ul> <div class="bluffs" v-if="players.length" ref="bluffs" :class="{ closed: !isBluffsOpen }" > <h3> <span v-if="session.isSpectator">{{ locale.townsquare.others }}</span> <span v-else>{{ locale.townsquare.bluffs }}</span> <font-awesome-icon icon="times-circle" @click.stop="toggleBluffs" /> <font-awesome-icon icon="plus-circle" @click.stop="toggleBluffs" /> </h3> <ul> <li v-for="index in bluffSize" :key="index" @click="openRoleModal(index * -1)" > <Token :role="bluffs[index - 1]"></Token> </li> </ul> </div> <div class="storytelling" v-if="!session.isSpectator" ref="storytelling" :class="{ closed: !isTimeControlsOpen }" > <h3> <span>{{ locale.townsquare.storytellerTools }}</span> <font-awesome-icon icon="times-circle" @click.stop="toggleTimeControls" /> <font-awesome-icon icon="plus-circle" @click.stop="toggleTimeControls" /> </h3> <div class="button-group"> <div @click="setTimer()" class="button">🕑 {{ timerDuration }} min</div> <div @click="renameTimer()" class="button">🗏 {{ timerName }}</div> <div class="button demon" @click="stopTimer()" :class="{ disabled: !timerOn }" > ■ </div> <div class="button townfolk" @click="startTimer()" :class="{ disabled: timerOn }" > ⏵ </div> </div> <div class="button-group" v-if="session.nomination"> <div @click="setAccusationTimer()" class="button"> {{ locale.townsquare.timer.accusation.button }} </div> <div @click="setDefenseTimer()" class="button"> {{ locale.townsquare.timer.defense.button }} </div> <div @click="setDebateTimer()" class="button"> {{ locale.townsquare.timer.debate.button }} </div> </div> <div class="button-group" v-else> <div @click="setDaytimeTimer()" class="button"> {{ locale.townsquare.timer.daytime.button }} </div> <div @click="setNominationTimer()" class="button"> {{ locale.townsquare.timer.nominations.button }} </div> <div @click="setDuskTimer()" class="button"> {{ locale.townsquare.timer.dusk.button }} </div> </div> <div class="button-group"> <div @click="toggleNight()" class="button" :class="{ disabled: grimoire.isNight }" > ☀ </div> <div @click="toggleNight()" class="button" :class="{ disabled: !grimoire.isNight }" > ☽ </div> </div> <div class="button-group"> <div @click="toggleRinging()" class="button"> <font-awesome-icon :icon="['fas', 'bell']" /> </div> </div> </div> <div class="fabled" :class="{ closed: !isFabledOpen }" v-if="fabled.length"> <h3> <span>{{ locale.townsquare.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 || !session.isSpectator) " > <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 || !session.isSpectator) " > <em>{{ nightOrder.get(role).other }}.</em> <span v-if="role.otherNightReminder">{{ role.otherNightReminder }}</span> </div> <Token :role="role"></Token> </li> </ul> </div> <ReminderModal :player-index="selectedPlayer"></ReminderModal> <RoleModal :player-index="selectedPlayer"></RoleModal> </div> </template> <script> import { mapGetters, mapState } from "vuex"; import Player from "./Player"; import Token from "./Token"; import ReminderModal from "./modals/ReminderModal"; import RoleModal from "./modals/RoleModal"; export default { components: { Player, Token, RoleModal, ReminderModal, }, computed: { ...mapGetters({ nightOrder: "players/nightOrder" }), ...mapState(["grimoire", "roles", "session", "locale"]), ...mapState("players", ["players", "bluffs", "fabled"]), firstMessage() { return JSON.stringify(this.locale.modal.nightOrder.firstNight); }, otherMessage() { return JSON.stringify(this.locale.modal.nightOrder.otherNights); }, }, data() { return { selectedPlayer: 0, bluffSize: 3, swap: -1, move: -1, nominate: -1, isBluffsOpen: true, isFabledOpen: true, isTimeControlsOpen: false, timerName: "Timer", timerDuration: 1, timerOn: false, timerEnder: false, }; }, methods: { toggleBluffs() { this.isBluffsOpen = !this.isBluffsOpen; }, toggleFabled() { this.isFabledOpen = !this.isFabledOpen; }, toggleTimeControls() { this.isTimeControlsOpen = !this.isTimeControlsOpen; }, removeFabled(index) { if (this.session.isSpectator) return; this.$store.commit("players/setFabled", { index }); }, toggleNight() { this.$store.commit("toggleNight"); if (this.grimoire.isNight) { this.$store.commit("session/setMarkedPlayer", -1); } }, toggleRinging() { this.$store.commit("toggleRinging", true); setTimeout(this.$store.commit, 4000, "toggleRinging", false); }, 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"); }, openRoleModal(playerIndex) { const player = this.players[playerIndex]; if (this.session.isSpectator && player && player.role.team === "traveler") return; this.selectedPlayer = playerIndex; this.$store.commit("toggleModal", "role"); }, removePlayer(playerIndex) { if (this.session.isSpectator || this.session.lockedVote) return; if ( confirm( `Do you really want to remove ${this.players[playerIndex].name}?`, ) ) { const { nomination } = this.session; if (nomination) { if (nomination.includes(playerIndex)) { // abort vote if removed player is either nominator or nominee this.$store.commit("session/nomination"); } else if ( nomination[0] > playerIndex || nomination[1] > playerIndex ) { // update nomination array if removed player has lower index this.$store.commit("session/setNomination", [ nomination[0] > playerIndex ? nomination[0] - 1 : nomination[0], nomination[1] > playerIndex ? nomination[1] - 1 : nomination[1], ]); } } this.$store.commit("players/remove", playerIndex); } }, swapPlayer(from, to) { if (this.session.isSpectator || this.session.lockedVote) return; if (to === undefined) { this.cancel(); this.swap = from; } else { if (this.session.nomination) { // update nomination if one of the involved players is swapped const swapTo = this.players.indexOf(to); const updatedNomination = this.session.nomination.map((nom) => { if (nom === this.swap) return swapTo; if (nom === swapTo) return this.swap; return nom; }); if ( this.session.nomination[0] !== updatedNomination[0] || this.session.nomination[1] !== updatedNomination[1] ) { this.$store.commit("session/setNomination", updatedNomination); } } this.$store.commit("players/swap", [ this.swap, this.players.indexOf(to), ]); this.cancel(); } }, movePlayer(from, to) { if (this.session.isSpectator || this.session.lockedVote) return; if (to === undefined) { this.cancel(); this.move = from; } else { if (this.session.nomination) { // update nomination if it is affected by the move const moveTo = this.players.indexOf(to); const updatedNomination = this.session.nomination.map((nom) => { if (nom === this.move) return moveTo; if (nom > this.move && nom <= moveTo) return nom - 1; if (nom < this.move && nom >= moveTo) return nom + 1; return nom; }); if ( this.session.nomination[0] !== updatedNomination[0] || this.session.nomination[1] !== updatedNomination[1] ) { this.$store.commit("session/setNomination", updatedNomination); } } this.$store.commit("players/move", [ this.move, this.players.indexOf(to), ]); this.cancel(); } }, nominatePlayer(from, to) { if (this.session.isSpectator || this.session.lockedVote) return; if (to === undefined) { this.cancel(); if (from !== this.nominate) { this.nominate = from; } } else { const nomination = [this.nominate, this.players.indexOf(to)]; this.$store.commit("session/nomination", { nomination }); this.cancel(); } }, cancel() { this.move = -1; this.swap = -1; this.nominate = -1; }, renameTimer() { let newName = prompt( this.locale.townsquare.timer.prompt.name, this.timerName, ); if (newName === "") { newName = this.locale.townsquare.timer.default.text; } this.timerName = newName.trim(); }, setDaytimeTimer() { this.timerDuration = 8; this.timerName = this.locale.townsquare.timer.daytime.text; }, setNominationTimer() { this.timerDuration = 2; this.timerName = this.timerName = this.locale.townsquare.timer.nominations.text; }, setDuskTimer() { this.timerDuration = 1; this.timerName = this.timerName = this.locale.townsquare.timer.dusk.text; }, setAccusationTimer() { this.timerDuration = 1; let timerText = this.locale.townsquare.timer.accusation.text; timerText = timerText .replace("$accusator", this.players[this.session.nomination[0]].name) .replace("$accusee", this.players[this.session.nomination[1]].name); this.timerName = timerText; }, setDefenseTimer() { this.timerDuration = 1; let timerText = this.locale.townsquare.timer.defense.text; timerText = timerText .replace("$accusee", this.players[this.session.nomination[1]].name) .replace("$accusator", this.players[this.session.nomination[0]].name); this.timerName = timerText; }, setDebateTimer() { this.timerDuration = 2; let timerText = this.locale.townsquare.timer.debate.text; timerText = timerText.replace( "$accusee", this.players[this.session.nomination[1]].name, ); this.timerName = timerText; }, setTimer() { let newDuration = prompt(this.locale.townsquare.timer.prompt.duration); if (isNaN(newDuration)) { return alert(this.locale.townsquare.timer.prompt.durationError); } if (newDuration > 0) { this.timerDuration = newDuration; } }, startTimer() { let timer = { name: this.timerName, duration: this.timerDuration * 60 }; this.$store.commit("setTimer", timer); this.timerOn = true; this.timerEnder = setTimeout(this.stopTimer, timer.duration * 1000); }, stopTimer() { this.$store.commit("setTimer", {}); this.timerOn = false; clearTimeout(this.timerEnder); }, }, }; </script> <style lang="scss"> @use "sass:math"; @import "../vars.scss"; #townsquare { width: 100%; height: 100%; padding: 20px; display: flex; align-items: center; align-content: center; justify-content: center; } .circle { padding: 0; width: 95vmin; height: 95vmin; list-style: none; margin: 0; > li { position: absolute; left: 50%; height: 50vmin; max-height: 50%; transform-origin: 0 100%; pointer-events: none; &:hover { z-index: 25 !important; } > .player { margin-left: -50%; width: 100%; pointer-events: all; } > .reminder { margin-left: -25%; width: 50%; pointer-events: all; } } } @mixin on-circle($item-count) { $angle: math.div(360, $item-count); $rot: 0; // rotation and tooltip placement @for $i from 1 through $item-count { &:nth-child(#{$i}) { transform: rotate($rot * 1deg); @if $i - 1 <= math.div($item-count, 2) { // first half of players z-index: $item-count - $i + 1; // open menu on the left .player > .menu { left: auto; right: 110%; margin-right: 15px; &:before { border-left-color: black; border-right-color: transparent; right: auto; left: 100%; } } .fold-enter-active, .fold-leave-active { transform-origin: right center; } .fold-enter, .fold-leave-to { transform: perspective(200px) rotateY(-90deg); } // show ability tooltip on the left .ability { right: 120%; left: auto; &:before { border-right-color: transparent; border-left-color: black; right: auto; left: 100%; } } .pronouns { left: 110%; right: auto; &:before { border-left-color: transparent; border-right-color: black; left: auto; right: 100%; } } } @else { // second half of players z-index: $i - 1; } > * { transform: rotate($rot * -1deg); } // animation cascade .life, .token, .shroud, .night-order, .seat { animation-delay: ($i - 1) * 50ms; transition-delay: ($i - 1) * 50ms; } // move reminders closer to the sides of the circle $q: math.div($item-count, 4); $x: $i - 1; @if $x < $q or ($x >= math.div($item-count, 2) and $x < $q * 3) { .player { margin-bottom: -10% + 20% * (1 - math.div($x % $q, $q)); } } @else { .player { margin-bottom: -10% + 20% * math.div($x % $q, $q); } } } $rot: $rot + $angle; } } @for $i from 1 through 20 { .circle.size-#{$i} > li { @include on-circle($item-count: $i); } } /***** Demon bluffs / Fabled *******/ #townsquare > .bluffs, #townsquare > .fabled, #townsquare > .storytelling { position: absolute; left: 10px; &.bluffs { bottom: 10px; } &.fabled { top: 10px; } &.storytelling { bottom: 10px; left: auto; right: 10px; width: min-content; } background: rgba(0, 0, 0, 0.5); border-radius: 10px; border: 3px solid black; filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5)); transform-origin: bottom left; transform: scale(1); opacity: 1; transition: all 250ms ease-in-out; z-index: 50; > svg { position: absolute; top: 10px; right: 10px; cursor: pointer; &:hover { color: red; } } h3 { margin: 5px 1vh 0; display: flex; align-items: center; align-content: center; justify-content: center; span { flex-grow: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } svg { cursor: pointer; flex-grow: 0; &.fa-times-circle { margin-left: 1vh; } &.fa-plus-circle { margin-left: 1vh; display: none; } &:hover path { fill: url(#demon); stroke-width: 30px; stroke: white; } } } ul { display: flex; align-items: center; justify-content: center; li { width: 14vmin; height: 14vmin; margin: 0 0.5%; display: inline-block; transition: all 250ms; } } .button-group { transition: all 250ms; input { background: none; border: none; color: white; font-size: 1.1em; } } &.closed { svg.fa-times-circle { display: none; } svg.fa-plus-circle { display: block; } ul li { scale: 0; width: 0; height: 0; .night-order { opacity: 0; } .token { border-width: 0; } } .button-group, .button-group * { width: 0px; height: 0px; scale: 0; } } } #townsquare.public > .bluffs { opacity: 0; transform: scale(0.1); } .fabled ul li .token:before { content: " "; opacity: 0; transition: opacity 250ms; background-image: url("../assets/icons/x.png"); z-index: 2; } /**** Night reminders ****/ .night-order { position: absolute; width: 100%; cursor: pointer; opacity: 1; transition: opacity 200ms; display: flex; top: 0; align-items: center; pointer-events: none; &:after { content: " "; display: block; padding-top: 100%; } #townsquare.public & { opacity: 0; pointer-events: none; } &:hover ~ .token .ability { opacity: 0; } span { display: flex; position: absolute; padding: 5px 10px 5px 30px; width: 350px; z-index: 25; font-size: 70%; background: rgba(0, 0, 0, 0.5); border-radius: 10px; border: 3px solid black; filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5)); text-align: left; align-items: center; opacity: 0; transition: opacity 200ms ease-in-out; &:before { transform: rotate(-90deg); transform-origin: center top; left: -98px; top: 50%; font-size: 100%; position: absolute; font-weight: bold; text-align: center; width: 200px; } &:after { content: " "; border: 10px solid transparent; width: 0; height: 0; position: absolute; } } &.first span { right: 120%; background: linear-gradient( to right, $townsfolk 0%, rgba(0, 0, 0, 0.5) 20% ); &:before { content: v-bind(firstMessage); } &:after { border-left-color: $townsfolk; margin-left: 3px; left: 100%; } } &.other span { left: 120%; background: linear-gradient(to right, $demon 0%, rgba(0, 0, 0, 0.5) 20%); &:before { content: v-bind(otherMessage); } &:after { right: 100%; margin-right: 3px; border-right-color: $demon; } } em { font-style: normal; position: absolute; width: 40px; height: 40px; border-radius: 50%; border: 3px solid black; filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.5)); font-weight: bold; opacity: 1; pointer-events: all; transition: opacity 200ms; display: flex; justify-content: center; align-items: center; z-index: 3; } &.first em { left: -10%; background: linear-gradient(180deg, rgba(0, 0, 0, 1) 0%, $townsfolk 100%); } &.other em { right: -10%; background: linear-gradient(180deg, rgba(0, 0, 0, 1) 0%, $demon 100%); } em:hover + span { opacity: 1; } // adjustment for fabled .fabled &.first { span { right: auto; left: 40px; &:after { left: auto; right: 100%; margin-left: 0; margin-right: 3px; border-left-color: transparent; border-right-color: $townsfolk; } } } } #townsquare:not(.spectator) .fabled ul li:hover .token:before { opacity: 1; } </style>