townsquare/src/components/modals/EditionModal.vue
2024-12-04 23:46:55 +01:00

489 lines
13 KiB
Vue

<template>
<Modal class="editions" v-if="modals.edition" @close="toggleModal('edition')">
<h3>{{ locale.modal.edition.title }}</h3>
<ul>
<li class="tabs" :class="tab">
<span
class="tab"
icon="book-open"
@click="tab = 'official'"
:class="{ active: tab == 'official' }"
>{{ locale.modal.edition.tab.official }}</span
>
<span
class="tab"
icon="broadcast-tower"
@click="tab = 'popular'"
:class="{ active: tab == 'popular' }"
>{{ locale.modal.edition.tab.popular }}</span
>
<span
class="tab"
icon="question"
@click="tab = 'custom'"
:class="{ active: tab == 'custom' }"
>{{ locale.modal.edition.tab.custom }}</span
>
<span
class="tab"
icon="question"
@click="
initPool();
tab = 'build';
"
:class="{ active: tab == 'build' }"
>{{ locale.modal.edition.tab.build }}</span
>
</li>
<template v-if="tab == 'official'">
<ul class="editions">
<li
v-for="edition in editions.official"
class="edition"
:class="['edition-' + edition.id]"
:style="{
backgroundImage: `url(${require(
'../../assets/logos/' + edition.id + '.png',
)})`,
}"
:key="edition.id"
@click="runEdition(edition)"
>
{{ edition.name }}
</li>
</ul>
</template>
<template v-if="tab == 'popular'">
<ul class="scripts">
<li
v-for="(script, index) in editions.popular"
:key="index"
@click="handleURL(script[1])"
>
{{ script[0] }}
</li>
</ul>
<h3>{{ locale.modal.edition.tab.teensyville }}</h3>
<ul class="scripts">
<li
v-for="(script, index) in editions.teensyville"
:key="index"
@click="handleURL(script[1])"
>
{{ script[0] }}
</li>
</ul>
</template>
<template v-if="tab == 'custom'">
<div class="custom">
{{ locale.modal.edition.custom.introStart }}
<a href="https://script.bloodontheclocktower.com/" target="_blank">{{
locale.modal.edition.custom.scriptTool
}}</a>
{{ locale.modal.edition.custom.introEnd }}.<br />
<br />
{{ locale.modal.edition.custom.instructionsStart }}
<a
href="https://github.com/bra1n/townsquare#custom-characters"
target="_blank"
>{{ locale.modal.edition.custom.documentation }}n</a
>
{{ locale.modal.edition.custom.instructionsEnd }}<br />
<b>{{ locale.modal.edition.custom.warning }}</b>
<input
type="file"
ref="upload"
accept="application/json"
@change="handleUpload"
/>
</div>
<div class="button-group">
<div class="button" @click="openUpload">
<font-awesome-icon icon="file-upload" />
{{ locale.modal.edition.custom.upload }}
</div>
<div class="button" @click="promptURL">
<font-awesome-icon icon="link" />
{{ locale.modal.edition.custom.url }}
</div>
<div class="button" @click="readFromClipboard">
<font-awesome-icon icon="clipboard" />
{{ locale.modal.edition.custom.clipboard }}
</div>
</div>
</template>
<template v-if="tab == 'build'">
<section
v-for="team in teams"
:key="team"
class="build team"
:class="team"
>
<aside class="aside">
<div>
<h4>{{ locale.modal.reference.teamNames[team] }}</h4>
<strong>{{ selectedInTeam(team) }}</strong>
</div>
</aside>
<ul class="roles" :class="team">
<li
v-for="role in rolesForTeam(team)"
class="role"
:class="{ selected: role.selected }"
:key="role.id"
@click="toggleRole(role.id)"
>
<Token :role="role" />
</li>
</ul>
</section>
<div class="button-group">
<div class="button" @click="resetBuilt">
<font-awesome-icon :icon="['fas', 'trash-alt']" />
</div>
<div class="button" @click="initPool">
<font-awesome-icon :icon="['fas', 'redo-alt']" />
</div>
<div class="button" @click="randomizeBuilt">
<font-awesome-icon :icon="['fas', 'random']" />
</div>
<div class="button" @click="startBuilt">
<font-awesome-icon :icon="['fas', 'play']" />
</div>
</div>
</template>
</ul>
</Modal>
</template>
<script>
import { mapMutations, mapState } from "vuex";
import Modal from "./Modal";
import { rolesJSON } from "../../store/modules/locale";
import Token from "../Token";
export default {
components: {
Modal,
Token,
},
data() {
return {
tab: "official",
draftPool: rolesJSON,
teams: ["townsfolk", "outsider", "minion", "demon"],
recommendedTeamSize: {
townsfolk: 13,
outsider: 4,
minion: 4,
demon: 4,
},
};
},
computed: {
...mapState(["modals", "locale", "editions", "roles", "jinxes"]),
},
methods: {
initPool() {
console.log("init pool");
console.table(this.roles);
this.draftPool = rolesJSON;
this.resetBuilt();
for (let [role] of this.roles) {
this.toggleRole(role);
}
},
toggleRole(id) {
const role = this.draftPool.find((r) => r.id === id);
if (role) {
this.$set(role, "selected", !role.selected);
}
},
rolesForTeam(team) {
return this.draftPool?.filter((role) => role.team === team) ?? [];
},
selectedInTeam(team) {
return this.draftPool?.filter(
(role) => role.team === team && role.selected,
).length;
},
resetBuilt() {
for (let role of this.draftPool) {
this.$set(role, "selected", false);
}
},
randomizeBuilt() {
this.resetBuilt();
for (let team of this.teams) {
let currentPool = this.rolesForTeam(team);
for (let i = 0; i < this.recommendedTeamSize[team]; i++) {
let picked = currentPool.splice(
Math.floor(Math.random() * currentPool.length),
1,
)[0];
this.$set(picked, "selected", true);
}
}
},
startBuilt() {
const selected = this.draftPool.filter((role) => role.selected);
this.parseRoles(selected);
},
openUpload() {
this.$refs.upload.click();
},
handleUpload() {
const file = this.$refs.upload.files[0];
if (!file?.size) {
return;
}
const reader = new FileReader();
reader.addEventListener("load", () => {
try {
const uploadedRoles = JSON.parse(reader.result);
this.parseRoles(uploadedRoles);
} catch (e) {
alert(`Error reading custom script: ${e.message}`);
}
this.$refs.upload.value = "";
});
reader.readAsText(file);
},
promptURL() {
const url = prompt(this.locale.prompt.customUrl);
if (url) {
this.handleURL(url);
}
},
async handleURL(url) {
const res = await fetch(url);
if (res?.json) {
try {
const script = await res.json();
this.parseRoles(script);
} catch (e) {
alert(`${this.locale.prompt.customError}: ${e.message}`);
}
}
},
async readFromClipboard() {
const text = await navigator.clipboard.readText();
try {
const uploadedRoles = JSON.parse(text);
this.parseRoles(uploadedRoles);
} catch (e) {
alert(`Error reading custom script: ${e.message}`);
}
},
parseRoles(pickedRoles) {
if (!pickedRoles || !pickedRoles.length) return;
pickedRoles = pickedRoles.map((role) =>
typeof role === "string" ? { id: role } : role,
);
const metaIndex = pickedRoles.findIndex(({ id }) => id === "_meta");
const meta = metaIndex > -1 ? pickedRoles.splice(metaIndex, 1).pop() : {};
this.$store.commit("setCustomRoles", pickedRoles);
this.$store.commit("setEdition", { ...meta, id: "custom" });
// set fabled
const fabled = [];
let djinnAdded = false;
let djinnNeeded = false;
let bootleggerAdded = false;
let bootleggerNedded = false;
pickedRoles.forEach((role) => {
if (this.$store.state.fabled.has(role.id || role)) {
fabled.push(this.$store.state.fabled.get(role.id || role));
if ((role.id || role) == "djinn") {
djinnAdded = true;
} else if ((role.id || role) == "bootlegger") {
bootleggerAdded = true;
}
} else if (role.edition == "custom" || role.image) {
/* If the role isn't fabled, but detected as custom, we will need a Bootlegger
* NB: The actual version isn't perfect, since they only detect custom roles with an image or with the argument "edition":"custom".
* The code will could be changed later, when all non-custom roles will have an attribute "edition"
*/
bootleggerNedded = true;
}
// If the role isn't fabled, neither custom, and if we neither added a Djinn neither planned to add a Djinn, we look if this role is jinxed
else if (!djinnAdded && !djinnNeeded && this.jinxes.get(role.id)) {
this.jinxes.get(role.id).forEach((reason, second) => {
if (this.roles.get(second)) {
djinnNeeded = true;
}
});
}
});
if (djinnNeeded && !djinnAdded) {
fabled.push(this.$store.state.fabled.get("djinn"));
}
if (bootleggerNedded && !bootleggerAdded) {
fabled.push(this.$store.state.fabled.get("bootlegger"));
}
this.$store.commit("players/setFabled", { fabled });
},
runEdition(edition) {
this.$store.commit("setEdition", edition);
// The editions contain no Fabled
this.$store.commit("players/setFabled", { fabled: [] });
},
...mapMutations(["toggleModal", "setEdition"]),
},
};
</script>
<style scoped lang="scss">
@import "../../vars.scss";
ul {
width: 100%;
}
ul.editions {
.edition {
font-family: PiratesBay, sans-serif;
letter-spacing: 1px;
text-align: center;
padding-top: 15%;
background-position: center center;
background-size: 100% auto;
background-repeat: no-repeat;
width: 30%;
margin: 5px;
font-size: 120%;
text-shadow:
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000,
1px 1px 0 #000,
0 0 5px rgba(0, 0, 0, 0.75);
cursor: pointer;
&:hover {
color: red;
}
}
}
.build .role {
width: 4vmax;
opacity: 0.7;
&.selected {
opacity: 1;
}
}
.tabs {
display: flex;
padding: 0;
justify-content: flex-start;
width: 100%;
gap: 0.25rem;
border-bottom: 3px solid white;
.tab {
text-align: center;
flex-grow: 1;
flex-shrink: 0;
height: 35px;
border: 1px solid grey;
border-radius: 5px 5px 0 0;
padding: 0.15em 1em;
cursor: pointer;
transition: color 250ms;
user-select: none;
&:hover {
color: red;
}
&.active {
background: linear-gradient(
rgb(31, 101, 255) 0%,
rgba(0, 0, 0, 0.5) 100%
);
}
}
}
.custom {
text-align: center;
margin-block: 1em;
}
input[type="file"] {
display: none;
}
.scripts {
margin-block: 1em;
list-style-type: disc;
font-size: 120%;
cursor: pointer;
display: flex;
gap: 0.75em 1em;
justify-content: flex-start;
li {
text-align: left;
list-style-type: none;
border: 1px solid white;
border-radius: 100vmax;
padding: 0.15em 1.5em;
background: linear-gradient(#4e4e4e, #040404);
user-select: none;
&:hover {
color: red;
}
}
}
.townsfolk {
aside {
background: linear-gradient(-90deg, $townsfolk, transparent);
}
}
.outsider {
aside {
background: linear-gradient(-90deg, $outsider, transparent);
}
}
.minion {
aside {
background: linear-gradient(-90deg, $minion, transparent);
}
}
.demon {
aside {
background: linear-gradient(-90deg, $demon, transparent);
}
}
.team {
display: grid;
width: 100%;
grid-template-columns: 3rem 1fr;
aside {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-transform: uppercase;
font-size: 0.7rem;
strong {
display: block;
font-size: 1.4rem;
}
div {
font-size: 1.1rem;
text-align: center;
rotate: 90deg;
}
h4 {
margin-block: 0.25rem;
}
}
.roles {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
padding-left: 1rem;
padding-block: 0.25rem;
overflow: hidden;
}
}
</style>