First commit

This commit is contained in:
2026-03-27 10:14:29 +03:00
commit ad29150770
10404 changed files with 962562 additions and 0 deletions

0
.gbf-menu-hover Normal file
View File

340
Replicard.vue Normal file
View File

@@ -0,0 +1,340 @@
<template>
<div class="flex flex-col">
<h1 class="self-center mb-8">Replicard Sandbox Maps</h1>
<div class="flex flex-row flex-wrap mb-4 items-center gap-4">
<button class="btn" :class="show_help ? 'btn-blue' : 'btn-white'" @click="show_help = ! show_help">
<fa-icon :icon="['fas', 'info-circle']" class="text-xl"></fa-icon> Usage
</button>
<checkbox v-model="show_loot">Show loot</checkbox>
<checkbox v-model="show_progression">Show progression</checkbox>
<button class="btn btn-blue" v-if="show_progression && isUserLogged" @click="save()">
<fa-icon :icon="['fas', 'save']" class="text-xl"></fa-icon> Save progression
</button>
</div>
<!-- Usage -->
<div class="bg-secondary self-center rounded p-4 mb-2" v-if="show_help">
<h2>Number of fights</h2>
<p class="pb-4">
The number above each circle represents the number of fights needed to guarantee the appearance of a Defender.
</p>
<h2>To fight the Boss</h2>
<p class="pb-4">
To fight the Boss of each zone, you need components dropped by Defenders.<br>
Chain fights of a node until the Defender appears.<br>
Each component is indicated in parentheses after the color of its Defender.
</p>
<h2>Track your progression</h2>
<p class="pb-4">
Click on the stars to track your progression.<br>
Each node has 3 quests. Add a star when you complete the corresponding quest.<br>
Mouseover the stars to see the name of the monster.
</p>
</div>
<!-- Tabs -->
<span class="flex flex-col w-full">
<div class="self-start flex flex-row flex-wrap border-primary border-b font-bold w-full">
<a
v-for="(zone, letter) in getZones"
:key="letter"
@click="show_tab = letter"
class="px-4 py-2 text-primary cursor-pointer rounded-t"
:class="show_tab === letter ? 'bg-secondary' : ''"
>
{{ zone.name }}
</a>
</div>
<div v-if="currentZone.boss4">
<div><span class="text-pink-500">Pink:</span> {{ currentZone.boss1 }} (Gospel of Egeiro)</div>
<div><span class="text-emerald-500">Green:</span> {{ currentZone.boss2 }} (Gospel of Genea)</div>
<div><span class="text-amber-500">Yellow:</span> {{ currentZone.boss3 }} (Gospel of Thysia)</div>
<div><span class="text-blue-500">Blue:</span> {{ currentZone.boss4 }} (Gospel of Analipsis)</div>
</div>
<div v-else-if="currentZone.boss3">
<div><span class="text-pink-500">Pink:</span> {{ currentZone.boss1 }} (Organ)</div>
<div><span class="text-emerald-500">Green:</span> {{ currentZone.boss2 }} (Rib)</div>
<div><span class="text-amber-500">Yellow:</span> {{ currentZone.boss3 }} (Core)</div>
</div>
<div v-else>
<div><span class="text-pink-500">Pink:</span> {{ currentZone.boss1 }} (Invocation)</div>
<div><span class="text-emerald-500">Green:</span> {{ currentZone.boss2 }} (Masquerade)</div>
</div>
<div class="relative bg-primary overflow-x-auto">
<picture v-if="show_loot" class="block" :style="currentZoneWidth">
<source type="image/webp" :srcset="'/img/arcarum/replicard_' + show_tab + '_loot.webp'">
<img :src="'/img/arcarum/replicard_' + show_tab + '_loot.png'">
</picture>
<picture :class="show_loot ? 'absolute top-0 left-0' : ''" :style="currentZoneWidth">
<source type="image/webp" :srcset="'/img/arcarum/replicard_' + show_tab + '.webp'">
<img :src="'/img/arcarum/replicard_' + show_tab + '.png'">
</picture>
<span
v-if="show_progression"
class="absolute top-0 left-0"
:style="currentZoneWidth"
>
<div class="relative">
<stars-line
v-for="(star, index) in currentZone.stars"
:key="index"
class="absolute bg-black"
:style="'top: ' + star.top + 'px; left: ' + star.left + 'px;'"
:base="2"
:extra="3"
:current.sync="progression[show_tab][index]"
:max="3"
:title="star.name"
></stars-line>
</div>
</span>
</div>
</span>
</div>
</template>
<script>
import Utils from '@/js/utils.js'
import Checkbox from '@/components/common/Checkbox.vue'
import StarsLine from '@/components/StarsLine.vue'
import replicardMixin from '@/store/modules/replicard'
const lsMgt = new Utils.LocalStorageMgt('Replicard');
const ZONES = {
'E': {
name: 'Eletio',
boss1: 'The Devil',
boss2: 'The Sun',
boss3: 'The Star',
width: '1377',
stars: [
{top: 140, left: 45, name: 'Slithering Seductress'},
{top: 135, left: 322, name: 'Living Lightning Rod'},
{top: 265, left: 272, name: 'Eletion Drake'},
{top: 142, left: 553, name: 'Paradoxical Gate'},
{top: 128, left: 685, name: 'Blazing Everwing'},
{top: 208, left: 812, name: 'Death Seer'},
{top: 66, left: 965, name: 'Hundred-Armed Hulk'},
{top: 193, left: 1135, name: 'Terror Trifecta'},
{top: 265, left: 1125, name: 'Rageborn One'},
{top: 135, left: 1280, name: 'Eletion Glider'}
]
},
'F': {
name: 'Faym',
boss1: 'Justice',
boss2: 'The Moon',
boss3: 'Death',
width: '1377',
stars: [
{top: 146, left: 30, name: 'Trident Grandmaster'},
{top: 206, left: 167, name: 'Hoarfrost Icequeen'},
{top: 263, left: 352, name: 'Oceanic Archon'},
{top: 146, left: 508, name: 'Farsea Predator'},
{top: 206, left: 646, name: 'Faymian Fortress'},
{top: 146, left: 791, name: 'Draconic Simulacrum'},
{top: 146, left: 993, name: 'Azureflame Dragon'},
{top: 263, left: 1091, name: 'Eye of Sorrow'},
{top: 149, left: 1275, name: 'Mad Shearwielder'},
{top: 154, left: 1132, name: 'Faymian Gun'}
]
},
'G': {
name: 'Goliath',
boss1: 'The Hanged Man',
boss2: 'The Tower',
boss3: 'Death',
width: '1377',
stars: [
{top: 257, left: 164, name: 'Avatar of Avarice'},
{top: 29, left: 237, name: 'Temptation\'s Guide'},
{top: 143, left: 285, name: 'World\'s Veil'},
{top: 138, left: 592, name: 'Bloodstained Barbarian'},
{top: 257, left: 563, name: 'Goliath Keeper'},
{top: 120, left: 810, name: 'Frenzied Howler'},
{top: 141, left: 963, name: 'Vestige of Truth'},
{top: 257, left: 857, name: 'Goliath Vanguard'},
{top: 194, left: 1085, name: 'Writhing Despair'},
{top: 198, left: 1282, name: 'Goliath Triune'}
]
},
'H': {
name: 'Harbinger',
boss1: 'Temperance',
boss2: 'Judgement',
boss3: 'The Star',
width: '1377',
stars: [
{top: 174, left: 29, name: 'Dirgesinger'},
{top: 34, left: 208, name: 'Vengeful Demigod'},
{top: 250, left: 159, name: 'Wildwind Conjurer / Fullthunder Conjurer'},
{top: 146, left: 334, name: 'Harbinger Simurgh'},
{top: 260, left: 486, name: 'Harbinger Hardwood'},
{top: 188, left: 583, name: 'Demanding Stormgod'},
{top: 143, left: 960, name: 'Harbinger Tyrant'},
{top: 256, left: 1099, name: 'Phantasmagoric Aberration'},
{top: 186, left: 1220, name: 'Dimentional Riftwalker'},
{top: 48, left: 670, name: 'Harbinger Stormer'}
]
},
'I': {
name: 'Invidia',
boss1: 'The Sun / The Devil',
boss2: 'The Star',
width: '884',
stars: [
]
},
'J': {
name: 'Joculator',
boss1: 'The Moon / Justice',
boss2: 'Death',
width: '884',
stars: [
]
},
'K': {
name: 'Kalendae',
boss1: 'Death',
boss2: 'The Hanged Man / The Tower',
width: '884',
stars: [
]
},
'L': {
name: 'Liber',
boss1: 'Temperance / Judgment',
boss2: 'The Star',
width: '884',
stars: [
]
},
'M': {
name: 'Mundus',
boss1: ' ',
boss2: ' ',
boss3: ' ',
boss4: ' ',
width: 960,
stars: []
}
}
export default {
components: {
Checkbox,
StarsLine
},
mixins: [
replicardMixin
],
head: {
title: 'Granblue.Party - Replicard Sandbox Maps',
desc: 'View Replicard Sandbox Maps with loots, colors, and progression for each node',
image: 'https://www.granblue.party/img/card_replicard.jpg',
keywords: 'Replicard, Sandbox, Maps, Arcarum, Evoker, Eletio, Faym, Goliath, Harbinger, Invidia, Joculator, Kalendae, Liber, Mundus'
},
data() {
return {
show_help: false,
show_loot: true,
show_progression: true,
scrollbar: true,
show_tab: 'E',
};
},
methods: {
save() {
this.axios.post('/replicard/save', this.progression)
.then(response => this.$store.dispatch('addMessage', {message: 'Data saved successfully'}))
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
},
loadServerData() {
if (this.data_fetched === false) {
return this.axios.get('/replicard/load')
.then(response => {
if (response.data !== null) {
this.$set(this, 'progression', response.data);
this.data_fetched = true;
}
})
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
}
},
loadData() {
if (this.isUserLogged) {
this.loadServerData();
}
else {
this.$store.commit('replicard/resetReplicard');
const progression = lsMgt.fetchValue('progression');
if (progression !== undefined) {
this.progression = progression;
}
}
},
},
computed: {
isUserLogged() {
return this.$store.getters.getUserId !== null;
},
getZones() {
return ZONES;
},
currentZone() {
return this.getZones[this.show_tab];
},
currentZoneWidth() {
return "min-width: " + this.currentZone.width + "px;";
},
progression: {
get() { return this.$store.state.replicard.progression },
set(value) { this.$store.commit('replicard/setReplicardData', value) }
},
data_fetched: {
get() { return this.$store.state.replicard.data_fetched },
set(value) { this.$store.commit('replicard/setReplicardFetched', value) }
},
},
watch: {
'$store.getters.getUserId'() {
this.data_fetched = false;
this.loadData();
},
show_loot() {
lsMgt.setValue('show_loot', this);
},
show_tab() {
lsMgt.setValue('show_tab', this);
},
progression: {
handler() {
if ( ! this.isUserLogged) {
lsMgt.setValue('progression', this);
}
},
deep: true
},
},
serverPrefetch() {
if (this.isUserLogged) {
return this.loadServerData();
}
},
mounted() {
lsMgt.getValue(this, 'show_loot');
lsMgt.getValue(this, 'show_tab');
this.loadData();
},
};
</script>

12
app.js Normal file
View File

@@ -0,0 +1,12 @@
import Vue from "vue"
import App from "./src/App.vue"
import Vuex from "vuex"
import store from "./src/store"
Vue.use(Vuex)
new Vue({
el: "#app",
store,
render: h => h(App)
})

63
build.js Normal file
View File

@@ -0,0 +1,63 @@
const esbuild = require("esbuild")
const fs = require("fs")
const { parse, compileTemplate } = require("@vue/compiler-sfc")
esbuild.build({
entryPoints: ["app.js"],
bundle: true,
outfile: "dist.js",
format: "iife",
platform: "browser",
define: {
"process.env.NODE_ENV": '"production"'
},
plugins: [
{
name: "vue2-sfc",
setup(build) {
build.onLoad({ filter: /\.vue$/ }, async (args) => {
const source = fs.readFileSync(args.path, "utf8")
const parsed = parse(source)
if (!parsed.descriptor) {
return { contents: "" }
}
const descriptor = parsed.descriptor
let scriptCode = ""
if (descriptor.script && descriptor.script.content) {
scriptCode = descriptor.script.content
}
let templateCode = ""
if (descriptor.template && descriptor.template.content) {
const compiled = compileTemplate({
source: descriptor.template.content,
filename: args.path,
id: args.path
})
templateCode = compiled.code
}
return {
contents: `
${scriptCode}
${templateCode}
`
}
})
}
}
]
}).catch(() => process.exit(1))

100
components/BoxCharacter.vue Normal file
View File

@@ -0,0 +1,100 @@
<template>
<div class="flex flex-col" style="min-width: 78px; max-width: 78px;">
<!-- Title -->
<a
class="text-xs text-primary h-5 px-1 text-center truncate"
target="_blank"
:href="'https://gbf.wiki/' + object.nameen"
:title="getName"
v-if="! objectIsEmpty"
>{{ getName }}</a>
<span class="text-xs h-5" v-else> </span>
<!-- Portrait -->
<portrait
:object="object"
:showRing="showRing"
@drag-portrait="$emit('drag-portrait', $event)"
@drop-portrait="drop"
@click-portrait="$emit('click-portrait')"
@click-pring="clickPRing"
@stars-changed="starsChanged"
></portrait>
<!-- Stats -->
<div class="flex flex-row flex-nowrap justify-around text-xs" v-if="! objectIsEmpty && showLevel">
<stat-input shortName="lvl" longName="Character level" :prop.sync="object.level" :length="3"></stat-input>
<stat-input shortName="+" longName="Plus bonuses" :prop.sync="object.pluses" :length="3"></stat-input>
</div>
<!-- Skills -->
<skills
:object="object"
@click-skill="$emit('click-skill', $event)"
></skills>
</div>
</template>
<script>
import { objectIsEmpty, getName } from "@/js/mixins"
import { mapState } from 'vuex'
import UtilsParty from '@/js/utils-party'
import Portrait from '@/components/BoxCharacterPortrait.vue'
import Skills from '@/components/BoxCharacterSkills.vue'
import StatInput from '@/components/common/StatInput.vue'
export default {
components: {
Portrait,
Skills,
StatInput,
},
mixins: [
objectIsEmpty,
getName
],
props: {
object: {
type: Object,
required: true
},
showLevel: {
type: Boolean,
required: true
},
showRing: {
type: Boolean,
default: false,
},
},
methods: {
drop(ev) {
const data = ev.dataTransfer.getData("character");
if (data.length > 0) {
this.$emit('swap', JSON.parse(data));
}
},
clickPRing() {
if (this.party_mode !== this.$MODE.ReadOnly) {
this.object.haspring = ! this.object.haspring
}
},
starsChanged(object, count) {
this.$set(object, "stars", count);
if ( ! this.showLevel || object.level > this.getLevel) {
this.$set(object, "level", this.getLevel);
}
},
},
computed: {
...mapState({
party_mode: state => state.party_builder.party_mode
}),
getLevel() {
return UtilsParty.getCharacterLevel(this.object);
}
},
}
</script>

View File

@@ -0,0 +1,83 @@
<template>
<span class="relative">
<img
class="w-full"
:class="party_mode === $MODE.Edit ? 'cursor-pointer' : ''"
style="min-height: 142px; max-height: 142px;"
:draggable="! objectIsEmpty"
:src="getImage"
@click="$emit('click-portrait')"
@dragstart="$emit('drag-portrait', $event)"
@dragover.prevent
@drop.prevent="$emit('drop-portrait', $event)"
>
<stars-line
v-if="! objectIsEmpty"
class="absolute top-0 bg-black/50"
:base="object.starsbase"
:extra="object.starsmax"
:current="object.stars"
@update:current="$emit('stars-changed', object, $event)"
:max="5"
:transcendance="true"
:readOnly="party_mode === $MODE.ReadOnly"
></stars-line>
<img
v-if="! objectIsEmpty && showRing"
class="absolute bottom-0 right-0"
:class="getPRingClasses"
src="/img/icon_pring.png"
title="Perpetuity Ring"
@click="$emit('click-pring')"
>
</span>
</template>
<script>
import { objectIsEmpty } from "@/js/mixins"
import { mapState } from 'vuex'
import StarsLine from '@/components/StarsLine.vue'
export default {
components: {
StarsLine,
},
mixins: [
objectIsEmpty
],
props: {
object: {
type: Object,
required: true
},
showRing: {
type: Boolean,
default: false,
},
},
computed: {
...mapState({
party_mode: state => state.party_builder.party_mode
}),
getPRingClasses() {
let classes = this.object.haspring ? '' : 'grayscale-80 opacity-70';
if (this.party_mode !== this.$MODE.ReadOnly) {
classes += ' cursor-pointer';
}
return classes;
},
getImage() {
if (this.objectIsEmpty) {
if (this.party_mode !== this.$MODE.Edit) {
return '/img/empty_chara_ro.jpg';
}
return '/img/empty_chara.jpg';
}
return "/img/unit/" + this.object.characterid + "000.jpg";
},
},
}
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="flex flex-row flex-wrap" style="min-height: 78px; max-height: 78px;" v-if="! objectIsEmpty">
<span v-for="skill in getSkills" :key="skill.index" class="w-1/2 tooltip-parent">
<img
class="w-full"
:class="party_mode === $MODE.Action ? 'cursor-pointer' : ''"
:src="getSkillImage(skill.index)"
@click="$emit('click-skill', skill.index)"
>
<span class="tooltip">{{ skill.name }}</span>
</span>
</div>
</template>
<script>
import { objectIsEmpty } from "@/js/mixins"
import { mapState } from 'vuex'
export default {
mixins: [
objectIsEmpty
],
props: {
object: Object,
},
methods: {
getSkillImage(index) {
return '/img/chara_skills/' + this.object.characterid + '_' + index + '.png';
},
},
computed: {
...mapState({
party_mode: state => state.party_builder.party_mode
}),
getSkills() {
return this.object.skills.flatMap(s => {
if (this.object.level >= s.obtain) {
return [s];
}
return [];
})
},
}
}
</script>

68
components/BoxClass.vue Normal file
View File

@@ -0,0 +1,68 @@
<template>
<div class="flex flex-col" style="min-width: 78px; max-width: 78px;">
<!-- Title -->
<a
class="text-xs text-primary rounded-t h-5 px-1 text-center truncate"
target="_blank"
:href="'https://gbf.wiki/' + object.nameen"
:title="object.nameen"
>{{ object.nameen }}</a>
<!-- Portrait -->
<img
:class="party_mode === $MODE.Edit ? 'cursor-pointer' : ''"
style="min-height: 142px; max-height: 142px;"
:src="getPortraitImage"
@click="$emit('click-portrait')">
<!-- Skills -->
<div class="flex flex-row flex-wrap">
<span v-for="(skill, skillIndex) in object.skills" :key="skillIndex" class="w-1/2 tooltip-parent">
<img
class="w-full"
:class="party_mode !== $MODE.ReadOnly ? 'cursor-pointer' : ''"
:src="getSkillImage(skillIndex)"
@click="$emit('click-skill', skillIndex)"
>
<span v-if="skill" class="tooltip">{{ skill.nameen }}</span>
</span>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
import Utils from '@/js/utils.js'
export default {
props: {
object: {
type: Object,
required: true
},
},
methods: {
getSkillImage(index) {
if (this.object.skills !== undefined && this.object.skills[index] !== null) {
return '/img/class_skills/' + this.object.skills[index].skillid + '.png';
}
return '/img/class_skills/null.png';
},
},
computed: {
...mapState({
party_mode: state => state.party_builder.party_mode
}),
getPortraitImage() {
if (Utils.isEmpty(this.object.nameen)) {
if (this.party_mode !== this.$MODE.Edit) {
return '/img/empty_chara_ro.jpg';
}
return '/img/empty_chara.jpg';
}
return '/img/class/' + this.object.nameen.replace(/\s/g, '_') + '.jpg';
},
},
}
</script>

92
components/BoxSummon.vue Normal file
View File

@@ -0,0 +1,92 @@
<template>
<div class="flex flex-col" style="min-width: 110px; max-width: 110px;">
<!-- Title -->
<a
class="text-xs text-primary hover:text-primary rounded-t h-5 px-1 text-center truncate"
target="_blank"
:href="'https://gbf.wiki/' + object.nameen"
:style="getTitleColor"
:title="getName"
v-if="! objectIsEmpty"
>{{ getName }}</a>
<span class="text-xs h-5" v-else> </span>
<!-- Portrait -->
<portrait
:object="object"
@drag-portrait="$emit('drag-portrait', $event)"
@drop-portrait="drop"
@click-portrait="$emit('click-portrait')"
@stars-changed="starsChanged"
></portrait>
<!-- Stats -->
<div class="flex flex-row flex-nowrap justify-around text-xs" v-if="! objectIsEmpty && showLevel">
<stat-input shortName="lvl" longName="Summon level" :prop.sync="object.level" :length="3"></stat-input>
<stat-input shortName="+" longName="Plus bonuses" :prop.sync="object.pluses" :length="3"></stat-input>
</div>
</div>
</template>
<script>
import { objectIsEmpty, getName } from "@/js/mixins"
import UtilsParty from '@/js/utils-party'
import Portrait from '@/components/BoxSummonPortrait.vue'
import StatInput from '@/components/common/StatInput.vue'
export default {
components: {
StatInput,
Portrait
},
mixins: [
objectIsEmpty,
getName
],
props: {
object: {
type: Object,
required: true
},
showLevel: {
type: Boolean,
default: false,
},
},
methods: {
drop(ev) {
const data = ev.dataTransfer.getData("summon");
if (data.length > 0) {
this.$emit('swap', JSON.parse(data));
}
},
starsChanged(count) {
this.$set(this.object, "stars", count);
if ( ! this.showLevel || this.object.level > this.getLevel) {
this.$set(this.object, "level", this.getLevel);
}
UtilsParty.setSummonCurrentData(this.object);
},
},
computed: {
getTitleColor() {
switch (this.object.stars) {
case 3:
return 'color: #ffa826;';
case 4:
return 'color: #e3b7ff;';
case 5:
return 'color: #a1e3ff;';
default:
return '';
}
},
getLevel() {
return UtilsParty.getSummonLevel(this.object);
}
},
}
</script>

View File

@@ -0,0 +1,76 @@
<template>
<span class="relative tooltip-parent">
<img
class="w-full"
:class="party_mode !== $MODE.ReadOnly ? 'cursor-pointer' : ''"
style="min-height: 83px; max-height: 83px;"
:draggable="! objectIsEmpty"
:src="getImage"
@click="tryToEmit('click-portrait')"
@dragstart="tryToEmit('drag-portrait', $event)"
@dragover.prevent
@drop.prevent="tryToEmit('drop-portrait', $event)"
>
<stars-line
v-if="! objectIsEmpty"
class="absolute bottom-0 right-0 w-4/5 bg-black/50"
:base="object.starsbase"
:extra="object.starsmax"
:current="object.stars"
@update:current="$emit('stars-changed', $event)"
:max="5"
:transcendance="true"
:readOnly="party_mode === $MODE.ReadOnly"
></stars-line>
<span class="tooltip" v-if="object.current_data !== undefined">
<span class="font-mono text-xs" v-for="(data, index) in object.data[object.current_data]" :key="index">
<span class="capitalize">{{ data.aura_type }}</span> {{ data.stat }} {{ data.percent }}% <span v-if="data.slot">({{ data.slot }})</span><br>
</span>
</span>
</span>
</template>
<script>
import { objectIsEmpty } from "@/js/mixins"
import { mapState } from 'vuex'
import StarsLine from '@/components/StarsLine.vue'
export default {
components: {
StarsLine,
},
mixins: [
objectIsEmpty
],
props: {
object: {
type: Object,
required: true
},
},
methods: {
tryToEmit(name, event) {
if (this.party_mode !== this.$MODE.ReadOnly) {
this.$emit(name, event);
}
}
},
computed: {
...mapState({
party_mode: state => state.party_builder.party_mode
}),
getImage() {
if (this.objectIsEmpty) {
if (this.party_mode === this.$MODE.Edit) {
return '/img/empty_summon.jpg';
}
return '/img/empty_summon_ro.jpg';
}
return "/img/unit/" + this.object.summonid + "000.jpg";
},
}
}
</script>

178
components/BoxWeapon.vue Normal file
View File

@@ -0,0 +1,178 @@
<template>
<div class="flex flex-col" style="max-width: 105px;">
<!-- Title -->
<a
class="text-xs text-primary h-5 px-1 text-center truncate"
target="_blank"
:href="'https://gbf.wiki/' + object.nameen"
:title="getName"
v-if="! objectIsEmpty"
>{{ getName }}</a>
<span class="text-xs h-5" v-else> </span>
<!-- Portrait -->
<portrait
:object="object"
:isArcarum="isArcarum"
@click-portrait="$emit('click-portrait')"
@drag-portrait="$emit('drag-portrait', $event)"
@drop-portrait="drop"
@stars-changed="starsChanged"
></portrait>
<!-- Stats -->
<div class="flex flex-row flex-nowrap justify-around text-xs" v-if="! objectIsEmpty && showLevel">
<stat-input shortName="lvl" longName="Weapon level" :prop.sync="object.level" :length="3"></stat-input>
<stat-input shortName="sl" longName="Skill level" :prop.sync="object.sklevel" :length="2"></stat-input>
<stat-input shortName="+" longName="Plus bonuses" :prop.sync="object.pluses" :length="3"></stat-input>
</div>
<!-- Skills -->
<skills :object="object" :skills="getSkills"></skills>
</div>
</template>
<script>
import { objectIsEmpty, getName } from "@/js/mixins"
import Utils from '@/js/utils'
import UtilsParty from '@/js/utils-party'
import Portrait from '@/components/BoxWeaponPortrait.vue'
import Skills from '@/components/BoxWeaponSkills.vue'
import StatInput from '@/components/common/StatInput.vue'
export default {
components: {
Portrait,
Skills,
StatInput,
},
mixins: [
objectIsEmpty,
getName
],
props: {
object: {
type: Object,
required: true
},
skills: {
type: Array,
required: true
},
showLevel: {
type: Boolean,
default: false,
},
isArcarum: {
type: Boolean,
default: false,
}
},
methods: {
drop(ev) {
const data = ev.dataTransfer.getData("weapon");
if (data.length > 0) {
this.$emit('swap', JSON.parse(data));
}
},
starsChanged(object, count) {
this.$set(object, "stars", count);
if ( ! this.showLevel || object.level > this.getLevel) {
this.$set(object, 'level', this.getLevel);
}
if ( ! this.showLevel || object.sklevel > this.getSkillLevel) {
this.$set(object, 'sklevel', this.getSkillLevel);
}
},
getWeaponSkillValue(skill_level, percent, stat) {
let ratio = percent[1];
if (stat === 'stamina') {
if (this.getPercentHP < 25) {
return 0;
}
if (skill_level <= 15) {
return (Math.pow(this.getPercentHP / (ratio - skill_level), 2.9) + 2.1) / 100;
}
else {
return (Math.pow(this.getPercentHP / (ratio - (15 + (0.4 * (skill_level-15)))), 2.9) + 2.1) / 100;
}
}
if (skill_level === 20 && percent.hasOwnProperty(20)) {
ratio = percent[20];
}
else {
if (skill_level > 1 && percent.hasOwnProperty(10)) {
ratio += (percent[10] - percent[1]) / 9 * Math.min(skill_level - 1, 9);
}
if (skill_level > 10 && percent.hasOwnProperty(15)) {
ratio += (percent[15] - percent[10]) / 5 * Math.min(skill_level - 10, 5);
}
if (skill_level > 15 && percent.hasOwnProperty(20)) {
ratio += (percent[20] - percent[15]) / 5 * Math.min(skill_level - 15, 5);
}
}
if (stat === 'enmity') {
const missing_hp_ratio = 1 - this.getPercentHP / 100;
return (ratio / 100) * ((1 + 2 * missing_hp_ratio) * missing_hp_ratio);
}
return ratio / 100;
},
},
computed: {
getPercentHP() {
return this.$store.state.party_builder.percent_HP;
},
getLevel() {
return UtilsParty.getWeaponLevel(this.object);
},
getSkillLevel() {
return UtilsParty.getWeaponSkillLevel(this.object);
},
getSkills() {
if (this.object.skills === undefined) {
return [];
}
let skills = this.object.skills.flatMap((slot, index) => {
let skill = [];
for (let item of slot) {
if (item.level > this.object.level) {
break;
}
if (this.object.keys[index] !== null) {
// Skill key selected
skill = this.object.keys[index];
break;
}
else {
skill = item;
}
}
return skill;
});
// This is probably not optimal, since level change triggers this for nothing
skills.forEach(s => {
if (s.data) {
for (let d of s.data) {
this.$set(d, 'value', this.getWeaponSkillValue(this.object.sklevel, d.percent, d.stat));
}
}
});
// Keep in the store
for (let i=0; i<skills.length; i++) {
this.$set(this.skills, i, skills[i]);
}
return skills;
}
},
}
</script>

View File

@@ -0,0 +1,69 @@
<template>
<span class="relative">
<img
class="w-full"
:class="party_mode === $MODE.Edit ? 'cursor-pointer' : ''"
style="max-height: 60px;"
:draggable="! objectIsEmpty"
:src="getImage"
@click="$emit('click-portrait')"
@dragstart="$emit('drag-portrait', $event)"
@dragover.prevent
@drop.prevent="$emit('drop-portrait', $event)"
>
<stars-line
v-if="! objectIsEmpty"
class="absolute bottom-0 right-0 w-3/4 bg-black/50"
:base="object.starsbase"
:extra="object.starsmax"
:current="object.stars"
@update:current="$emit('stars-changed', object, $event)"
:max="5"
:readOnly="party_mode === $MODE.ReadOnly"
></stars-line>
</span>
</template>
<script>
import { objectIsEmpty } from "@/js/mixins"
import { mapState } from 'vuex'
import StarsLine from '@/components/StarsLine.vue'
export default {
components: {
StarsLine,
},
mixins: [
objectIsEmpty
],
props: {
object: {
type: Object,
required: true
},
isArcarum: {
type: Boolean,
default: false,
}
},
computed: {
...mapState({
party_mode: state => state.party_builder.party_mode
}),
getImage() {
if (this.objectIsEmpty) {
if (this.party_mode !== this.$MODE.Edit) {
return '/img/empty_weapon_ro.jpg';
}
if (this.isArcarum) {
return '/img/empty_weapon_arcarum.jpg';
}
return '/img/empty_weapon.jpg';
}
return '/img/weapon/' + this.object.weaponid + '00.jpg';
},
}
}
</script>

View File

@@ -0,0 +1,72 @@
<template>
<div class="flex flex-row flex-nowrap justify-around" v-if="! objectIsEmpty">
<span v-for="(skill, skillIndex) in skills" :key="skillIndex" class="tooltip-parent">
<img
:class="(skill.keyid !== null && party_mode !== $MODE.ReadOnly) ? 'cursor-pointer' : ''"
style="width: 30px; height: 30px;"
:src="'/img/weapon_skills/' + skill.icon"
@click="showKeyModal(skillIndex, skill.keyid)"
>
<span class="tooltip">
{{ skill.name }} <span v-if="skill.boost">({{ skill.boost }})</span><br>
<p v-if="skill.data !== null">
<span class="font-mono text-xs" v-for="(data, dataIndex) in skill.data" :key="dataIndex">
<span class="capitalize">{{ data.aura_type }}</span> {{ data.stat }} {{ (data.value * 100).toFixed(2) }}%<br>
</span>
</p>
</span>
</span>
<!-- Modal -->
<modal-keys
v-if="party_mode !== $MODE.ReadOnly"
v-model="show_modal_keys"
:keyId="modal_key_id"
@key-selected="selectKey"
></modal-keys>
</div>
</template>
<script>
import { objectIsEmpty } from "@/js/mixins"
import { mapState } from 'vuex'
import ModalKeys from '@/components/ModalKeys.vue'
export default {
components: {
ModalKeys,
},
mixins: [
objectIsEmpty
],
props: {
object: Object,
skills: Array,
},
data() {
return {
show_modal_keys: false,
modal_skill_index: 0,
modal_key_id: 0,
}
},
methods: {
showKeyModal(index, keyid) {
if (keyid !== null && this.party_mode !== this.$MODE.ReadOnly) {
this.modal_skill_index = index;
this.modal_key_id = keyid;
this.show_modal_keys = true;
}
},
selectKey(key) {
this.$set(this.object.keys, this.modal_skill_index, key);
}
},
computed: {
...mapState({
party_mode: state => state.party_builder.party_mode
}),
}
}
</script>

View File

@@ -0,0 +1,118 @@
<template>
<div class="flex flex-col flex-wrap gap-4 items-center w-full">
<a
class="btn-blue rounded-t font-bold hover:text-primary cursor-pointer p-2"
:class="show ? '' : 'rounded-b'"
@click="show = !show"
title="Click to toggle..."
>
{{ header }}
<fa-icon v-if=" ! show" :icon="['fas', 'angle-right']" class="ml-2"></fa-icon>
<fa-icon v-else :icon="['fas', 'angle-down']" class="ml-2"></fa-icon>
</a>
<div v-if="show" class="flex flex-col w-full items-center" >
<span class="mb-4">{{ explainer }}</span>
<div v-for="bullets in getBullets" class="flex flex-col bg-secondary p-2 w-full lg:w-1/2 items-center rounded">
<h2 class="mb-4">{{ bullets.name }}</h2>
<div :class="displayList === 0 ? 'flex flex-row flex-wrap' : 'grid grid-cols-1 sm:grid-cols-2'">
<div v-for="bullet in bullets.bullets" class="tooltip-parent">
<div v-if="displayList === 0">
<img :src="'/img/item/' + bullet.image + '.jpg'" width="60px" :title="bullet.name" :class="getBullet(bullet) > 0 ? '' : 'grayscale-80'">
<div class="flex flex-row justify-around">
<a :class="getBullet(bullet) > 0 ? 'cursor-pointer' : ''" @click="removeBullet(bullet)">
<fa-icon
:icon="['fa', 'minus-circle']"
class="text-sm"
:class="getBullet(bullet) > 0 ? 'text-primary hover:text-link-hover' : 'text-transparent'"
></fa-icon>
</a>
<span class="select-none">{{ getBullet(bullet) }}</span>
<a class="cursor-pointer" @click="addBullet(bullet)">
<fa-icon :icon="['fa', 'plus-circle']" class="text-primary text-sm hover:text-link-hover"></fa-icon>
</a>
</div>
<span class="tooltip">
{{ bullet.name }}
</span>
</div>
<div v-else class="flex flex-row flex-wrap items-center">
<img :src="'/img/item/' + bullet.image + '.jpg'" width="40px" :title="bullet.name" class="ml-4 mr-2" :class="getBullet(bullet) > 0 ? '' : 'grayscale-80'">
<span class="flex flex-col">
<div>{{ bullet.name }}</div>
<div class="flex flex-row gap-4">
<a :class="getBullet(bullet) > 0 ? 'cursor-pointer' : ''" @click="removeBullet(bullet)">
<fa-icon
:icon="['fa', 'minus-circle']"
class="text-sm"
:class="getBullet(bullet) > 0 ? 'text-primary hover:text-link-hover' : 'text-transparent'"
></fa-icon>
</a>
<span class="select-none">{{ getBullet(bullet) }}</span>
<a class="cursor-pointer" @click="addBullet(bullet)">
<fa-icon :icon="['fa', 'plus-circle']" class="text-primary text-sm hover:text-link-hover"></fa-icon>
</a>
</div>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
header: {
type: String,
required: true
},
explainer: {
type: String,
required: true
},
displayList: {
type: Number,
required: true
},
showBullets: {
type: Boolean,
required: true
},
bullets: {
type: Array,
required: true
},
getBullet: {
type: Function,
required: true
},
addBullet: {
type: Function,
required: true
},
removeBullet: {
type: Function,
required: true
},
},
methods: {
},
computed: {
getBullets() {
return this.bullets;
},
show: {
get() {
return this.showBullets;
},
set(value) {
this.$emit('update:showBullets', value);
}
}
}
}
</script>

View File

@@ -0,0 +1,69 @@
<template>
<div class="flex flex-row items-center pr-2" :class="localQuantity === max ? 'bg-secondary' : 'bg-primary'">
<img :src="'/img/item/' + tag + (animated ? '.gif' : '.jpg')" style="max-height: 50px; max-width: 50px;" class="mr-2">
<div class="flex flex-col items-center">
<a :href="'https://gbf.wiki/' + name" target="_blank" title="Go to gbf.wiki">
{{ name }}
</a>
<div>
<stat-input
longName="Quantity"
:prop.sync="localQuantity"
:length="max.toString().length + 1"
:max="max"
:alignRight="true"
></stat-input> / {{ max }}
<fa-icon
v-if="localQuantity < max"
@click="localQuantity = max"
:icon="['fas', 'check']"
class="ml-1 cursor-pointer"
title="Max quantity"
></fa-icon>
</div>
</div>
</div>
</template>
<script>
import StatInput from '@/components/common/StatInput.vue'
export default {
components: {
StatInput
},
props: {
tag: {
type: String,
required: true
},
name: {
type: String,
required: true
},
quantity: {
type: Number,
required: true
},
max: {
type: Number,
required: true,
},
animated: {
type: Boolean,
required: true
}
},
computed: {
localQuantity: {
get() {
return this.quantity;
},
set(value) {
this.$emit('update:quantity', value);
}
}
}
}
</script>

View File

@@ -0,0 +1,66 @@
<template>
<div class="flex flex-row items-center">
<a :href="'https://gbf.wiki/' + name" target="_blank" title="Go to gbf.wiki" class="mr-2">
<img :src="'/img/item/' + tag + (animated ? '.gif' : '.jpg')" style="max-height: 25px; max-width: 25px;">
{{ name }}
</a>
<div>
<stat-input
longName="Quantity"
:prop.sync="localQuantity"
:length="max.toString().length + 1"
:max="max"
:alignRight="true"
></stat-input> / {{ max }}
<fa-icon
v-if="localQuantity < max"
@click="localQuantity = max"
:icon="['fas', 'check']"
class="ml-1 cursor-pointer"
title="Max quantity"
></fa-icon>
</div>
</div>
</template>
<script>
import StatInput from '@/components/common/StatInput.vue'
export default {
components: {
StatInput
},
props: {
tag: {
type: String,
required: true
},
name: {
type: String,
required: true
},
quantity: {
type: Number,
required: true
},
max: {
type: Number,
required: true,
},
animated: {
type: Boolean,
required: true
}
},
computed: {
localQuantity: {
get() {
return this.quantity;
},
set(value) {
this.$emit('update:quantity', value);
}
}
}
}
</script>

411
components/Calculator.vue Normal file
View File

@@ -0,0 +1,411 @@
<template>
<div class="flex flex-col items-center lg:w-3/4">
<!-- Toolbar -->
<div class="flex flex-row flex-wrap gap-4 items-center">
<span>
<dropdown v-model.number="unit_index" v-if="Object.keys(getUnitToAdd).length > 0" >
<option :value="-1" disabled hidden>--- Select {{ unitsLabel }} ---</option>
<option
v-for="(target, index) in getUnitToAdd"
:key="target.summon"
:value="index"
>
{{ target.name }}
</option>
</dropdown>
<button class="btn btn-blue" @click="addUnit()" :disabled="unit_index < 0" v-if="Object.keys(getUnitToAdd).length > 0" >
Add
</button>
</span>
<checkbox v-model="splitMats">Split materials for each step</checkbox>
<checkbox v-model="hideCompletedMats">Hide completed materials</checkbox>
<label>Display:&nbsp;
<dropdown v-model.number="displayList">
<option :value="0">Grid</option>
<option :value="1">List</option>
</dropdown>
</label>
</div>
<!-- Unit box -->
<div v-for="(_, unitKey) in progress" :key="unitKey" class="flex flex-col mt-8 border-4 border-secondary rounded p-1 lg:p-4 bg-tertiary w-full">
<span class="flex flex-row justify-between text-3xl font-bold">
<div></div>
<a class="cursor-pointer" @click="toggleFolded(progress[unitKey])">
{{ getUnits[unitKey].name }}
<fa-icon v-if="progress[unitKey].fold" :icon="['fas', 'angle-right']" class="ml-2"></fa-icon>
<fa-icon v-else :icon="['fas', 'angle-down']" class="ml-2"></fa-icon>
</a>
<a class="cursor-pointer" @click="removeUnit(unitKey)" title="Remove">
<fa-icon :icon="['fas', 'trash']" class="ml-2"></fa-icon>
</a>
</span>
<span v-if=" ! progress[unitKey].fold" class="flex flex-col mt-6">
<!-- Options -->
<div class="flex flex-row flex-wrap self-center items-center gap-2">
<span>Completed step</span>
<dropdown v-model.number="progress[unitKey].from" @change="selectFromStep(unitKey)">
<option :value="-1">-- Nothing --</option>
<option
v-for="(step, index) in getUnitsMaterials.slice(0, -1)"
:key="step.name"
:value="index"
>
{{ step.name }}
</option>
</dropdown>
<span>Target step</span>
<dropdown v-model.number="progress[unitKey].to">
<option
v-for="(step, index) in getUnitsMaterials"
:key="step.name"
:value="index"
:disabled="progress[unitKey].from >= index"
>
{{ step.name }}
</option>
</dropdown>
</div>
<!-- Materials -->
<div v-for="(material, matKey) in getMaterialsFor(unitKey)" :key="matKey">
<h2 class="my-4">{{ material.name }}</h2>
<div v-if="displayList == 0" class="flex flex-row flex-wrap gap-1">
<calc-preview-item v-for="(item, keyItem) in filterCompletedItems(unitKey, matKey, material.items)" :key="keyItem"
:tag="item.item"
:name="item.name"
:quantity="getQuantityForItem(unitKey, matKey, item.item)"
@update:quantity="val => setQuantityForItem(unitKey, matKey, item, val)"
:max="item.max"
:animated="item.animated"
class="border-secondary border rounded"
></calc-preview-item>
</div>
<div v-else class="flex flex-col">
<calc-preview-list v-for="(item, keyItem) in filterCompletedItems(unitKey, matKey, material.items)" :key="keyItem"
:tag="item.item"
:name="item.name"
:quantity="getQuantityForItem(unitKey, matKey, item.item)"
@update:quantity="val => setQuantityForItem(unitKey, matKey, item, val)"
:max="item.max"
:animated="item.animated"
></calc-preview-list>
</div>
</div>
</span>
</div>
</div>
</template>
<script>
import supplies from '@/js/supplies'
import utils from '@/js/utils'
import Checkbox from '@/components/common/Checkbox.vue'
import Dropdown from '@/components/common/Dropdown.vue'
import CalcPreviewItem from '@/components/CalcPreviewItem.vue'
import CalcPreviewList from '@/components/CalcPreviewList.vue'
class UnitProgress {
constructor(dataLength) {
this.from = -1;
this.to = 0;
this.fold = false;
this.materials = Array.from({length: dataLength}, _ => { return {} });
}
}
const sortMaterials = (lhs, rhs) => {
if (lhs.category === rhs.category) {
// Smaller quantity first
return lhs.max > rhs.max;
}
// Group by category
return lhs.category > rhs.category;
};
class MaterialStep {
constructor(name, items) {
this.name = name;
this.items = items;
}
}
export default {
components: {
Checkbox,
Dropdown,
CalcPreviewItem,
CalcPreviewList
},
props: {
// { 2040236: new UnitProgress([{chaotichaze: 0, ...}, ...]), ... }
unitsProgress: {
type: Object,
required: true
},
unitsData: {
type: Object,
required: true,
},
unitsLabel: {
type: String,
required: true
},
unitsSplitMats: {
type: Boolean,
required: true
},
unitsHideCompletedMats: {
type: Boolean,
required: true
},
unitsDisplayList: {
type: Number,
default: 0,
}
},
data() {
return {
unit_index: -1,
};
},
methods: {
addUnit() {
this.$set(this.progress, this.unit_index, new UnitProgress(this.unitsData.materials.length));
this.unit_index = -1;
},
removeUnit(unitKey) {
this.$delete(this.progress, unitKey);
},
getItemProgressFor(unitKey, item, materialStep) {
let itemRefs = [];
if (item.item) {
itemRefs.push(item.item);
}
else if (item.group) {
switch (supplies.groups[item.group].type) {
case 'element': {
const tmpRef = supplies.groups[item.group][this.unitsData.units[unitKey].element];
if (tmpRef instanceof Array) {
itemRefs = tmpRef;
}
else {
itemRefs.push(tmpRef);
}
} break;
case 'summon': {
const tmpRef = supplies.groups[item.group][unitKey];
if (tmpRef instanceof Array) {
itemRefs = tmpRef;
}
else {
itemRefs.push(tmpRef);
}
} break;
}
}
else {
console.log("Unknown type of item for:");
console.log(item);
}
let resultArray = [];
for (let itemRef of itemRefs) {
if (this.progress[unitKey].materials[materialStep][itemRef] === undefined) {
this.$set(this.progress[unitKey].materials[materialStep], itemRef, 0);
}
// Round non integer values
let max = item.q / itemRefs.length;
if (item.q % itemRefs.length > 0) {
if (max < 5) max = Math.ceil(max);
else max = Math.floor(max);
}
if (supplies.items[itemRef] === undefined) {
console.log(itemRef + ' does not exist.')
}
resultArray.push({
item: itemRef,
group: item.group,
name: supplies.items[itemRef].name,
category: supplies.items[itemRef].category,
max: max,
animated: supplies.items[itemRef].animated,
});
}
return resultArray;
},
getMaterialsFor(unitKey) {
const result = [];
// In case steps are added later
while (this.unitsData.materials.length > this.progress[unitKey].materials.length) {
this.progress[unitKey].materials.push({});
}
if (this.splitMats) {
this.unitsData.materials
.slice(this.progress[unitKey].from + 1, this.progress[unitKey].to + 1)
.forEach((items, index) => {
result.push(new MaterialStep(
items.name,
Object.values(items.items
.flatMap(item => this.getItemProgressFor(unitKey, item, index + this.progress[unitKey].from + 1))
.reduce((buffer, item) => {
// Merge materials
if (buffer.hasOwnProperty(item.item)) {
buffer[item.item].max += item.max;
}
else {
buffer[item.item] = item;
}
return buffer;
}, {})
)
.sort(sortMaterials)
));
});
}
else {
const buffer = {};
this.unitsData.materials
.slice(this.progress[unitKey].from + 1, this.progress[unitKey].to + 1)
.forEach((items, index) => {
items.items
.flatMap(item => this.getItemProgressFor(unitKey, item, index + this.progress[unitKey].from + 1))
.forEach(item => {
// Merge materials
if (buffer.hasOwnProperty(item.item)) {
buffer[item.item].max += item.max;
}
else {
buffer[item.item] = item;
}
});
});
result.push(new MaterialStep('Materials', Object.values(buffer)));
result[0].items.sort(sortMaterials);
}
return result;
},
selectFromStep(unitKey) {
// Dropdown takes some time to update
this.$nextTick().then(() => {
if (this.progress[unitKey].from >= this.progress[unitKey].to) {
this.progress[unitKey].to = this.progress[unitKey].from + 1;
}
});
},
filterCompletedItems(unitKey, matKey, items) {
if (this.hideCompletedMats) {
return items.filter(item => this.getQuantityForItem(unitKey, matKey, item.item) < item.max);
}
return items;
},
toggleFolded(progressForUnit) {
this.$set(progressForUnit, 'fold', ! progressForUnit.fold);
},
getQuantityForItem(unitKey, matKey, itemKey) {
if (this.splitMats) {
return this.progress[unitKey].materials[matKey + this.progress[unitKey].from + 1][itemKey];
}
return this.progress[unitKey].materials
.slice(matKey + this.progress[unitKey].from + 1, matKey + this.progress[unitKey].to + 1)
.reduce((acc, items) => {
if (items.hasOwnProperty(itemKey)) {
return acc + items[itemKey];
}
return acc;
}, 0);
},
setQuantityForItem(unitKey, matKey, item, value) {
if (this.splitMats) {
this.progress[unitKey].materials[matKey + this.progress[unitKey].from + 1][item.item] = value;
}
else {
let totalToAdd = value;
this.progress[unitKey].materials
.slice(matKey + this.progress[unitKey].from + 1, matKey + this.progress[unitKey].to + 1)
.forEach((items, index) => {
if (items.hasOwnProperty(item.item)) {
// Find max quantity of item
const foundItem = this.unitsData.materials[matKey + this.progress[unitKey].from + index + 1].items.find(i =>
i.item ? i.item == item.item : i.group == item.group
);
// Protection against bad localStorage data
if (foundItem) {
const max = foundItem.q;
const toAdd = Math.min(totalToAdd, max);
this.progress[unitKey].materials[matKey + this.progress[unitKey].from + index + 1][item.item] = toAdd;
totalToAdd -= toAdd;
}
}
});
}
},
},
computed: {
getUnitToAdd() {
return utils.filterObject(this.unitsData.units, ([key, _]) => ! this.progress.hasOwnProperty(key));
},
getUnits() {
return this.unitsData.units;
},
getUnitsMaterials() {
return this.unitsData.materials;
},
localQuantity: {
get() {
return this.quantity;
},
set(value) {
this.$emit('update:quantity', value);
}
},
progress: {
get() {
return this.unitsProgress;
},
set(value) {
this.$emit('update:unitsProgress', value);
}
},
splitMats: {
get() {
return this.unitsSplitMats;
},
set(value) {
this.$emit('update:unitsSplitMats', value);
}
},
hideCompletedMats: {
get() {
return this.unitsHideCompletedMats;
},
set(value) {
this.$emit('update:unitsHideCompletedMats', value);
}
},
displayList: {
get() {
return this.unitsDisplayList;
},
set(value) {
this.$emit('update:unitsDisplayList', value);
}
},
},
};
</script>

View File

@@ -0,0 +1,40 @@
<template>
<div class="flex flex-col">
<span class="flex flex-row justify-between items-center mt-1">
<button class="btn btn-blue" @click="clickAttack">Attack</button>
<p v-if="party_mode === $MODE.Edit" class="text-sm text-center mx-2">Uncheck <i>Edit Grid</i> to click and add skills and summons below</p>
<span class="flex flex-row">
<button class="btn btn-white mr-1" @click="clickUndo">Undo</button>
<button class="btn btn-red" @click="clickClear">Clear</button>
</span>
</span>
<textarea class="w-full h-full appearance-none text-primary bg-primary" v-model="getActionsText" readonly></textarea>
</div>
</template>
<script>
import { mapState } from 'vuex'
export default {
methods: {
clickAttack() {
this.$store.commit('addActionAttack');
},
clickUndo() {
this.$store.commit('undoAction');
},
clickClear() {
this.$store.commit('clearActions');
}
},
computed: {
...mapState({
party_mode: state => state.party_builder.party_mode
}),
getActionsText() {
return this.$store.getters.getActionsText;
}
}
}
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div class="flex flex-row flex-wrap group-characters">
<box-character
v-for="(object, index) in objects"
:key="index"
:object="object"
:showLevel="showLevel"
:showRing="showRing"
@click-portrait="showModal(index)"
@click-skill="clickSkill(index, $event)"
@drag-portrait="drag($event, index)"
@swap="swap($event, index)"
></box-character>
<!-- Modal -->
<modal
v-if="party_mode !== $MODE.ReadOnly"
v-model="show_modal"
route="/party/characters"
:categories="getCategories"
@item-selected="changeObject"
></modal>
</div>
</template>
<script>
import { mapState } from 'vuex'
import Utils from '@/js/utils'
import BoxCharacter from '@/components/BoxCharacter.vue'
import Modal from '@/components/ModalSelector.vue'
const CATEGORIES = [
{
name: "Name",
isColumn: true,
isFilter: false,
key: "n",
},
{
name: "Rarity",
isColumn: false,
isFilter: true,
key: "ri",
},
{
name: "Element",
isColumn: true,
isFilter: true,
key: "e",
},
{
name: "Type",
isColumn: true,
isFilter: true,
key: "t",
},
{
name: "Race",
isColumn: true,
isFilter: true,
key: "ra",
},
{
name: "Weapon",
isColumn: true,
isFilter: true,
key: "w",
},
];
export default {
components: {
BoxCharacter,
Modal,
},
props: {
showLevel: {
type: Boolean,
default: false,
},
showRing: {
type: Boolean,
default: false,
},
},
data() {
return {
show_modal: false,
selected_box_index: 0,
}
},
methods: {
drag(ev, index) {
ev.dataTransfer.setData("character", JSON.stringify(index));
},
showModal(index) {
switch (this.party_mode) {
case this.$MODE.Action:
// TODO show warning
break;
case this.$MODE.Edit:
this.selected_box_index = index;
this.show_modal = true;
break;
case this.$MODE.ReadOnly:
// Do nothing
break;
}
},
changeObject(id) {
const slot = this.selected_box_index;
if (Utils.isEmpty(id)) {
this.$store.commit('setCharacter', { index: slot, data: {} });
}
else {
this.axios.get('/party/characters/' + id)
.then(response => this.$store.commit('setCharacter', { index: slot, data: response.data }))
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
}
},
clickSkill(index, skillIndex) {
switch (this.party_mode) {
case this.$MODE.Action:
if (this.objects[index].skills[skillIndex] !== undefined) {
this.$store.commit('addActionCharacterSkill', { slot: index, index: skillIndex });
}
break;
case this.$MODE.Edit:
// TODO warning
break;
case this.$MODE.ReadOnly:
// Do nothing
break;
}
},
swap(from, to) {
if (this.party_mode === this.$MODE.ReadOnly) {
return;
}
let tmp = this.objects[from];
this.$store.commit('setCharacter', { index: from, data: this.objects[to] })
this.$store.commit('setCharacter', { index: to, data: tmp })
}
},
computed: {
...mapState({
objects: state => state.party_builder.characters,
party_mode: state => state.party_builder.party_mode
}),
getCategories() {
return CATEGORIES;
}
}
}
</script>
<style scoped>
.group-characters > :nth-child(3) {
@apply mr-2;
}
</style>

142
components/GroupClass.vue Normal file
View File

@@ -0,0 +1,142 @@
<template>
<div class="flex flex-row flex-wrap">
<box-class
:object="getObject"
@click-portrait="showModalClass"
@click-skill="clickSkill"
></box-class>
<!-- Modals -->
<modal
v-if="party_mode !== $MODE.ReadOnly"
v-model="show_modal_class"
route="/party/classes"
:categories="getCategoriesClass"
@item-selected="changeObject"
></modal>
<modal
v-if="party_mode !== $MODE.ReadOnly"
v-model="show_modal_skill"
route="/party/skills"
:routeParameters="'family=' + getObject.family"
:categories="getCategoriesSkill"
@item-selected="changeSkill"
></modal>
</div>
</template>
<script>
import { mapState } from 'vuex'
import Utils from '@/js/utils'
import BoxClass from "@/components/BoxClass.vue";
import Modal from '@/components/ModalSelector.vue'
export default {
components: {
BoxClass,
Modal,
},
data() {
return {
show_modal_class: false,
show_modal_skill: false,
selected_skill_index: 0,
}
},
methods: {
showModalClass() {
switch (this.party_mode) {
case this.$MODE.Action:
// TODO show warning
break;
case this.$MODE.Edit:
this.show_modal_class = true;
break;
case this.$MODE.ReadOnly:
// Do nothing
break;
}
},
clickSkill(index) {
switch (this.party_mode) {
case this.$MODE.Action:
if (this.getObject.skills[index] !== null) {
// Send action
this.$store.commit('addActionMCSkill', index);
}
break;
case this.$MODE.Edit:
// Show modal
if (this.getObject.skills[index] === null || ! this.getObject.skills[index].fixed) {
this.selected_skill_index = index;
this.show_modal_skill = true;
}
break;
case this.$MODE.ReadOnly:
// Do nothing
break;
}
},
changeObject(id) {
if (Utils.isEmpty(id)) {
this.$store.commit('setClasse', {});
}
else {
this.axios.get('/party/classes/' + id)
.then(response => this.$store.commit('setClasse', response.data))
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
}
},
changeSkill(id) {
if (Utils.isEmpty(id)) {
this.$store.commit('setClasseSkill', {slot: this.selected_skill_index, data: null})
}
else {
this.axios.get('/party/skills/' + id)
.then(response => this.$store.commit('setClasseSkill', {slot: this.selected_skill_index, data: response.data}))
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
}
},
},
computed: {
...mapState({
party_mode: state => state.party_builder.party_mode
}),
getObject() {
return this.$store.state.party_builder.classe;
},
getCategoriesClass() {
return [
{
name: "Name",
isColumn: true,
isFilter: false,
key: "n",
},
{
name: "Row",
isColumn: true,
isFilter: true,
key: "row",
},
];
},
getCategoriesSkill() {
return [
{
name: "Name",
isColumn: true,
isFilter: false,
key: "n",
},
];
}
}
}
</script>

View File

@@ -0,0 +1,14 @@
<template>
<textarea class="w-full h-full appearance-none text-primary bg-secondary" v-model="description"></textarea>
</template>
<script>
export default {
computed: {
description: {
get() { return this.$store.state.party_builder.description },
set(value) { this.$store.commit('setDescription', value) }
},
}
}
</script>

View File

@@ -0,0 +1,374 @@
<template>
<div class="flex flex-col">
<!-- Options -->
<div class="flex flex-row flex-wrap items-center">
<label class="mr-2">
Enemy Element
<dropdown v-model="enemy_element">
<option value="fire">Fire</option>
<option value="wind">Wind</option>
<option value="earth">Earth</option>
<option value="water">Water</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="none">None</option>
</dropdown>
</label>
<label>
HP%
<input
class="input"
style="width: 5ch;"
v-model.number="percentHP"
@keydown.arrow-up="percentHP++"
@keydown.arrow-down="percentHP--">
</label>
</div>
<!-- Table -->
<div class="overflow-y-auto" v-if="getStatsForCharacters.length > 0">
<table class="table">
<thead>
<tr>
<th></th>
<th>Elem</th>
<th><abbr title="Normal and Optimus modifiers ratio">Norm</abbr></th>
<th><abbr title="Omega">&#x3A9;</abbr></th>
<th>Ex</th>
<th><abbr title="Character-specific unique attack ratio">Chara</abbr></th>
<th><abbr title="Critical damage ratio">Crit</abbr></th>
<th><abbr title="Double attack ratio">DA</abbr></th>
<th><abbr title="Triple attack ratio">TA</abbr></th>
<!--<th><abbr title="Attack cap">Cap</abbr></th>-->
</tr>
</thead>
<tbody>
<tr v-for="(chara, index) in getStatsForCharacters" :key="index">
<td class="truncate" style="max-width: 4rem;">{{ chara.name }}</td>
<td>{{ (chara.elem_atk * 100).toFixed(0) }}%</td>
<td>{{ (chara.normal_atk * 100).toFixed(0) }}%</td>
<td>{{ (chara.omega_atk * 100).toFixed(0) }}%</td>
<td>{{ (chara.ex_atk * 100).toFixed(0) }}%</td>
<td>{{ (chara.chara_atk * 100).toFixed(0) }}%</td>
<td>{{ Math.floor(chara.crit * 100) }}%</td>
<td>{{ Math.min(75, (chara.da * 100).toFixed(0)) }}%</td>
<td>{{ Math.min(75, (chara.ta * 100).toFixed(0)) }}%</td>
<!--<td>{{ ((chara.atk_cap-1) * 100).toFixed(0) }}%</td>-->
</tr>
</tbody>
</table>
</div>
<div class="mt-4">
<span class="tag bg-red-500">Warning</span>
Some things might still be missing.
Mouse over the skills and summons to see what's already implemented.
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
import Utils from '@/js/utils.js'
import DataModel from '@/js/data-model'
import Dropdown from '@/components/common/Dropdown.vue';
const lsMgt = new Utils.LocalStorageMgt('PartyBuilder');
const RATIO_TYPES = {
atk: 0,
atk_cap: 0,
hp: 0,
da: 0,
ta: 0,
crit: 0,
ca_dmg: 0,
ca_cap: 0,
chainburst_dmg: 0,
chainburst_cap: 0,
stamina: 0,
enmity: 0,
supplemental_dmg: 0,
};
const ELEM_SUPERIORITY = {
fire: {
superior: 'wind',
weak: 'water'
},
wind: {
superior: 'earth',
weak: 'fire'
},
earth: {
superior: 'water',
weak: 'wind'
},
water: {
superior: 'fire',
weak: 'earth'
},
light: {
superior: 'dark',
weak: ''
},
dark: {
superior: 'light',
weak: ''
},
none: {
superior: '',
weak: ''
},
};
export default {
components: {
Dropdown
},
data() {
return {
enemy_element: "water",
// enemy_defense: 10,
}
},
methods: {
getElemSuperiority(chara_element) {
if (this.enemy_element === "none") {
return 0;
}
if (ELEM_SUPERIORITY[chara_element].superior === this.enemy_element) {
return 0.50;
}
if (ELEM_SUPERIORITY[chara_element].weak === this.enemy_element) {
return -0.25;
}
return 0;
},
getSummonAuras(chara_element) {
let auras = {
elemental: Object.assign({}, RATIO_TYPES),
normal: Object.assign({}, RATIO_TYPES),
optimus: Object.assign({}, RATIO_TYPES),
omega: Object.assign({}, RATIO_TYPES),
mysterious: Object.assign({}, RATIO_TYPES),
seraphic: Object.assign({}, RATIO_TYPES),
ranko: Object.assign({}, RATIO_TYPES),
};
[this.summons[0], this.summons[5]].forEach(summon => {
if ( ! Utils.isEmpty(summon) && summon.current_data !== undefined) {
for (let aura of summon.data[summon.current_data]) {
if (chara_element === aura.element || aura.element === "any") {
// TODO formula
auras[aura.aura_type][aura.stat] += aura.percent / 100;
}
}
}
})
this.summons.slice(1, 5).forEach(summon => {
if ( ! Utils.isEmpty(summon) && summon.current_data !== undefined) {
for (let aura of summon.data[summon.current_data]) {
if (aura.slot === 'sub' && (chara_element === aura.element || aura.element === "any")) {
// TODO formula
auras[aura.aura_type][aura.stat] += aura.percent / 100;
}
}
}
})
this.summons.slice(6, 8).forEach(summon => {
if ( ! Utils.isEmpty(summon) && summon.current_data !== undefined) {
for (let aura of summon.data[summon.current_data]) {
if (aura.slot === 'sub' && (chara_element === aura.element || aura.element === "any")) {
// TODO formula
auras[aura.aura_type][aura.stat] += aura.percent / 100;
}
}
}
})
return auras;
},
},
computed: {
...mapState({
characters: state => state.party_builder.characters,
summons: state => state.party_builder.summons,
weapons: state => state.party_builder.weapons,
}),
percentHP: {
get() { return this.$store.state.party_builder.percent_HP },
set(value) { this.$store.commit('setPercentHP', value) }
},
getStatsForCharacters() {
let stats = [];
for (let c of this.characters) {
if ( ! Utils.isEmpty(c)) {
let data = {
name: c.nameen,
ratio: {
elemental: Object.assign({}, RATIO_TYPES),
normal: Object.assign({}, RATIO_TYPES),
optimus: Object.assign({}, RATIO_TYPES),
omega: Object.assign({}, RATIO_TYPES),
ex: Object.assign({}, RATIO_TYPES),
mysterious: Object.assign({}, RATIO_TYPES),
seraphic: Object.assign({}, RATIO_TYPES),
}
};
let chara_element = DataModel.e.data[c.elementid].name.toLowerCase();
// Characters with "any" element get the main weapon element
if (chara_element === "any") {
if ( ! Utils.isEmpty(this.weapons[0])) {
chara_element = DataModel.e.data[this.weapons[0].elementid].name.toLowerCase();
}
else {
chara_element = "dark";
}
}
const chara_race = DataModel.ra.data[c.raceid].name.toLowerCase();
const chara_weapons = c.weapontypeid.flatMap(w => [DataModel.w.data[w].name.toLowerCase()]);
for (let index=0; index<this.weapons.length; index++) {
const w = this.weapons[index];
if ( ! Utils.isEmpty(w)) {
// Weapon skills
for (let skill_data of this.$store.getters.getWeaponsCurrentData[index].flat()) {
let add_value = true;
if (skill_data.restriction) {
for (let [key, value] of Object.entries(skill_data.restriction)) {
if (key === 'element') {
if ( ! value.some(v => v === chara_element)) {
add_value = false;
break;
}
}
else if (key === 'race') {
if ( ! value.some(v => v === chara_race)) {
add_value = false;
break;
}
}
else if (key === 'weapon') {
if ( ! value.some(v => chara_weapons.some(w => v === w))) {
add_value = false;
break;
}
}
}
}
if (add_value === true) {
data.ratio[skill_data.aura_type][skill_data.stat] += skill_data.value;
}
}
}
}
const chara_auras = this.getSummonAuras(chara_element);
const elem_sup = this.getElemSuperiority(chara_element);
data.elem_atk = (1
+ elem_sup
+ chara_auras.elemental.atk
//+ Elem EMP buffs {[Element] ATK up}
//+ Elem ATK buffs {[Element] ATK up}
//- Elem ATK debuffs {[Element] ATK down}
);
data.normal_atk =
(1
+ data.ratio.optimus.atk * (1 + chara_auras.optimus.atk)
+ data.ratio.normal.atk + data.ratio.normal.enmity + data.ratio.normal.stamina
+ chara_auras.normal.atk
//+ Norm buffs (ATK up)
//- Norm debuffs (ATK down)
) * (1
+ data.ratio.optimus.enmity * (1 + chara_auras.optimus.atk)
) * (1
+ data.ratio.optimus.stamina * (1 + chara_auras.optimus.atk)
);
data.omega_atk =
(1
+ data.ratio.omega.atk * (1 + chara_auras.omega.atk)
) * (1
+ data.ratio.omega.enmity * (1 + chara_auras.omega.atk)
) * (1
+ data.ratio.omega.stamina * (1 + chara_auras.omega.atk)
);
data.ex_atk =
(1
+ data.ratio.ex.atk
+ data.ratio.mysterious.atk * (1 + chara_auras.ranko.atk)
) * (1
+ data.ratio.ex.enmity
) * (1
+ data.ratio.ex.stamina
);
data.fixed_atk_mod = 0; //- Fixed ATK Modifiers {ex: 15% ATK cut from Qinglong Manewhip.}
data.normal_omega_ex_atk = data.normal_atk * data.omega_atk * data.ex_atk - data.fixed_atk_mod;
data.chara_atk = (1
//+ Jammed mod
//+ Enmity EMP mod
//+ Ring Enmity mod
) * (1
//+ Strength mod
//+ Stamina EMP mod
//+ Ring Stamina mod
) * (1
+ (c.haspring === true ? 0.1 : 0) // Total Char Unique ATK boosts
)
// Increase atk cap
//data.atk_cap = 1
// + Object.entries(data.ratio).reduce((acc, [key, cur]) => (key != 'seraphic') ? acc + cur.atk_cap : acc, 0)
// + Object.entries(chara_auras).reduce((acc, [key, cur]) => (key != 'seraphic') ? acc + cur.atk_cap : acc, 0);
//if (elem_sup > 0) {
// data.atk_cap = data.atk_cap * (1 + data.ratio.seraphic.atk_cap + chara_auras.seraphic.atk_cap)
//}
// Crit proba
data.crit = 0;
if (elem_sup > 0 || this.enemy_element === "none") {
data.crit = data.ratio.omega.crit * (1 + chara_auras.omega.atk)
+ data.ratio.optimus.crit * (1 + chara_auras.optimus.atk)
+ data.ratio.normal.crit;
}
// DA/TA
data.da = data.ratio.omega.da * (1 + chara_auras.omega.atk)
+ data.ratio.optimus.da * (1 + chara_auras.optimus.atk)
+ data.ratio.normal.da;
data.ta = data.ratio.omega.ta * (1 + chara_auras.omega.atk)
+ data.ratio.optimus.ta * (1 + chara_auras.optimus.atk)
+ data.ratio.normal.ta;
stats.push(data);
}
}
return stats;
}
},
watch: {
enemy_element() {
lsMgt.setValue('enemy_element', this);
},
},
mounted() {
lsMgt.getValue(this, 'enemy_element');
}
}
</script>

177
components/GroupSummons.vue Normal file
View File

@@ -0,0 +1,177 @@
<template>
<div class="flex flex-row flex-wrap gap-x-2">
<span class="flex flex-col">
<span class="bg-secondary rounded-lg text-center mb-1">Main</span>
<box-summon
:object="objects[0]"
:showLevel="showLevel"
@click-portrait="showModal(0)"
@drag-portrait="drag($event, 0)"
@swap="swap($event, 0)"
></box-summon>
</span>
<span class="flex flex-col flex-wrap">
<div class="flex flex-row justify-around mb-1">
<span>Atk {{ getStats.atk }}</span>
<span>HP {{ getStats.hp }}</span>
</div>
<div class="flex flex-row flex-wrap" v-for="(line, lineIndex) in getIndexes" :key="lineIndex">
<span v-for="index in line" :key="index" :class="lineIndex === 1 ? 'mt-1' : ''">
<box-summon
:object="objects[index]"
:showLevel="showLevel"
@click-portrait="showModal(index)"
@drag-portrait="drag($event, index)"
@swap="swap($event, index)"
></box-summon>
</span>
</div>
</span>
<span class="flex flex-col">
<span class="bg-secondary rounded-lg text-center mb-1">Friend</span>
<box-summon
:object="objects[5]"
:showLevel="showLevel"
@click-portrait="showModal(5)"
@drag-portrait="drag($event, 5)"
@swap="swap($event, 5)"
></box-summon>
</span>
<!-- Sub Aura -->
<span class="flex flex-col bg-secondary rounded-lg gap-y-1">
<span class="rounded-lg text-center">Sub Aura</span>
<box-summon
:object="objects[6]"
:showLevel="showLevel"
@click-portrait="showModal(6)"
@drag-portrait="drag($event, 6)"
@swap="swap($event, 6)"
></box-summon>
<box-summon
:object="objects[7]"
:showLevel="showLevel"
@click-portrait="showModal(7)"
@drag-portrait="drag($event, 7)"
@swap="swap($event, 7)"
></box-summon>
</span>
<!-- Modal -->
<modal
v-if="party_mode !== $MODE.ReadOnly"
v-model="show_modal"
route="/party/summons"
:categories="getCategories"
@item-selected="changeObject"
></modal>
</div>
</template>
<script>
import { mapState } from 'vuex'
import Utils from '@/js/utils'
import BoxSummon from "@/components/BoxSummon.vue";
import Modal from '@/components/ModalSelector.vue'
const CATEGORIES = [
{
name: "Name",
isColumn: true,
isFilter: false,
key: "n",
},
{
name: "Rarity",
isColumn: true,
isFilter: true,
key: "ri",
},
{
name: "Element",
isColumn: true,
isFilter: true,
key: "e",
},
];
export default {
components: {
BoxSummon,
Modal,
},
props: {
showLevel: {
type: Boolean,
default: false,
},
},
data() {
return {
show_modal: false,
selected_box_index: 0,
}
},
methods: {
drag(ev, index) {
ev.dataTransfer.setData("summon", JSON.stringify(index));
},
showModal(index) {
switch (this.party_mode) {
case this.$MODE.Action:
this.$store.commit('addActionSummon', index);
break;
case this.$MODE.Edit:
this.selected_box_index = index;
this.show_modal = true;
break;
case this.$MODE.ReadOnly:
// Do nothing
break;
}
},
changeObject(id) {
const slot = this.selected_box_index;
if (Utils.isEmpty(id)) {
this.$store.commit('setSummon', { index: slot, data: {} });
} else {
this.axios.get('/party/summons/' + id)
.then(response => this.$store.commit('setSummon', { index: slot, data: response.data }))
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
}
},
swap(from, to) {
if (this.party_mode === this.$MODE.ReadOnly) {
return;
}
let tmp = this.objects[from];
this.$store.commit('setSummon', { index: from, data: this.objects[to] })
this.$store.commit('setSummon', { index: to, data: tmp })
}
},
computed: {
...mapState({
objects: state => state.party_builder.summons,
party_mode: state => state.party_builder.party_mode
}),
getStats() {
return this.$store.getters.getSummonsStats;
},
getCategories() {
return CATEGORIES;
},
getIndexes() {
return [[1, 2], [3, 4]];
}
}
}
</script>

View File

@@ -0,0 +1,53 @@
<template>
<div>
<div v-for="(w, i) in getWeaponKeys" :key="'w'+i" class="mb-3">
<b>{{ w.name }}</b>
<ul class="list-disc ml-4">
<li v-for="(k, j) in w.keys" :key="'k'+j">{{ k }}</li>
</ul>
</div>
<br>
</div>
</template>
<script>
import { mapState } from 'vuex'
import { LANGUAGES } from '@/js/lang'
import Utils from '@/js/utils.js'
export default {
computed: {
...mapState({
weapons: state => state.party_builder.weapons,
}),
englishNames() {
return this.$store.getters.getLang === LANGUAGES.EN;
},
getWeaponKeys() {
let keys = [];
for (const w of this.weapons) {
if ( ! Utils.isEmpty(w)) {
let weapon = {
name: this.englishNames ? w.nameen : w.namejp,
keys: []
};
for (const k of w.keys) {
if ( ! Utils.isEmpty(k)) {
weapon.keys.push(k.desc);
}
}
if (weapon.keys.length > 0) {
keys.push(weapon);
}
}
}
return keys;
}
}
}
</script>

165
components/GroupWeapons.vue Normal file
View File

@@ -0,0 +1,165 @@
<template>
<div class="flex flex-row flex-wrap">
<!-- Main weapon -->
<box-weapon
:object="objects[0]"
:skills="skills[0]"
:showLevel="showLevel"
@click-portrait="showModal(0)"
@drag-portrait="drag($event, 0)"
@swap="swap($event, 0)"
></box-weapon>
<!-- Grid -->
<div class="flex flex-col flex-wrap gap-y-1">
<div class="flex flex-row px-2" v-for="(line, lineIndex) in getIndexes" :key="lineIndex">
<span v-for="index in line" :key="index">
<box-weapon
:object="objects[index]"
:skills="skills[index]"
:showLevel="showLevel"
@click-portrait="showModal(index)"
@drag-portrait="drag($event, index)"
@swap="swap($event, index)"
></box-weapon>
</span>
</div>
<div v-if="showArcarum" class="flex flex-row shrink bg-secondary rounded px-2 pb-1">
<span v-for="index in [10, 11, 12]" :key="index">
<box-weapon
:object="objects[index]"
:skills="skills[index]"
:showLevel="showLevel"
:isArcarum="true"
@click-portrait="showModal(index)"
@drag-portrait="drag($event, index)"
@swap="swap($event, index)"
></box-weapon>
</span>
</div>
</div>
<!-- Modal -->
<modal
v-if="party_mode !== $MODE.ReadOnly"
v-model="show_modal"
route="/party/weapons"
:categories="getCategories"
@item-selected="changeObject"
></modal>
</div>
</template>
<script>
import { mapState } from 'vuex'
import Utils from '@/js/utils'
import BoxWeapon from "@/components/BoxWeapon.vue";
import Modal from '@/components/ModalSelector.vue'
const CATEGORIES = [
{
name: "Name",
isColumn: true,
isFilter: false,
key: "n",
},
{
name: "Rarity",
isColumn: false,
isFilter: false,
key: "ri",
},
{
name: "Element",
isColumn: true,
isFilter: true,
key: "e",
},
{
name: "Weapon",
isColumn: true,
isFilter: true,
key: "w",
},
];
const INDEXES = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
export default {
components: {
BoxWeapon,
Modal,
},
props: {
showLevel: {
type: Boolean,
default: false,
},
showArcarum: {
type: Boolean,
default: false,
}
},
data() {
return {
show_modal: false,
selected_box_index: 0,
}
},
methods: {
drag(ev, index) {
ev.dataTransfer.setData("weapon", JSON.stringify(index));
},
showModal(index) {
switch (this.party_mode) {
case this.$MODE.Action:
// TODO show warning
break;
case this.$MODE.Edit:
this.selected_box_index = index;
this.show_modal = true;
break;
case this.$MODE.ReadOnly:
// Do nothing
break;
}
},
changeObject(id) {
const slot = this.selected_box_index;
if (Utils.isEmpty(id)) {
this.$store.commit('setWeapon', { index: slot, data: {} })
}
else {
this.axios.get('/party/weapons/' + id)
.then(response => this.$store.commit('setWeapon', { index: slot, data: response.data }))
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
}
},
swap(from, to) {
if (this.party_mode === this.$MODE.ReadOnly) {
return;
}
let tmp = this.objects[from];
this.$store.commit('setWeapon', { index: from, data: this.objects[to] })
this.$store.commit('setWeapon', { index: to, data: tmp })
}
},
computed: {
...mapState({
objects: state => state.party_builder.weapons,
skills: state => state.party_builder.weapons_skills,
party_mode: state => state.party_builder.party_mode
}),
getCategories() {
return CATEGORIES;
},
getIndexes() {
return INDEXES;
}
}
}
</script>

View File

@@ -0,0 +1,54 @@
<template>
<modal :show="show" @close="$emit('close', false)">
<template v-slot:header>
<h2>Are you sure?</h2>
</template>
<p class="my-8">
<fa-icon :icon="['fas', 'exclamation-triangle']" class="text-red-400"></fa-icon> {{ text }}
</p>
<div class="flex flex-row justify-between">
<button class="btn btn-red" @click="confirmAction()">{{ button }}</button>
<button class="btn btn-blue" @click="$emit('close', false)">Close</button>
</div>
</modal>
</template>
<script>
import Modal from './common/Modal.vue'
export default {
model: {
prop: 'show',
event: 'close'
},
components: {
Modal
},
props: {
show: {
type: Boolean,
required: true
},
confirm: {
type: Function,
required: true
},
text: {
type: String,
required: true
},
button: {
type: String,
required: true
}
},
methods: {
confirmAction() {
this.confirm();
this.$emit('close', false);
},
},
}
</script>

69
components/ModalKeys.vue Normal file
View File

@@ -0,0 +1,69 @@
<template>
<modal :show="show" @close="close()">
<template v-slot:header>
<h1>Select a skill key</h1>
</template>
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Key</th>
</tr>
</thead>
<tbody>
<tr @click="selectItem(null)">
<td>-</td>
<td>Remove current key</td>
</tr>
<tr
v-for="(item, index) in getData"
:key="index"
@click="selectItem(item)"
>
<td>{{ item.name }}</td>
<td>{{ item.desc }}</td>
</tr>
</tbody>
</table>
</modal>
</template>
<script>
import KeyData from '@/js/key-data.js'
import Modal from './common/Modal.vue'
export default {
model: {
prop: 'show',
event: 'close'
},
components: {
Modal
},
props: {
show: {
type: Boolean,
required: true
},
keyId: {
type: Number,
required: true
},
},
methods: {
selectItem(item) {
this.$emit('key-selected', item);
this.close();
},
close() {
this.$emit('close', false);
}
},
computed: {
getData() {
return KeyData.data[this.keyId];
}
}
}
</script>

118
components/ModalLogin.vue Normal file
View File

@@ -0,0 +1,118 @@
<template>
<modal :show="show" @close="$emit('close', false)">
<template v-slot:header>
<h1 v-if="reset_password">Reset password</h1>
<h1 v-else>Login</h1>
</template>
<!-- Reset password -->
<form v-if="reset_password" @submit.prevent="resetPassword()" class="m-1">
<p class="p-4 bg-secondary rounded">
Which email address did you link your account to?<br>
We'll send you an email with a link to reset your password.
</p>
<div class="mt-4">
<label for="email" class="">Email address</label>
<input class="input w-full" id="email" ref="email" type="email" placeholder="Email address" v-model="email" required autofocus>
</div>
<p class="mt-4">
<!-- Honeypot -->
<input type="checkbox" name="captcha" v-model="captcha" style="display:none !important" tabindex="-1" autocomplete="off">
<a class="cursor-pointer" @click="reset_password = false">Back to login</a>
</p>
<input class="btn btn-blue mt-6" type="submit" value="Send Email">
</form>
<!-- Login -->
<form v-else @submit.prevent="doLogin()" class="m-1">
<div>
<label for="username" class="">Username</label>
<input class="input w-full" id="username" ref="username" type="text" placeholder="Username" v-model="username" required autofocus>
</div>
<div class="pb-2">
<label for="password">Password</label>
<input class="input w-full" id="password" ref="password" placeholder="Password" type="password" minlength="1" required>
</div>
<p class="mt-4">
<a class="cursor-pointer" @click="reset_password = true">Forgot password?</a>
</p>
<input class="btn btn-blue mt-6" type="submit" value="Login">
</form>
</modal>
</template>
<script>
import Modal from './common/Modal.vue'
export default {
model: {
prop: 'show',
event: 'close'
},
components: {
Modal
},
props: {
show: {
type: Boolean,
required: true
},
},
data() {
return {
username: "",
email: "",
reset_password: false,
captcha: false,
}
},
methods: {
doLogin() {
const body = {
username: this.username,
password: this.$refs.password.value,
}
this.axios.post('/user/login', body)
.then(response => {
this.$emit('close', false);
this.$emit('user-logged', this.username, response.data.data.userid);
})
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
},
resetPassword() {
const body = {
email: this.email,
captcha: this.captcha
}
this.reset_password = false;
this.email = "";
this.axios.post('/user/sendreset', body)
.then(response => this.$store.dispatch('addMessage', {message: 'Email sent'}))
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
}
},
mounted() {
this.username = this.$store.getters.getUsername;
},
watch: {
show() {
if (this.show === true) {
let self = this;
this.$nextTick().then(() => {
self.$refs.username.focus();
});
}
},
'$store.getters.getUsername'() {
this.username = this.$store.getters.getUsername;
}
}
}
</script>

View File

@@ -0,0 +1,176 @@
<template>
<modal :show="show" @close="close()">
<template v-slot:header>
<span class="flex flex-row flex-wrap items-center">
<input class="input input-sm" placeholder="Name" ref="nameField" type="text" v-model="name_searched">
<button class="btn btn-sm btn-red mx-2" @click="name_searched = ''">Clear</button>
<data-filter
class="my-2 mr-2"
v-for="(category, index) in getFilters"
:key="index"
:category="category.name"
:data="data_model[category.key].data">
</data-filter>
</span>
</template>
<div v-if="message.length < 1">
Loading...
</div>
<div v-else>
<table class="table">
<thead>
<tr>
<th v-for="(category, index) in getColumns" :key="index" class="whitespace-nowrap">{{ category.name }}</th>
</tr>
</thead>
<tbody>
<tr v-if="canUnselect" @click="selectItem(null)">
<td v-for="index in getColumns.length" :key="index">-</td>
</tr>
<tr v-for="item in getData" :key="item.id" @click="selectItem(item.id)">
<td v-for="(val, index) in getColumn(item)" :key="index">{{ val }}</td>
</tr>
</tbody>
</table>
</div>
<template v-slot:footer>
{{ getResultsCount }}
</template>
</modal>
</template>
<script>
import DataModel from '@/js/data-model'
import Utils from '@/js/utils'
import Modal from './common/Modal.vue'
import DataFilter from './common/DataFilter.vue'
export default {
model: {
prop: 'show',
event: 'close'
},
components: {
Modal,
DataFilter
},
props: {
show: {
type: Boolean,
required: true
},
route: {
type: String,
required: true
},
routeParameters: {
type: String,
default: undefined
},
categories: {
type: Array,
required: true
},
dataModel: {
type: Object,
default: undefined
},
canUnselect: {
type: Boolean,
default: true,
},
},
data() {
return {
message: [],
previousRoute: '',
data_model: {},
name_searched: '',
}
},
methods: {
selectItem(id) {
this.$emit('item-selected', id);
this.close();
},
close() {
this.$emit('close', false);
},
getColumn(item) {
return this.getColumns.map(c => {
return this.data_model[c.key].expand(item, this.getLang);
});
}
},
computed: {
getData() {
return this.message.filter(item => {
return item.n.toLowerCase().includes(this.name_searched.toLowerCase()) &&
this.getFilters.every(f => {
return this.data_model[f.key].show(item[f.key])
});
});
},
getResultsCount() {
if (this.getData.length == 1) {
return this.getData.length + ' result'
}
return this.getData.length + ' results'
},
getColumns() {
return this.categories.filter(c => { return c.isColumn });
},
getFilters() {
return this.categories.filter(c => { return c.isFilter });
},
getLang() {
return this.$store.getters.getLang;
}
},
watch: {
show() {
if (this.show) {
let currentRoute = this.route;
if (this.routeParameters !== undefined) {
currentRoute += '?' + this.routeParameters;
}
// Fetch the message again if the route changed
if (this.previousRoute !== currentRoute) {
this.message = [];
}
this.previousRoute = currentRoute;
if (this.message.length === 0) {
this.axios.get(currentRoute)
.then(response => this.message = response.data)
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
}
let self = this;
this.$nextTick().then(() => {
self.name_searched = '';
self.$refs.nameField.focus();
});
}
}
},
mounted() {
// Copy the data model locally to modify "checked" properties
this.categories.forEach(c => {
this.$set(this.data_model, c.key, Utils.copy(DataModel[c.key]));
});
// Overload data model if specified
if (this.dataModel !== undefined) {
for (let [key, data] of Object.entries(this.dataModel)) {
this.$set(this.data_model, key, data);
}
}
}
}
</script>

105
components/ModalSignup.vue Normal file
View File

@@ -0,0 +1,105 @@
<template>
<modal :show="show" @close="$emit('close', false)">
<template v-slot:header>
<h1>Create an account</h1>
</template>
<form @submit.prevent="doLogin()" class="m-1">
<div>
<label for="username" class="">Username</label>
<input class="input w-full" id="username" ref="username" type="text" placeholder="Username" v-model="username" required autofocus>
</div>
<div>
<label for="password">Password</label>
<input class="input w-full" id="password" ref="password" placeholder="Password" type="password" minlength="1" required>
</div>
<div class="pb-2">
<label for="password2">Confirm password</label>
<input class="input w-full" id="password2" ref="password2" placeholder="Password" type="password" minlength="1" required>
</div>
<div>
<label for="email" class="">Email address (optional)</label>
<input class="input w-full" id="email" ref="email" type="email" placeholder="Email address" v-model="email">
</div>
<p v-if="error_message.length > 0">
<fa-icon :icon="['fas', 'exclamation-triangle']" class="text-red-400"></fa-icon>
{{ error_message }}
</p>
<p v-else>&nbsp;</p>
<!-- Honeypot -->
<input type="checkbox" name="captcha" v-model="captcha" style="display:none !important" tabindex="-1" autocomplete="off">
<input class="btn btn-blue pt-2" type="submit" value="Create">
</form>
</modal>
</template>
<script>
import Modal from './common/Modal.vue'
export default {
model: {
prop: 'show',
event: 'close'
},
components: {
Modal
},
props: {
show: {
type: Boolean,
required: true
},
},
data() {
return {
username: "",
email: "",
error_message: "",
captcha: false,
}
},
methods: {
doLogin() {
this.error_message = "";
if (this.$refs.password.value != this.$refs.password2.value) {
this.error_message = "Passwords don't match";
this.$refs.password.value = "";
this.$refs.password2.value = "";
return;
}
if (/([\x00-\x1F\x7F]|\s)/.test(this.username)) {
this.error_message = "Username doesn't match requirements (no whitespace or control characters)";
return;
}
const body = {
username: this.username,
email: this.email,
password: this.$refs.password.value,
captcha: this.captcha
}
this.axios.post('/user/register', body)
.then(response => {
this.$emit('close', false);
this.$emit('user-logged', this.username, response.data.data.userid);
})
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
},
},
watch: {
show() {
if (this.show === true) {
let self = this;
this.$nextTick().then(() => {
self.$refs.username.focus();
});
}
}
}
}
</script>

View File

@@ -0,0 +1,116 @@
<template>
<modal :show="show" :large="true" @close="close()">
<template v-slot:header>
<div v-if=" ! loading" class="columns-2 md:columns-3 grow">
<h3 class="w-1/2 truncate hidden md:block md:w-32 lg:w-64 xl:w-96" :title="party_name">{{ party_name }}</h3>
<a :href="'/builder?p=' + teamId" class="text-center">Open in Party Builder</a>
<like-button :teamId="teamId"></like-button>
</div>
</template>
<div v-if="loading" class="text-center">
Loading...
</div>
<div v-else class="flex flex-col justify-center items-center flex-wrap gap-2">
<div class="flex flex-row justify-center items-start flex-wrap gap-2">
<group-class></group-class>
<group-characters :showLevel="false" :showRing="true"></group-characters>
<group-summons :showLevel="false"></group-summons>
</div>
<div class="flex flex-row flex-wrap justify-center items-start gap-2">
<group-weapons :showLevel="false" :showArcarum="true"></group-weapons>
<span class="flex flex-col w-full self-stretch md:w-128">
<h3 v-if="video">YouTube video:</h3>
<a v-if="video" :href="videoURL" target="_blank">{{ videoURL }}</a>
<h3 v-if="description">Description</h3>
<textarea v-if="description" class="self-stretch h-full w-full md:w-128 appearance-none text-primary bg-primary" v-model="description" readonly></textarea>
<h3 v-if="getActionsText">Actions</h3>
<textarea v-if="getActionsText" class="self-stretch h-full w-full md:w-128 appearance-none text-primary bg-primary" v-model="getActionsText" readonly></textarea>
</span>
</div>
</div>
</modal>
</template>
<script>
import { mapState, mapGetters } from 'vuex'
import Utils from '@/js/utils.js'
import LikeButton from '@/components/common/LikeButton.vue'
import Modal from './common/Modal.vue'
import GroupClass from "@/components/GroupClass.vue";
import GroupCharacters from "@/components/GroupCharacters.vue";
import GroupSummons from "@/components/GroupSummons.vue";
import GroupWeapons from "@/components/GroupWeapons.vue";
export default {
model: {
prop: 'show',
event: 'close'
},
components: {
LikeButton,
Modal,
GroupClass,
GroupCharacters,
GroupSummons,
GroupWeapons
},
props: {
show: {
type: Boolean,
required: true
},
teamId: {
type: Number,
required: true
}
},
data() {
return {
loading: true,
}
},
methods: {
close() {
this.$emit('close', false);
},
},
computed: {
...mapState({
party_name: state => state.party_builder.party_name,
description: state => state.party_builder.description,
video: state => state.party_builder.video_id,
}),
...mapGetters([
'getActionsText'
]),
videoURL() {
if (this.video) {
return 'https://www.youtube.com/watch?v=' + this.video;
}
return null;
}
},
watch: {
teamId() {
if (this.teamId > 0) {
this.loading = true;
this.axios.get('/party/load/' + this.teamId)
.then(response => {
let res = Utils.getPartyResponse(response);
res.party_mode = this.$MODE.ReadOnly;
this.$store.dispatch('loadParty', res);
this.loading = false;
})
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
}
}
}
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<modal :show="show" @close="$emit('close', false)">
<template v-slot:header>
<h2>Please copy your gbf.wiki collection URL below</h2>
</template>
<form @submit.prevent="getURL()" class="m-1">
<div class="mb-2">
<label for="url" class="">URL</label>
<input class="input w-full" id="url" ref="url" type="text" placeholder="URL" v-model="url" required autofocus>
</div>
<input class="btn btn-blue pt-2" type="submit" value="Import">
</form>
</modal>
</template>
<script>
import Modal from './common/Modal.vue'
export default {
model: {
prop: 'show',
event: 'close'
},
components: {
Modal
},
props: {
show: {
type: Boolean,
required: true
},
},
data() {
return {
url: "",
}
},
methods: {
getURL() {
this.$emit('import', this.url);
this.$emit('close', false);
},
},
watch: {
show() {
if (this.show === true) {
let self = this;
this.$nextTick().then(() => {
self.url = '';
self.$refs.url.focus();
});
}
}
}
}
</script>

130
components/ModalYoutube.vue Normal file
View File

@@ -0,0 +1,130 @@
<template>
<modal :show="show" @close="$emit('close', false)">
<template v-slot:header>
<h2>Add a YouTube video</h2>
</template>
<form @submit.prevent="fetchVideo()" class="flex flex-row m-1 gap-x-4">
<label class="flex flex-row whitespace-nowrap items-center gap-x-2 grow">URL
<input class="input w-full" ref="url" type="text" placeholder="YouTube URL" v-model="input_url" required autofocus>
</label>
<input class="btn btn-blue pt-2" type="submit" value="Fetch">
</form>
<div class="flex justify-center" style="height: 360px">
{{ error_message }}
<div id="player"></div>
</div>
<input class="btn btn-blue pt-2" type="button" value="Add" @click="addVideo()">
<input v-if="url" class="btn btn-red pt-2" type="button" value="Remove" @click="removeVideo()">
</modal>
</template>
<script>
import Modal from './common/Modal.vue'
// 2. This code loads the IFrame Player API code asynchronously.
if (VUE_ENV !== 'server') {
let tag = document.createElement('script');
tag.src = "https://www.youtube.com/iframe_api";
let firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
}
export default {
model: {
prop: 'show',
event: 'close'
},
components: {
Modal
},
props: {
show: {
type: Boolean,
required: true
},
url: {
type: String
}
},
data() {
return {
input_url: "",
video_id: null,
player: null,
error_message: "",
}
},
methods: {
fetchVideo() {
if (this.input_url.length > 0) {
this.video_id = this.getYouTubeId(this.input_url);
if (this.video_id == null) {
this.error_message = "Invalid URL";
this.closePlayer();
return;
}
this.error_message = "";
if (this.player === null) {
this.player = new YT.Player('player', {
height: '360',
width: '640',
videoId: this.video_id,
rel: 0,
});
}
else {
this.player.cueVideoById(this.video_id);
}
}
},
getYouTubeId(url) {
const reg = /^(https?:)?(\/\/)?((www\.|m\.)?youtube(-nocookie)?\.com\/((watch)?\?(feature=\w*&)?vi?=|embed\/|vi?\/|e\/)|youtu.be\/)([\w\-]{10,20})/i
const match = url.match(reg);
if (match) {
return match[9];
}
return null;
},
addVideo() {
this.closePlayer();
this.video_id = this.getYouTubeId(this.input_url);
this.$emit('add', this.video_id);
this.$emit('close', false);
},
removeVideo() {
this.video_id = null;
this.addVideo();
},
closePlayer() {
if (this.player) {
this.player.destroy();
this.player = null;
}
}
},
watch: {
show() {
if (this.show === true) {
if (this.url) {
this.video_id = this.url;
this.input_url = "https://www.youtube.com/watch?v=" + this.url;
}
else {
this.video_id = null;
this.input_url = null;
}
let self = this;
this.$nextTick().then(() => {
self.$refs.url.focus();
});
}
},
}
}
</script>

352
components/Parties.vue Normal file
View File

@@ -0,0 +1,352 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-row flex-wrap items-center gap-2" v-if="isUserLogged">
<button class="btn btn-red" @click="new_party_modal = true">
<fa-icon :icon="['fas', 'file']" class="text-xl"></fa-icon> New Party
</button>
<button class="btn btn-blue" @click="show_parties_modal = true">
<fa-icon :icon="['fas', 'folder-open']" class="text-xl"></fa-icon> Load&#8230;
</button>
<button class="btn btn-blue" @click="clickPartySave(current_party)" :disabled=" ! isMyParty">
<fa-icon :icon="['fas', 'save']" class="text-xl"></fa-icon> Save
</button>
<button class="btn btn-blue" @click="save_as_modal = true">
<fa-icon :icon="['fas', 'file-pen']" class="text-xl"></fa-icon> Save As&#8230;
</button>
<button class="btn btn-red" @click="delete_party_modal = true" :disabled=" ! isMyParty">
<fa-icon :icon="['fas', 'trash']" class="text-xl"></fa-icon> Delete
</button>
<button class="btn btn-blue" @click="add_video_modal = true" :disabled=" ! isMyParty">
<fa-icon :icon="['fab', 'youtube']" class="text-xl"></fa-icon> Add Video
</button>
<!-- TODO Twitter, Facebook -->
<button class="btn btn-blue" @click="clickPartyShare()" :disabled=" ! isMyParty">
<fa-icon :icon="['fas', 'share-alt']" class="text-xl"></fa-icon> Share
</button>
<span v-if="current_party === null">(unsaved)</span>
</div>
<div class="flex flex-row flex-wrap items-center gap-2">
<input class="input w-52" type="text" placeholder="Party name" v-model="party_name" maxlength="64">
<label>
Category
<content-categories v-model.number="content"></content-categories>
</label>
<checkbox
v-if="isUserLogged"
v-model="isPublic"
:disabled="cannotBePublic"
:title="cannotBePublic ? 'Parties uncategorized or without a main weapon cannot be made public' : 'Make this team visible in the Teams section'"
>
Public Team
</checkbox>
<a :href="'https://www.youtube.com/watch?v=' + video_id" target="_blank" v-if="video_id" title="Open YouTube video">
<fa-icon :icon="['fab', 'youtube']" class="text-4xl"></fa-icon>
</a>
<like-button :teamId="this.current_party"></like-button>
</div>
<!-- Modals -->
<modal-selector
v-model="show_parties_modal"
route="/party/list"
:categories="getCategories"
:canUnselect="false"
@item-selected="loadPartyFromModal"
:key="reload_route"
></modal-selector>
<modal-confirm
v-model="new_party_modal"
:confirm="clickPartyNew"
text="This will clear the current party and start a new one."
button="New Party"
></modal-confirm>
<modal-confirm
v-model="save_as_modal"
:confirm="clickPartySave"
:text="'This will create a new ' + (this.isPublic ? 'public' : 'private') + ' Party' + (this.party_name ? ' called \'' + this.party_name + '\'.' : ' with no name.')"
button="Save new Party"
></modal-confirm>
<modal-confirm
v-model="delete_party_modal"
:confirm="clickPartyDelete"
text="This will permanently delete this party from your account."
button="Delete Party"
></modal-confirm>
<modal-youtube
v-model="add_video_modal"
:url="video_id"
@add="addVideo"
></modal-youtube>
<input v-show="clipboard_text.length > 0" id="clipboardInput" readonly type="text" :value="clipboard_text">
</div>
</template>
<script>
import { mapState } from 'vuex'
import Utils from '@/js/utils.js'
import Checkbox from '@/components/common/Checkbox.vue'
import ContentCategories from '@/components/common/ContentCategories.vue'
import Dropdown from '@/components/common/Dropdown.vue'
import LikeButton from '@/components/common/LikeButton.vue'
import ModalConfirm from '@/components/ModalConfirm.vue'
import ModalSelector from '@/components/ModalSelector.vue'
import ModalYoutube from '@/components/ModalYoutube.vue'
const BOOKMARKLET_VERSION = 6;
export default {
components: {
Checkbox,
ContentCategories,
Dropdown,
LikeButton,
ModalConfirm,
ModalSelector,
ModalYoutube,
},
data() {
return {
clipboard_text: '',
new_party_modal: false,
save_as_modal: false,
delete_party_modal: false,
show_parties_modal: false,
add_video_modal: false,
reload_route: 0,
}
},
methods: {
clickPartyNew() {
this.$store.commit('resetParty');
this.cleanURL();
},
cleanURL() {
if (VUE_ENV !== 'server') {
history.pushState(null, null, window.location.origin + window.location.pathname);
}
},
loadPartyFromModal(party) {
this.axios.get('/party/load/' + party)
.then(response => this.loadParty(Utils.getPartyResponse(response)))
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
},
clickPartyShare() {
const text = '?p=' + this.current_party + '#';
this.clipboard_text = window.location.href.split('?')[0] + text;
// history.pushState(null, null, text);
let self = this;
this.$nextTick().then(() => {
const input = document.getElementById("clipboardInput");
input.select();
document.execCommand("copy");
self.clipboard_text = '';
self.$store.dispatch('addMessage', {message: 'URL copied to clipboard'});
});
},
clickPartyDelete() {
if (this.current_party) {
this.axios.post('/party/delete', {id: this.current_party})
.then(() => {
this.clickPartyNew();
this.reloadParties();
this.$store.dispatch('addMessage', {message: 'Party deleted successfully'});
})
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
}
},
addVideo(id) {
this.video_id = (id && id.length > 0) ? id : null;
},
clickPartySave(partyId = null) {
let data = {
classe: this.classe.classid,
class_skills: Utils.isEmpty(this.classe.skills) ? null : this.classe.skills.map(s => {return s ? s.skillid : null}), // Skill ids
characters: this.characters.map(e => { return Utils.isEmpty(e) ? null : e.characterid }),
characters_stars: this.characters.map(e => { return Utils.isEmpty(e) ? null : e.stars }),
characters_levels: this.characters.map(e => { return Utils.isEmpty(e) ? null : e.level }),
characters_pluses: this.characters.map(e => { return Utils.isEmpty(e) ? null : e.pluses }),
characters_prings: this.characters.map(e => { return Utils.isEmpty(e) ? null : e.haspring }),
summons: this.summons.map(e => { return Utils.isEmpty(e) ? null : e.summonid }),
summons_levels: this.summons.map(e => { return Utils.isEmpty(e) ? null : e.level }),
summons_pluses: this.summons.map(e => { return Utils.isEmpty(e) ? null : e.pluses }),
summons_stars: this.summons.map(e => { return Utils.isEmpty(e) ? null : e.stars }),
weapons: this.weapons.map(e => { return Utils.isEmpty(e) ? null : e.weaponid }),
weapons_levels: this.weapons.map(e => { return Utils.isEmpty(e) ? null : e.level }),
weapons_pluses: this.weapons.map(e => { return Utils.isEmpty(e) ? null : e.pluses }),
weapons_skill_levels: this.weapons.map(e => { return Utils.isEmpty(e) ? null : e.sklevel }),
weapons_skill_names: this.weapons.map(e => { return Utils.isEmpty(e) ? null : e.keys.map(k => k ? k.name : null) }),
weapons_stars: this.weapons.map(e => { return Utils.isEmpty(e) ? null : e.stars }),
actions: this.actions.map(e => { return [e.sourceSlot-1, e.skillSlot-1, e.type] }),
}
this.axios.post('/party/save', {
id: partyId,
name: this.party_name,
data: data,
content: this.content,
isPublic: this.isPublic,
desc: this.description,
video: this.video_id,
})
.then(response => {
this.current_party = response.data.id;
this.team_owner = this.$store.getters.getUserId;
this.reloadParties();
this.$store.dispatch('addMessage', {message: 'Party saved successfully'});
})
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
},
reloadParties() {
this.reload_route++;
},
loadParty(data) {
// Clean URL when party id changes
if ( ! Utils.isEmpty(this.$route.query.p)) {
const param = parseInt(this.$route.query.p, 10);
if (data.id !== param) {
this.cleanURL();
}
}
this.$store.dispatch('loadParty', data);
},
},
computed: {
...mapState({
classe: state => state.party_builder.classe,
characters: state => state.party_builder.characters,
summons: state => state.party_builder.summons,
weapons: state => state.party_builder.weapons,
actions: state => state.party_builder.actions,
description: state => state.party_builder.description,
}),
isUserLogged() {
return this.$store.getters.getUserId !== null;
},
content: {
get() { return this.$store.state.party_builder.content },
set(value) { this.$store.commit('setContent', value) }
},
isPublic: {
get() { return this.$store.state.party_builder.isPublic },
set(value) { this.$store.commit('setPublic', value) }
},
team_owner: {
get() { return this.$store.state.party_builder.team_owner },
set(value) { this.$store.commit('setTeamOwner', value) }
},
party_name: {
get() { return this.$store.state.party_builder.party_name },
set(value) { this.$store.commit('setPartyName', value) }
},
current_party: {
get() { return this.$store.state.party_builder.current_party },
set(value) { this.$store.commit('setCurrentParty', value) }
},
video_id: {
get() { return this.$store.state.party_builder.video_id },
set(value) { this.$store.commit('setVideoId', value) }
},
isMyParty() {
return this.team_owner === this.$store.getters.getUserId;
},
cannotBePublic() {
return this.content === null || Utils.isEmpty(this.weapons[0]);
},
getCategories() {
return [
{
name: "Name",
isColumn: true,
isFilter: false,
key: "n",
},
{
name: "Element",
isColumn: true,
isFilter: true,
key: "e",
},
{
name: "Public",
isColumn: true,
isFilter: true,
key: "pub",
},
];
}
},
watch: {
'$store.getters.getUserId'(id) {
if (id !== null) {
this.reloadParties();
}
},
},
serverPrefetch() {
let promises = [];
/**
* Load party from URL
*/
// Bookmarklet
if ( ! Utils.isEmpty(this.$route.query.l)) {
const data = JSON.parse(this.$route.query.l);
const postData = {
classe: data.p,
class_skills: data.ps, // Skill names
characters: data.c,
summons: data.s,
weapons: data.w,
}
if ( ! data.v || data.v < BOOKMARKLET_VERSION) {
this.$emit('update-bookmarklet');
}
promises.push(
this.axios.post('/party/load', postData)
.then(response => this.loadParty({
data: response.data,
characters_levels: data.cl,
characters_stars: data.cs,
characters_pluses: data.cp,
characters_prings: data.cwr,
summons_levels: data.sl,
summons_stars: data.ss,
summons_pluses: data.sp,
weapons_levels: data.wll,
weapons_skill_levels: data.wl,
weapons_skill_names: data.wsn,
weapons_pluses: data.wp
}))
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error))
);
}
// Party ID
else if ( ! Utils.isEmpty(this.$route.query.p)) {
const param = parseInt(this.$route.query.p, 10);
promises.push(
this.axios.get('/party/load/' + param)
.then(response => {
this.loadParty(Utils.getPartyResponse(response));
const timestamp = response.data.updated ? response.data.updated : '0';
this.$ssrContext.head_image = 'https://www.granblue.party/previews/party/party_' + param + '.' + timestamp + '.jpg';
})
.catch(_ => this.$store.dispatch('addMessage', { title: 'Error', message: 'Party not found'}))
);
}
return Promise.all(promises);
},
}
</script>

View File

@@ -0,0 +1,204 @@
<template>
<div class="flex flex-col gap-4">
<span class="flex flex-row flex-wrap gap-2 items-center">
<div>Search in</div>
<checkbox v-model="search_name">Names</checkbox>
<checkbox v-model="search_ca_names">Charge attack names</checkbox>
<checkbox v-model="search_ca_desc">Charge attack descriptions</checkbox>
<checkbox v-model="search_skill_names">Skill names</checkbox>
<checkbox v-model="search_skill_desc">Skill descriptions</checkbox>
</span>
<div class="flex flex-row flex-wrap items-center gap-2">
<data-filter
v-for="category in getFilters"
:key="category.name"
:category="category.name"
:data="data_model[category.key].data"
></data-filter>
</div>
<form @submit.prevent="search()" class="flex flex-row flex-wrap gap-2">
<input class="input" type="text" placeholder="Search" ref="searchfield" autofocus>
<button class="btn btn-blue" type="submit">
<fa-icon :icon="['fas', 'search']" class="text-xl"></fa-icon> Search
</button>
<checkbox v-model="case_sensitive">Match case</checkbox>
<checkbox v-model="whole_word">Match whole word</checkbox>
</form>
<div id="results">
Results: {{ getResults.length }}
</div>
<div v-for="(item, k) in getResultsSlice()" :key="k" class="flex flex-col lg:flex-row bg-secondary p-2 gap-2">
<span class="flex flex-col gap-2">
<!-- Name -->
<span>
{{ index * slice_size + k + 1 }}-
<a class="font-medium" target="_blank" :href="'https://gbf.wiki/' + item.n" v-html="highlight(item.n, search_name)"></a>
<br>
<div class="font-medium">
{{ data_model['e'].expand(item) }}
{{ data_model['w'].expand(item) }}
</div>
</span>
<img style="max-height: 96px; max-width:168px; height: 96px; width:168px;" :src="'/img/unit_small/' + item.id + '000.jpg'">
</span>
<span class="flex flex-col">
<!-- Ougi -->
<div class="flex flex-col lg:flex-row lg:items-center flex-wrap lg:flex-nowrap mt-2 gap-2">
<h4 class="whitespace-nowrap">Charge attack:</h4>
<div class="divide-y divide-inherit border-secondary">
<div v-for="(ougi, l) in item.o" :key="l" class="flex flex-col lg:flex-row lg:items-center gap-x-4">
<div class="whitespace-nowrap font-medium" v-html="highlight(ougi.n, search_ca_names)" />
<div class="text-sm" v-html="highlight(ougi.d, search_ca_desc)" />
</div>
</div>
</div>
<!-- Skills -->
<div v-if="item.s" class="flex flex-col lg:flex-row lg:items-center flex-wrap lg:flex-nowrap mt-2 gap-2">
<h4 class="whitespace-nowrap">Skills:</h4>
<div class="divide-y divide-inherit border-secondary">
<div v-for="(skill, l) in item.s" :key="l" class="flex flex-col lg:flex-row lg:items-center gap-x-4">
<div class="whitespace-nowrap font-medium" v-html="highlight(skill.n, search_skill_names)" />
<div class="text-sm" v-html="highlight(skill.d, search_skill_desc)" />
</div>
</div>
</div>
</span>
</div>
<nav class="flex flex-row flex-wrap gap-2" role="navigation" aria-label="pagination" v-if="getResults.length > slice_size">
<span
class="px-2 py-1 rounded cursor-pointer hover:text-link-hover"
:class="index === i-1 ? 'bg-tertiary' : 'bg-secondary'"
v-for="i in Math.ceil(getResults.length / slice_size)"
:key="i"
@click="changeSlice(i)"
>{{ i }}</span>
</nav>
</div>
</template>
<script>
import Utils from '@/js/utils.js'
import MixinSearch from '@/js/mixin-search.js'
import Checkbox from '@/components/common/Checkbox.vue'
import DataFilter from '@/components/common/DataFilter.vue'
const lsMgt = new Utils.LocalStorageMgt('SearchCharacters');
const categories = [
{
name: "Rarity",
isColumn: false,
isFilter: true,
key: "ri",
},
{
name: "Element",
isColumn: true,
isFilter: true,
key: "e",
},
{
name: "Type",
isColumn: true,
isFilter: true,
key: "t",
},
{
name: "Race",
isColumn: true,
isFilter: true,
key: "ra",
},
{
name: "Weapon",
isColumn: true,
isFilter: true,
key: "w",
},
];
export default {
components: {
Checkbox,
DataFilter
},
mixins: [
MixinSearch(categories, lsMgt)
],
data() {
return {
search_name: false,
search_ca_names: false,
search_ca_desc: true,
search_skill_names: false,
search_skill_desc: true,
}
},
methods: {
search_impl(re) {
this.results = this.data.filter(obj => {
if (this.search_name && obj.n !== null) {
if (re.test(obj.n)) {
return true;
}
}
if (obj.o !== null) {
if (this.search_ca_names && obj.o.some(ougi => re.test(ougi.n))) {
return true;
}
if (this.search_ca_desc && obj.o.some(ougi => re.test(ougi.d))) {
return true;
}
}
if (obj.s !== null) {
if (this.search_skill_names && obj.s.some(skill => re.test(skill.n))) {
return true;
}
if (this.search_skill_desc && obj.s.some(skill => re.test(skill.d))) {
return true;
}
}
return false;
});
},
},
watch: {
search_name() {
lsMgt.setValue('search_name', this);
},
search_ca_names() {
lsMgt.setValue('search_ca_names', this);
},
search_ca_desc() {
lsMgt.setValue('search_ca_desc', this);
},
search_skill_names() {
lsMgt.setValue('search_skill_names', this);
},
search_skill_desc() {
lsMgt.setValue('search_skill_desc', this);
},
},
mounted() {
lsMgt.getValue(this, 'search_name');
lsMgt.getValue(this, 'search_ca_names');
lsMgt.getValue(this, 'search_ca_desc');
lsMgt.getValue(this, 'search_skill_names');
lsMgt.getValue(this, 'search_skill_desc');
this.axios.get('/search/characters')
.then(response => this.data = response.data)
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
}
}
</script>

View File

@@ -0,0 +1,230 @@
<template>
<div class="flex flex-col gap-4">
<span class="flex flex-row flex-wrap gap-2 items-center">
<div>Search in</div>
<checkbox v-model="search_name">Names</checkbox>
<checkbox v-model="search_call_names">Call names</checkbox>
<checkbox v-model="search_call_desc">Call descriptions</checkbox>
<checkbox v-model="search_auras">Auras</checkbox>
<checkbox v-model="search_subauras">Sub auras</checkbox>
</span>
<div class="flex flex-row flex-wrap items-center gap-2">
<data-filter
v-for="category in getFilters"
:key="category.name"
:category="category.name"
:data="data_model[category.key].data"
></data-filter>
</div>
<form @submit.prevent="search()" class="flex flex-row flex-wrap gap-2">
<input class="input" type="text" placeholder="Search" ref="searchfield" autofocus>
<button class="btn btn-blue" type="submit">
<fa-icon :icon="['fas', 'search']" class="text-xl"></fa-icon> Search
</button>
<checkbox v-model="case_sensitive">Match case</checkbox>
<checkbox v-model="whole_word">Match whole word</checkbox>
</form>
<div id="results">
Results: {{ getResults.length }}
</div>
<div v-for="(item, k) in getResultsSlice()" :key="k" class="flex flex-col lg:flex-row bg-secondary p-2 gap-2">
<span class="flex flex-col gap-2">
<!-- Name -->
<span>
{{ index * slice_size + k + 1 }}-
<a class="font-medium" target="_blank" :href="'https://gbf.wiki/' + item.n" v-html="highlight(item.n, search_name)"></a>
<br>
<div class="font-medium">
{{ data_model['e'].expand(item) }}
</div>
</span>
<img style="max-height: 104px; max-width:138px; height: 104px; width:138px;" :src="'/img/unit/' + item.id + '000.jpg'">
</span>
<span class="flex flex-col">
<!-- Call -->
<div class="flex flex-col lg:flex-row lg:items-center flex-wrap lg:flex-nowrap mt-2 gap-2">
<h4 class="whitespace-nowrap">Call:</h4>
<div class="whitespace-nowrap font-medium" v-html="highlight(item.cn, search_call_names)" />
<div v-if="hasData(item.c)" class="divide-y divide-inherit border-secondary">
<div v-if="item.c[0]" class="text-sm" v-html="highlight(item.c[0], search_call_desc)" />
<div v-if="item.c[1]" class="flex flex-row lg:items-center gap-x-4">
<div class="whitespace-nowrap font-medium">MLB</div>
<div class="text-sm" v-html="highlight(item.c[1], search_call_desc)" />
</div>
<div v-if="item.c[2]" class="flex flex-row lg:items-center gap-x-4">
<div class="whitespace-nowrap font-medium">FLB</div>
<div class="text-sm" v-html="highlight(item.c[2], search_call_desc)" />
</div>
<div v-if="item.c[3]" class="flex flex-row lg:items-center gap-x-4">
<div class="whitespace-nowrap font-medium">ULB</div>
<div class="text-sm" v-html="highlight(item.c[3], search_call_desc)" />
</div>
</div>
</div>
<!-- Aura -->
<div v-if="hasData(item.a)" class="flex flex-row lg:items-center flex-wrap lg:flex-nowrap mt-2 gap-2">
<h4 class="whitespace-nowrap">Auras:</h4>
<div class="divide-y divide-inherit border-secondary">
<div v-if="item.a[0]" class="text-sm" v-html="highlight(item.a[0], search_auras)" />
<div v-if="item.a[1]" class="flex flex-row lg:items-center gap-x-4">
<div class="whitespace-nowrap font-medium">MLB</div>
<div class="text-sm" v-html="highlight(item.a[1], search_auras)" />
</div>
<div v-if="item.a[2]" class="flex flex-row lg:items-center gap-x-4">
<div class="whitespace-nowrap font-medium">FLB</div>
<div class="text-sm" v-html="highlight(item.a[2], search_auras)" />
</div>
<div v-if="item.a[3]" class="flex flex-row lg:items-center gap-x-4">
<div class="whitespace-nowrap font-medium">ULB</div>
<div class="text-sm" v-html="highlight(item.a[3], search_auras)" />
</div>
</div>
</div>
<!-- Sub Aura -->
<div v-if="hasData(item.s)" class="flex flex-row lg:items-center flex-wrap lg:flex-nowrap mt-2 gap-2">
<h4 class="whitespace-nowrap">Sub auras:</h4>
<div class="divide-y divide-inherit border-secondary">
<div v-if="item.s[0]" class="text-sm" v-html="highlight(item.s[0], search_subauras)" />
<div v-if="item.s[1]" class="flex flex-row lg:items-center gap-x-4">
<div class="whitespace-nowrap font-medium">MLB</div>
<div class="text-sm" v-html="highlight(item.s[1], search_subauras)" />
</div>
<div v-if="item.s[2]" class="flex flex-row lg:items-center gap-x-4">
<div class="whitespace-nowrap font-medium">FLB</div>
<div class="text-sm" v-html="highlight(item.s[2], search_subauras)" />
</div>
<div v-if="item.s[3]" class="flex flex-row lg:items-center gap-x-4">
<div class="whitespace-nowrap font-medium">ULB</div>
<div class="text-sm" v-html="highlight(item.s[3], search_subauras)" />
</div>
</div>
</div>
</span>
</div>
<nav class="flex flex-row flex-wrap gap-2" role="navigation" aria-label="pagination" v-if="getResults.length > slice_size">
<span
class="px-2 py-1 rounded cursor-pointer hover:text-link-hover"
:class="index === i-1 ? 'bg-tertiary' : 'bg-secondary'"
v-for="i in Math.ceil(getResults.length / slice_size)"
:key="i"
@click="changeSlice(i)"
>{{ i }}</span>
</nav>
</div>
</template>
<script>
import Utils from '@/js/utils.js'
import MixinSearch from '@/js/mixin-search.js'
import Checkbox from '@/components/common/Checkbox.vue'
import DataFilter from '@/components/common/DataFilter.vue'
const lsMgt = new Utils.LocalStorageMgt('SearchSummons');
const categories = [
{
name: "Rarity",
isColumn: false,
isFilter: true,
key: "ri",
},
{
name: "Element",
isColumn: true,
isFilter: true,
key: "e",
},
];
export default {
components: {
Checkbox,
DataFilter
},
mixins: [
MixinSearch(categories, lsMgt)
],
data() {
return {
search_name: false,
search_call_names: false,
search_call_desc: true,
search_auras: true,
search_subauras: true,
}
},
methods: {
search_impl(re) {
this.results = this.data.filter(obj => {
if (this.search_name && obj.n !== null) {
if (re.test(obj.n)) {
return true;
}
}
if (this.search_call_names && obj.cn !== null) {
if (re.test(obj.cn)) {
return true;
}
}
if (this.search_call_desc && obj.c !== null) {
if (obj.c.some(call => re.test(call))) {
return true;
}
}
if (this.search_auras && obj.a !== null) {
if (obj.a.some(call => re.test(call))) {
return true;
}
}
if (this.search_subauras && obj.s !== null) {
if (obj.s.some(call => re.test(call))) {
return true;
}
}
return false;
});
},
},
watch: {
search_name() {
lsMgt.setValue('search_name', this);
},
search_call_names() {
lsMgt.setValue('search_call_names', this);
},
search_call_desc() {
lsMgt.setValue('search_call_desc', this);
},
search_auras() {
lsMgt.setValue('search_auras', this);
},
search_subauras() {
lsMgt.setValue('search_subauras', this);
},
},
mounted() {
lsMgt.getValue(this, 'search_name');
lsMgt.getValue(this, 'search_call_names');
lsMgt.getValue(this, 'search_call_desc');
lsMgt.getValue(this, 'search_auras');
lsMgt.getValue(this, 'search_subauras');
this.axios.get('/search/summons')
.then(response => this.data = response.data)
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
}
}
</script>

View File

@@ -0,0 +1,182 @@
<template>
<div class="flex flex-col gap-4">
<span class="flex flex-row flex-wrap gap-2 items-center">
<div>Search in</div>
<checkbox v-model="search_name">Names</checkbox>
<checkbox v-model="search_ca">Charge attacks</checkbox>
<checkbox v-model="search_skill_names">Skill names</checkbox>
<checkbox v-model="search_skill_desc">Skill descriptions</checkbox>
</span>
<div class="flex flex-row flex-wrap items-center gap-2">
<data-filter
v-for="category in getFilters"
:key="category.name"
:category="category.name"
:data="data_model[category.key].data"
></data-filter>
</div>
<form @submit.prevent="search()" class="flex flex-row flex-wrap gap-2">
<input class="input" type="text" placeholder="Search" ref="searchfield" autofocus>
<button class="btn btn-blue" type="submit">
<fa-icon :icon="['fas', 'search']" class="text-xl"></fa-icon> Search
</button>
<checkbox v-model="case_sensitive">Match case</checkbox>
<checkbox v-model="whole_word">Match whole word</checkbox>
</form>
<div id="results">
Results: {{ getResults.length }}
</div>
<div v-for="(item, k) in getResultsSlice()" :key="k" class="flex flex-col lg:flex-row bg-secondary p-2 gap-2">
<span class="flex flex-col gap-2">
<!-- Name -->
<span>
{{ index * slice_size + k + 1 }}-
<a class="font-medium" target="_blank" :href="'https://gbf.wiki/' + item.n" v-html="highlight(item.n, search_name)"></a>
<br>
<div class="font-medium">
{{ data_model['e'].expand(item) }}
{{ data_model['w'].expand(item) }}
</div>
</span>
<img style="max-height: 96px; max-width:168px; height: 96px; width:168px;" :src="'/img/weapon/' + item.id + '00.jpg'">
</span>
<span class="flex flex-col">
<!-- Ougi -->
<div class="flex flex-col lg:flex-row lg:items-center flex-wrap lg:flex-nowrap mt-2 gap-2">
<h4 class="whitespace-nowrap">Charge attack:</h4>
<div class="divide-y divide-inherit border-secondary">
<div v-if="item.o[0]" class="text-sm" v-html="highlight(item.o[0], search_ca)" />
<div v-if="item.o[1]" class="flex flex-col lg:flex-row lg:items-center gap-x-4">
<div class="whitespace-nowrap font-medium">FLB effect</div>
<div class="text-sm" v-html="highlight(item.o[1], search_ca)" />
</div>
<div v-if="item.o[2]" class="flex flex-col lg:flex-row lg:items-center gap-x-4">
<div class="whitespace-nowrap font-medium">ULB effect</div>
<div class="text-sm" v-html="highlight(item.o[2], search_ca)" />
</div>
</div>
</div>
<!-- Skills -->
<div v-if="item.s" class="flex flex-col lg:flex-row lg:items-center flex-wrap lg:flex-nowrap mt-2 gap-2">
<h4 class="whitespace-nowrap">Skills:</h4>
<div class="divide-y divide-inherit border-secondary">
<div v-for="(skill, l) in item.s" :key="l" class="flex flex-col lg:flex-row lg:items-center gap-x-4">
<div class="whitespace-nowrap font-medium" v-html="highlight(skill.n, search_skill_names)" />
<div class="text-sm" v-html="highlight(skill.d, search_skill_desc)" />
</div>
</div>
</div>
</span>
</div>
<nav class="flex flex-row flex-wrap gap-2" role="navigation" aria-label="pagination" v-if="getResults.length > slice_size">
<span
class="px-2 py-1 rounded cursor-pointer hover:text-link-hover"
:class="index === i-1 ? 'bg-tertiary' : 'bg-secondary'"
v-for="i in Math.ceil(getResults.length / slice_size)"
:key="i"
@click="changeSlice(i)"
>{{ i }}</span>
</nav>
</div>
</template>
<script>
import Utils from '@/js/utils.js'
import MixinSearch from '@/js/mixin-search.js'
import Checkbox from '@/components/common/Checkbox.vue'
import DataFilter from '@/components/common/DataFilter.vue'
const lsMgt = new Utils.LocalStorageMgt('SearchWeapons');
const categories = [
{
name: "Element",
isColumn: true,
isFilter: true,
key: "e",
},
{
name: "Type",
isColumn: true,
isFilter: true,
key: "w",
},
];
export default {
components: {
Checkbox,
DataFilter
},
mixins: [
MixinSearch(categories, lsMgt)
],
data() {
return {
search_name: false,
search_ca: true,
search_skill_names: false,
search_skill_desc: true,
}
},
methods: {
search_impl(re) {
this.results = this.data.filter(obj => {
if (this.search_name && obj.n !== null) {
if (re.test(obj.n)) {
return true;
}
}
if (this.search_ca && obj.o !== null) {
if (obj.o.some(ougi => re.test(ougi))) {
return true;
}
}
if (obj.s !== null) {
if (this.search_skill_names && obj.s.some(skill => re.test(skill.n))) {
return true;
}
if (this.search_skill_desc && obj.s.some(skill => re.test(skill.d))) {
return true;
}
}
return false;
});
},
},
watch: {
search_name() {
lsMgt.setValue('search_name', this);
},
search_ca() {
lsMgt.setValue('search_ca', this);
},
search_skill_names() {
lsMgt.setValue('search_skill_names', this);
},
search_skill_desc() {
lsMgt.setValue('search_skill_desc', this);
},
},
mounted() {
lsMgt.getValue(this, 'search_name');
lsMgt.getValue(this, 'search_ca');
lsMgt.getValue(this, 'search_skill_names');
lsMgt.getValue(this, 'search_skill_desc');
this.axios.get('/search/weapons')
.then(response => this.data = response.data)
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
}
}
</script>

65
components/SparkUnit.vue Normal file
View File

@@ -0,0 +1,65 @@
<template>
<div class="flex flex-col relative" :style="'width: ' + width + 'px;'">
<a
class="text-xs text-primary h-5 px-1 text-center truncate"
target="_blank"
:href="'https://gbf.wiki/' + unit.n"
:title="getName(unit)"
>{{ getName(unit) }}</a>
<img
class="cursor-pointer"
:style="'min-width: ' + width + 'px;'"
:height="height"
:width="width"
:title="getName(unit)"
:src="'/img/unit_small/' + unit.id + '000.jpg'"
@click="$emit('left-click-unit')"
@contextmenu.prevent="$emit('right-click-unit')"
>
<img
v-if="isSpark === true"
class="absolute bottom-0 right-0 pointer-events-none"
height="39"
width="38"
src="/img/item/ceruleanspark.png"
>
</div>
</template>
<script>
import { LANGUAGES } from '@/js/lang'
export default {
props: {
unit: { // Yellow stars
type: Object,
required: true
},
width: {
type: Number,
default: 105
},
height: {
type: Number,
default: 60
},
isSpark: {
type: Boolean,
default: false
}
},
methods: {
getName(element) {
if (this.isLangEnglish) {
return element.n;
}
return element.nj;
},
},
computed: {
isLangEnglish() {
return this.$store.getters.getLang === LANGUAGES.EN;
}
}
}
</script>

133
components/StarsLine.vue Normal file
View File

@@ -0,0 +1,133 @@
<template>
<div v-if="base !== undefined" class="flex flex-col flex-nowrap" :title="title">
<div class="flex flex-row flex-nowrap">
<img
v-for="i in getYellowStarsCount"
:key="'y' + i"
:class="readOnly ? '' : 'cursor-pointer'"
:src="getImage('y', i)"
:style="'width: ' + 100/max + '%;'"
@click="click(i)"
>
<img
v-for="i in getBlueStarsCount"
:key="'b' + i"
:class="readOnly ? '' : 'cursor-pointer'"
:src="getImage('b', i+base)"
:style="'width: ' + 100/max + '%;'"
@click="click(i+base)"
>
<img
v-for="i in getInvisibleStarsCount"
:key="'i' + i"
class="hidden"
src="/img/star_b1.png"
:style="'width: ' + 100/max + '%;'"
>
</div>
<div v-if="transcendance && extra > 5" class="flex flex-row flex-nowrap">
<img
v-for="i in 5"
:key="'v' + i"
:class="getTranscendanceClass(5+i)"
:src="getTranscendanceImage(i)"
:style="'width: 20%;'"
@click="click(5+i)"
>
</div>
</div>
</template>
<script>
export default {
props: {
base: { // Yellow stars
type: Number,
required: true
},
extra: { // Blue stars
type: Number,
required: true
},
current: { // Number of checked stars
type: Number,
required: true
},
max: { // For line size
type: Number,
required: true
},
transcendance: {
type: Boolean,
default: false
},
readOnly: {
type: Boolean,
default: false
},
title: {
type: String,
default: 'Uncap level'
}
},
methods: {
isStarEnabled(index) {
return index <= this.current;
},
getImage(type, index) {
return '/img/star_' + type + (this.isStarEnabled(index) ? "1" : "0") + '.png';
},
getTranscendanceImage(index) {
if ( ! this.isStarEnabled(index + 5)) {
return '/img/star_v0.png';
}
return '/img/star_v' + index + '.png';
//return '/img/star_v' + index + '.png';
},
getTranscendanceClass(index) {
let result = '';
if ( ! this.readOnly) result += 'cursor-pointer ';
if ( ! this.isStarEnabled(index)) result += 'grayscale-80 opacity-70';
return result;
},
click(index) {
if (this.readOnly) {
return;
}
let current_stars = index;
if (index === 1 && this.current !== 0) {
// First click on 1st star sets 0 stars instead of 1
current_stars = 0;
}
else if (index === this.current) {
current_stars = index - 1;
}
this.$emit('update:current', current_stars);
}
},
computed: {
getYellowStarsCount() {
return this.base;
},
getBlueStarsCount() {
if (this.base > this.extra) {
return 0;
}
if (this.transcendance && this.extra > 5) {
return Math.min(this.extra, 5) - this.base;
}
return this.extra - this.base;
},
getInvisibleStarsCount() {
const baseStars = Math.max(this.base, this.extra);
if (baseStars > this.max) {
return 0;
}
return this.max - baseStars;
},
},
}
</script>

View File

@@ -0,0 +1,64 @@
<template>
<div
@click="changeValue()"
class="select-none flex flex-row flex-nowrap items-center"
:class="getClasses"
>
<!-- Box -->
<fa-icon v-if="value === true" :icon="on" :class="iconSize"></fa-icon>
<fa-icon v-else :icon="off" :class="iconSize"></fa-icon>
<!-- Label -->
<span class="ml-1 my-1 text-primary">
<slot></slot>
</span>
</div>
</template>
<script>
export default {
props: {
value: {
type: Boolean,
required: true
},
disabled: {
type: Boolean,
default: false,
},
on: {
type: Array,
default: () => ['fa', 'toggle-on']
},
off: {
type: Array,
default: () => ['fa', 'toggle-off']
},
iconSize: {
type: String,
default: 'text-4xl'
}
},
methods: {
changeValue() {
if ( ! this.disabled) {
this.$emit("input", ! this.value);
}
}
},
computed: {
getClasses() {
let classes = this.value ? 'text-blue-300 ' : 'text-rose-400 ';
if (this.disabled) {
classes += ' cursor-not-allowed grayscale-70 opacity-70 ';
}
else {
classes += 'cursor-pointer hover:underline hover:decoration-2 ';
classes += this.value ?
'hover:text-blue-400 hover:decoration-blue-400 ' :
'hover:text-rose-600 hover:decoration-rose-600 ';
}
return classes;
}
}
}
</script>

View File

@@ -0,0 +1,69 @@
<template>
<!-- <dropdown :value="value" :index="index" @change="changeValue" ref="select"> -->
<dropdown :value="value" @change="changeValue" class="w-52" :disabled="disabled">
<option v-if="all" :value="-1">--- All Content ---</option>
<optgroup
v-for="(step, index) in content"
:key="index"
:label="step.name"
>
<option v-for="raid in filterContent(step.content)" :key="raid.id" :value="raid.id">
{{ raid.name }}
</option>
</optgroup>
</dropdown>
</template>
<script>
import Content from '@/js/content'
import Dropdown from '@/components/common/Dropdown.vue'
export default {
components: {
Dropdown,
},
props: {
value: {
//required: true, Cannot uncomment, since it can be null
type: Number
},
all: {
required: false,
type: Boolean,
default: false
},
showPrivateCategories: {
required: false,
type: Boolean,
default: true
},
disabled: {
type: Boolean,
default: false,
},
},
methods: {
filterContent(content) {
if ( ! this.showPrivateCategories) {
return content.filter(raid => raid.private !== true);
}
return content;
},
changeValue(e) {
this.$emit('change', e);
if (e.target.value === '') {
this.$emit('input', null);
}
else {
this.$emit('input', e.target.value);
}
}
},
computed: {
content() {
return Content;
}
}
}
</script>

View File

@@ -0,0 +1,115 @@
<template>
<div class="flex flex-row items-center">
<span class="pr-2">{{ category }}</span>
<span class="inline-flex flex-row flex-wrap btn-group">
<button
v-if="hasAll"
class="btn btn-sm"
:class="all ? 'btn-blue' : 'btn-white'"
@click="clickAll()"
>
All
</button>
<button
class="btn btn-sm relative"
:class="getClassesForItem(item)"
v-for="(item, index) in data_view"
:key="index"
@click="clickItem(index)"
>
{{ item.name }}
<div
v-if="count"
class="absolute w-5 -top-2 -right-1 z-10 rounded-full text-xs leading-5 tracking-tight bg-tertiary text-primary"
>
{{ count[index] > 0 ? count[index] : '-' }}
</div>
</button>
</span>
</div>
</template>
<script>
import Utils from '@/js/utils.js'
export default {
props: {
data: {
type: Array,
required: true,
},
count: {
type: Array,
default: undefined
},
category: {
type: String,
required: true,
},
hasAll: {
type: Boolean,
default: true,
}
},
data() {
return {
all: true,
data_view: [],
}
},
methods: {
clickAll() {
// check
if ( ! this.all) {
this.all = true;
this.data_view.forEach(e => e.checked = false);
this.data.forEach(e => e.checked = true);
}
},
clickItem(index) {
this.data_view[index].checked = ! this.data_view[index].checked;
if (this.all === true) {
this.all = false;
// Propagate change
for (let i=0; i<this.data.length; i++) {
this.$set(this.data[i], 'checked', this.data_view[i].checked);
}
}
else {
this.$set(this.data[index], 'checked', this.data_view[index].checked);
}
},
getClassesForItem(item) {
let classes = item.checked ? 'btn-blue ' : 'btn-white ';
if (this.count) {
classes += 'pr-3 ';
}
return classes;
},
},
created() {
if (this.hasAll === true) {
// Copy data locally to deal with the All button
this.data_view = Utils.copy(this.data);
if (this.data_view.some(e => ! e.checked)) {
this.all = false;
}
else {
this.data_view.forEach(e => e.checked = false);
this.all = true;
}
}
else {
// Data view is the same as the data
this.data_view = this.data;
// Disable All button
this.all = false;
}
},
}
</script>

View File

@@ -0,0 +1,43 @@
<template>
<div class="inline-block relative">
<select :value="value" @change="changeValue" class="block select select-sm w-full" ref="select" :disabled="disabled">
<slot></slot>
</select>
<div v-if="! disabled" class="pointer-events-none absolute inset-y-0 right-0 rounded-md flex items-center px-2 text-gray-700">
<fa-icon :icon="['fas', 'angle-down']" class="text-xl"></fa-icon>
</div>
</div>
</template>
<script>
export default {
props: {
value: {
required: false
},
index: {
type: Number,
required: false,
},
disabled: {
type: Boolean,
default: false,
},
},
methods: {
changeValue(e) {
this.$emit('change', e);
this.$emit('input', this.$refs.select.value);
}
},
watch: {
value() {
// Selected index takes some time to update. Don't batch it in changeValue
this.$nextTick().then(() => {
this.$emit('update:index', this.$refs.select.selectedIndex);
});
}
}
}
</script>

View File

@@ -0,0 +1,76 @@
<template>
<ins
class="adsbygoogle responsive_ad"
style="display:block"
:style="$isDebug ? 'border: 1px solid grey;' : ''"
data-ad-client="ca-pub-2769716391947040"
:data-ad-slot="adSlot"
:data-ad-region="ad_region"
:key="ad_region"
data-ad-format="fluid"
data-full-width-responsive="true"
></ins>
</template>
<script>
export default {
props: {
adSlot: {
required: true
},
},
data() {
return {
ad_region: null,
}
},
methods: {
getNewAds() {
if (VUE_ENV === 'server') {
return;
}
this.ad_region = 'page-' + Math.random();
this.$nextTick(() => {
try {
(window.adsbygoogle = window.adsbygoogle || []).push({})
} catch (error) {
//console.error(error)
}
});
},
},
mounted() {
this.getNewAds();
},
watch: {
'$route.path'(to, from) {
if (to !== from) {
this.getNewAds();
}
},
}
}
</script>
<style scoped>
.responsive_ad {
width: 320px;
height: 100px;
}
@media(min-width: 500px) {
.responsive_ad {
width: 468px;
height: 60px;
}
}
@media(min-width: 800px) {
.responsive_ad {
width: 728px;
height: 90px;
}
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<checkbox
v-show="team_owner && ! isMyParty"
:value="liked"
@input="clickLike"
:disabled="team_owner && ! isUserLogged"
:title="isUserLogged ? '' : 'Log in to like this team'"
:off="['far', 'circle']"
:on="['far', 'face-grin-wide']"
iconSize="text-3xl"
>
{{ liked ? 'Liked' : 'Like this team' }}
</checkbox>
</template>
<script>
import { mapState } from 'vuex'
import Checkbox from '@/components/common/Checkbox.vue'
export default {
components: {
Checkbox,
},
props: {
teamId: {
type: Number
}
},
methods: {
clickLike(e) {
if (e) {
this.axios.get('/party/like/' + this.teamId)
.then(_ => {
this.$store.dispatch('addMessage', {message: 'You liked this party'});
this.liked = e;
})
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
}
else {
this.axios.get('/party/unlike/' + this.teamId)
.then(_ => {
this.$store.dispatch('addMessage', {message: 'You stopped liking this party'});
this.liked = e;
})
.catch(error => this.$store.dispatch('addAxiosErrorMessage', error));
}
},
},
computed: {
...mapState({
team_owner: state => state.party_builder.team_owner,
}),
liked: {
get() { return this.$store.state.party_builder.liked },
set(value) { this.$store.commit('setLiked', value) }
},
isUserLogged() {
return this.$store.getters.getUserId !== null;
},
isMyParty() {
return this.team_owner === this.$store.getters.getUserId;
},
},
}
</script>

View File

@@ -0,0 +1,64 @@
<template>
<div v-if="show" class="fixed inset-0 z-40 flex justify-center items-start bg-black/80" @click.self="close()">
<div
class="w-screen h-auto mt-8 bg-primary md:rounded flex flex-col overflow-hidden"
:class="large ? 'md:w-11/12' : 'md:w-3/4 xl:w-3/5 2xl:1/2'"
style="max-height: calc(100vh - 4rem);"
>
<div class="px-4 py-2 flex flex-col-reverse sm:flex-row sm:justify-between">
<slot name="header"></slot>
<button @click.prevent="close()" class="self-end sm:self-start">
<fa-icon :icon="['fas', 'times-circle']" class="text-link-primary hover:text-link-hover text-2xl"></fa-icon>
</button>
</div>
<div class="overflow-auto break-all px-4" >
<slot></slot>
</div>
<div class="px-4 py-2 flex shrink-0">
<slot name="footer"></slot>
</div>
</div>
</div>
</template>
<script>
export default {
model: {
prop: 'show',
event: 'close'
},
props: {
show: {
type: Boolean,
required: true
},
large: {
type: Boolean,
default: false,
}
},
methods: {
close() {
this.$emit("close", ! this.show);
},
toggleOverflow() {
if (this.show === true) {
document.querySelector("HTML").classList.add("overflow-hidden");
}
else {
document.querySelector("HTML").classList.remove("overflow-hidden");
}
}
},
watch: {
show() {
this.toggleOverflow();
}
},
mounted() {
this.toggleOverflow();
}
}
</script>

View File

@@ -0,0 +1,49 @@
<template>
<label :title="longName">
{{ shortName }}
<input
class="appearance-none text-primary bg-transparent"
:class="alignRight ? 'text-right' : ''"
type="tel"
:style="'width: ' + length + 'ch;'"
v-model.number.lazy="localProp"
@keydown.arrow-up="incrementProp()"
@keydown.arrow-down="decrementProp()"
>
</label>
</template>
<script>
export default {
props: {
prop: Number,
shortName: String,
longName: String,
length: Number,
max: Number,
alignRight: Boolean,
},
methods: {
incrementProp() {
if (this.max === undefined || this.max > this.localProp) {
this.localProp++;
}
},
decrementProp() {
if (this.localProp > 0) {
this.localProp--;
}
},
},
computed: {
localProp: {
get() {
return this.prop;
},
set(value) {
this.$emit('update:prop', value);
}
}
}
}
</script>

17043
dist.js Normal file

File diff suppressed because it is too large Load Diff

BIN
favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
favicon-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
favicon-96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 B

BIN
img/card_calceternal.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
img/card_calcevent.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
img/card_calcevoker.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
img/card_calcgw.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
img/card_collection.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
img/card_dailygrind.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
img/card_friendsum.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
img/card_index.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
img/card_party.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
img/card_release.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
img/card_replicard.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
img/card_roomname.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
img/card_search.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
img/card_spark.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
img/e_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
img/e_earth.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
img/e_fire.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
img/e_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
img/e_water.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
img/e_wind.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
img/empty_chara.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
img/empty_chara_ro.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
img/empty_summon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
img/empty_summon_ro.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
img/empty_weapon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
img/empty_weapon_ro.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
img/icon_pring.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
img/img/card_calcevent.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
img/img/card_calcevoker.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
img/img/card_calcgw.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
img/img/card_collection.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
img/img/card_dailygrind.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
img/img/card_friendsum.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
img/img/card_index.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
img/img/card_party.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
img/img/card_release.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
img/img/card_replicard.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
img/img/card_roomname.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
img/img/card_search.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
img/img/card_spark.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
img/img/e_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
img/img/e_earth.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
img/img/e_fire.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
img/img/e_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
img/img/e_water.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Some files were not shown because too many files have changed in this diff Show More