summaryrefslogtreecommitdiff
path: root/resources
diff options
context:
space:
mode:
Diffstat (limited to 'resources')
-rw-r--r--resources/css/battle.css231
-rw-r--r--resources/css/menu.css70
-rw-r--r--resources/css/page.css24
-rw-r--r--resources/css/variables.css3
-rw-r--r--resources/js/classes/Item.js1
-rw-r--r--resources/js/classes/Monster.js223
-rw-r--r--resources/js/classes/State.js38
-rw-r--r--resources/js/classes/StatusEffect.js55
-rw-r--r--resources/js/classes/Technique.js77
-rw-r--r--resources/js/db.js62
-rw-r--r--resources/js/definitions.js136
-rw-r--r--resources/js/formula.js93
-rw-r--r--resources/js/game.js459
-rw-r--r--resources/js/helpers.js45
-rw-r--r--resources/js/ui.js643
15 files changed, 2160 insertions, 0 deletions
diff --git a/resources/css/battle.css b/resources/css/battle.css
new file mode 100644
index 0000000..0f56234
--- /dev/null
+++ b/resources/css/battle.css
@@ -0,0 +1,231 @@
+#battle {
+ user-select: none;
+ min-height: 300px;
+ padding: 0.5rem;
+
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+
+ background-image: url('/modules/tuxemon/mods/tuxemon/gfx/ui/combat/sea_background.png');
+ background-size: cover;
+}
+
+#battle__enemy {
+ position: relative;
+ cursor: pointer;
+ flex-grow: 1;
+}
+
+.battle__monster {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.battle__monster-info {
+ border: 2px solid #000;
+ background-color: beige;
+
+ flex-grow: 1;
+ flex-basis: 50%;
+}
+.battle__monster-info-box {
+ display: flex;
+ flex-direction: column;
+
+ padding: 0.25rem;
+}
+.battle__monster-info__gender {
+ line-height: 1em;
+}
+
+.battle__monster-info-box__status {
+ display: flex;
+ justify-content: space-between;
+ /* align-items: center; */
+}
+.battle__monster-info__status {
+ /* flex-basis: 5%; */
+ flex-basis: calc(1rem + 0.25rem);
+ /* text-align: center; */
+}
+.battle__monster-info__status img {
+ width: 1rem;
+ height: 1rem;
+}
+
+.battle__monster-info-exp {
+ display: flex;
+ align-items: center;
+
+ padding: 0.25rem;
+
+ background-color: #000;
+ color: yellow;
+}
+.exp-label {
+ margin-right: 0.5rem;
+}
+.battle__monster-visual {
+ flex-grow: 1;
+ flex-basis: 50%;
+ text-align: center;
+}
+
+.battle__monster-sprite {
+ margin-bottom: 0.25rem;
+}
+.battle__monster-img {
+ transition-property: filter;
+}
+.battle__monster-img.damaged {
+ filter: brightness(2);
+}
+.battle__monster-sprite__animation {
+ position: fixed;
+ z-index: 10;
+}
+
+.battle__monster-technique {
+ background-color: beige;
+ border: 2px solid #000;
+ padding: 0.25rem;
+
+ display: inline-flex;
+ align-items: center;
+
+ cursor: pointer;
+}
+.battle__monster-technique__name {}
+.battle__monster-technique__types {}
+.battle__monster-technique__power {}
+
+.battle__monster--player {
+ flex-direction: row-reverse;
+}
+.battle__monster--enemy .battle__monster-info-exp,
+.battle__monster--enemy .battle__monster-technique {
+ display: none;
+}
+
+.exp-bar {
+ width: 100%;
+}
+.exp-bar__bar-wrap {
+ width: 100%;
+ border: 1px solid rgba(0, 0, 0, 0.5);
+ background-color: lightgray;
+}
+.exp-bar__bar {
+ background-color: blue;
+ height: 7px;
+ transition: background-color;
+ width: 0%;
+}
+
+.hp-bar {
+ flex-grow: 1;
+}
+.hp-bar__bar-wrap {
+ width: 100%;
+ border: 1px solid rgba(0, 0, 0, 0.5);
+}
+.hp-bar__bar {
+ background-color: green;
+ height: 10px;
+ transition: background-color;
+}
+
+
+
+.moveset__list {
+ padding: 0 0.5rem;
+}
+
+.moveset__item {
+ font-size: 1.5rem;
+ border: 1px solid #000;
+ padding: 0.5em 0.75em;
+ margin: 0.5em 0;
+ line-height: 1em;
+ cursor: pointer;
+ background-color: rgba(255, 255, 255, 0.5);
+
+ /* display: flex; */
+ /* align-items: center; */
+}
+
+.moveset__item:hover {
+ background-color: rgba(0, 0, 0, 0.1);
+}
+
+.moveset__item[disabled] {
+ filter: grayscale(100%);
+ opacity: 0.5;
+}
+
+.moveset__item[selected] {
+ border-color: var(--color-success);
+ color: var(--color-success);
+ background-color: rgba(255, 255, 255, 0.75);
+}
+
+.moveset__item img {
+ height: 1em;
+ width: 1em;
+}
+
+
+
+
+#techniques {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr 1fr;
+
+ background: beige;
+}
+
+.techniques__technique {
+ user-select: none;
+ border: 1px solid #000;
+ padding: 0.25rem;
+ cursor: pointer;
+}
+
+.techniques__technique:hover,
+.techniques__technique:active {
+ background-color: rgba(0, 0, 0, 0.05);
+}
+
+.techniques__technique > div {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.techniques__technique > div + div {
+ margin-top: 0.25rem;
+}
+.techniques__technique [data-template-slot="name"] {
+ font-size: 1.5rem;
+}
+
+
+.damage {
+ position: absolute;
+ z-index: 11;
+
+ animation: float-up-and-disappear;
+}
+
+@keyframes float-up-and-disappear {
+ from {
+ opacity: 1;
+ }
+
+ to {
+ top: 0;
+ opacity: 0;
+ font-size: 0;
+ }
+}
diff --git a/resources/css/menu.css b/resources/css/menu.css
new file mode 100644
index 0000000..41353b2
--- /dev/null
+++ b/resources/css/menu.css
@@ -0,0 +1,70 @@
+.popup__overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background-color: rgba(0, 0, 0, 0.8);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.popup__overlay.popup--is-multiple {
+ background-color: rgba(0, 0, 0, 0.25);
+}
+
+.popup {
+ background-image: url('/modules/tuxemon/mods/tuxemon/gfx/ui/background/spyder_empty.png');
+ background-size: cover;
+ background-position: center;
+}
+
+
+
+
+#status {
+ color: #fff;
+ background-color: #000;
+ padding: 0.25rem;
+}
+
+
+
+#menu {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ text-align: center;
+ background-color: beige;
+}
+#menu > div {
+ cursor: pointer;
+}
+#menu > div:hover {
+ background-color: rgba(0, 0, 0, 0.05);
+}
+
+
+
+.party__monsters {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+}
+
+.party__monster {
+ cursor: pointer;
+ padding: 1rem;
+}
+.party__monster:hover {
+ background-color: rgba(0, 0, 0, 0.1);
+}
+
+.party__selection-modes {
+ display: flex;
+ justify-content: space-between;
+ padding: 1rem;
+}
+
+.party__selection-modes button[selected] {
+ border-color: var(--color-success);
+}
diff --git a/resources/css/page.css b/resources/css/page.css
new file mode 100644
index 0000000..96cefdf
--- /dev/null
+++ b/resources/css/page.css
@@ -0,0 +1,24 @@
+* {
+ box-sizing: border-box;
+}
+
+html {
+ font-size: 14px;
+}
+
+body {
+ margin: 0;
+}
+
+img {
+ display: inline-block;
+ max-width: 100%;
+}
+
+.wrap {
+ margin: 0 auto;
+ min-width: 360px;
+ max-width: 750px;
+ width: 100vw;
+ height: 100vh;
+}
diff --git a/resources/css/variables.css b/resources/css/variables.css
new file mode 100644
index 0000000..d5f11be
--- /dev/null
+++ b/resources/css/variables.css
@@ -0,0 +1,3 @@
+:root {
+ --color-success: darkgreen;
+}
diff --git a/resources/js/classes/Item.js b/resources/js/classes/Item.js
new file mode 100644
index 0000000..e274a20
--- /dev/null
+++ b/resources/js/classes/Item.js
@@ -0,0 +1 @@
+class Item {}
diff --git a/resources/js/classes/Monster.js b/resources/js/classes/Monster.js
new file mode 100644
index 0000000..9023f32
--- /dev/null
+++ b/resources/js/classes/Monster.js
@@ -0,0 +1,223 @@
+class Monster {
+ #level = 2;
+ #hp = 0;
+
+ exp = 1;
+
+ tasteWarm = TasteWarm.tasteless;
+ tasteCold = TasteCold.tasteless;
+
+ gender = '';
+
+ heldItem = null;
+
+ /**
+ * @type {StatusEffect}
+ */
+ statusEffect = null;
+
+ statModifiers = {
+ hp: 0,
+ melee: 0,
+ armour: 0,
+ ranged: 0,
+ dodge: 0,
+ speed: 0,
+ };
+
+ experienceModifier = 1;
+ moneyModifier = 1;
+
+ /**
+ * @type {Technique[]}
+ */
+ activeTechniques = [];
+
+ constructor (slug) {
+ this.slug = slug;
+
+ const tasteWarm = Object.keys(TasteWarm).slice(1);
+ this.tasteWarm = tasteWarm[Math.floor(Math.random() * tasteWarm.length)];
+ const tasteCold = Object.keys(TasteCold).slice(1);
+ this.tasteCold = tasteCold[Math.floor(Math.random() * tasteCold.length)];
+
+ this.hp = this.stats.hp;
+
+ const possibleGenders = DB.monsters[this.slug].possible_genders;
+ this.gender = possibleGenders[Math.floor(Math.random() * possibleGenders.length)];
+
+ }
+
+ async initialize () {
+ for (const move of this.getLearnableTechniques()) {
+ this.activeTechniques.push(await fetchTechnique(move.technique));
+ }
+ }
+
+ get shape () {
+ for (const shapeData of DB.shapes) {
+ if (shapeData.slug === DB.monsters[this.slug].shape) {
+ return shapeData;
+ }
+ }
+ }
+
+ get types () {
+ return DB.monsters[this.slug].types;
+ }
+
+ get moveset () {
+ return DB.monsters[this.slug].moveset;
+ }
+
+ get evolutions () {
+ return DB.monsters[this.slug].evolutions;
+ }
+
+ get level () {
+ return this.#level;
+ }
+
+ set level (level) {
+ const statsPreLevelUp = this.stats;
+ const hpPreLevelUp = this.hp;
+
+ this.#level = level;
+
+ const statsPostLevelUp = this.stats;
+
+ this.hp = statsPostLevelUp.hp - (statsPreLevelUp.hp - hpPreLevelUp);
+
+ if (this.exp < this.getExperienceRequired(-1)) {
+ this.exp = this.getExperienceRequired(-1);
+ }
+ }
+
+ get hp () {
+ return this.#hp;
+ }
+
+ set hp (hp) {
+ this.#hp = Math.max(0, Math.min(hp, this.stats.hp));
+ }
+
+ get name () {
+ return slugToName(this.slug);
+ }
+
+ getLearnableTechniques () {
+ return this.moveset.filter((move) => this.level >= move.level_learned);
+ }
+
+ canLevelUp () {
+ return this.exp >= this.getExperienceRequired();
+ }
+
+ levelUp () {
+ while (this.canLevelUp()) {
+ this.level++;
+ }
+ }
+
+ getExperienceRequired (levelOffset = 0) {
+ return Math.max(
+ Math.pow(this.level + levelOffset, 3),
+ 1
+ );
+ }
+
+ getPossibleEvolutions () {
+ // return this.evolutions.filter((evolution) => this.level >= evolution.at_level && (!evolution.item || this.heldItem === evolution.item));
+ return this.evolutions.filter((evolution) => evolution.path === 'standard' && this.level >= evolution.at_level);
+ }
+
+ canEvolve () {
+ return this.getPossibleEvolutions().length > 0;
+ }
+
+ evolve () {
+ const evolution = this.getPossibleEvolutions()[0];
+
+ const statsPreEvolve = this.stats;
+ const hpPreEvolve = this.hp;
+
+ this.slug = evolution.monster_slug;
+
+ const statsPostEvolve = this.stats;
+
+ this.hp = statsPostEvolve.hp - (statsPreEvolve.hp - hpPreEvolve);
+ }
+
+ getTasteStatModifier (statType, baseStat) {
+ let positive = 0;
+ let negative = 0;
+
+ let isPositive = false;
+ let isNegative = false;
+ if (statType === StatType.melee) {
+ isPositive = this.tasteWarm === TasteWarm.salty;
+ isNegative = this.tasteCold === TasteCold.sweet;
+ }
+ else if (statType === StatType.armour) {
+ isPositive = this.tasteWarm === TasteWarm.hearty;
+ isNegative = this.tasteCold === TasteCold.soft;
+ }
+ else if (statType === StatType.ranged) {
+ isPositive = this.tasteWarm === TasteWarm.zesty;
+ isNegative = this.tasteCold === TasteCold.flakey;
+ }
+ else if (statType === StatType.dodge) {
+ isPositive = this.tasteWarm === TasteWarm.refined;
+ isNegative = this.tasteCold === TasteCold.dry;
+ }
+ else if (statType === StatType.speed) {
+ isPositive = this.tasteWarm === TasteWarm.peppy;
+ isNegative = this.tasteCold === TasteCold.mild;
+ }
+
+ if (isPositive) {
+ positive = baseStat * 10 / 100;
+ }
+ if (isNegative) {
+ negative = baseStat * 10 / 100;
+ }
+
+ return Math.floor(positive) - Math.floor(negative);
+ }
+
+ get stats () {
+ const multiplier = this.level + 7;
+ let hp = (this.shape.hp * multiplier) + this.statModifiers.hp;
+ let melee = (this.shape.melee * multiplier) + this.statModifiers.melee;
+ let armour = (this.shape.armour * multiplier) + this.statModifiers.armour;
+ let ranged = (this.shape.ranged * multiplier) + this.statModifiers.ranged;
+ let dodge = (this.shape.dodge * multiplier) + this.statModifiers.dodge;
+ let speed = (this.shape.speed * multiplier) + this.statModifiers.speed;
+
+ // Tastes
+ melee += this.getTasteStatModifier(StatType.melee, melee);
+ armour += this.getTasteStatModifier(StatType.armour, armour);
+ ranged += this.getTasteStatModifier(StatType.ranged, ranged);
+ dodge += this.getTasteStatModifier(StatType.dodge, dodge);
+ speed += this.getTasteStatModifier(StatType.speed, speed);
+
+ return {
+ hp: hp,
+ [StatType.melee]: melee,
+ [StatType.armour]: armour,
+ [StatType.ranged]: ranged,
+ [StatType.dodge]: dodge,
+ [StatType.speed]: speed,
+ };
+ }
+
+ setStatModifier (statType, newAbsoluteValue) {
+ this.statModifiers[statType] = newAbsoluteValue - this.stats[statType];
+ }
+
+ resetStatModifiers () {
+ for (const m in this.statModifiers) {
+ this.statModifiers[m] = 0;
+ }
+ }
+};
diff --git a/resources/js/classes/State.js b/resources/js/classes/State.js
new file mode 100644
index 0000000..2384a85
--- /dev/null
+++ b/resources/js/classes/State.js
@@ -0,0 +1,38 @@
+class State {
+ /**
+ * @type {number}
+ */
+ money = 0;
+
+ /**
+ * @type {Monster[]}
+ */
+ monsters = [];
+
+ /**
+ * @type {Item[]}
+ */
+ inventory = [];
+
+ /**
+ * @type {Monster[]}
+ */
+ partyMonsters = [];
+
+ /**
+ * @type {Monster}
+ */
+ activeMonster = null;
+
+ /**
+ * @type {Technique}
+ */
+ activeTechnique = null;
+
+ enemy = {
+ /**
+ * @type {Monster}
+ */
+ monster: null,
+ };
+};
diff --git a/resources/js/classes/StatusEffect.js b/resources/js/classes/StatusEffect.js
new file mode 100644
index 0000000..ac6ae54
--- /dev/null
+++ b/resources/js/classes/StatusEffect.js
@@ -0,0 +1,55 @@
+class StatusEffect {
+ turnsLeft = 0;
+ onRemove = null;
+
+ /**
+ * @type {Monster}
+ */
+ issuer = null;
+
+ constructor (slug) {
+ this.slug = slug;
+
+ if (['recover', 'lifeleech'].includes(this.slug)) {
+ this.turnsLeft = 1;
+ }
+ else if (['charging'].includes(this.slug)) {
+ this.turnsLeft = 2;
+ }
+ else if (this.category === 'positive') {
+ this.turnsLeft = Math.ceil(Math.random() * 6) + 4;
+ }
+ else if (this.category === 'negative') {
+ this.turnsLeft = Math.ceil(Math.random() * 3) + 2;
+ }
+ else {
+ this.turnsLeft = Math.ceil(Math.random() * 3) + 2;
+ }
+ }
+
+ /**
+ * @returns {string[]}
+ */
+ get effects () {
+ return DB.statusEffects[this.slug].effects;
+ }
+
+ get category () {
+ return DB.statusEffects[this.slug].category;
+ }
+
+ get name () {
+ return slugToName(this.slug);
+ }
+
+ get stats () {
+ const stats = {};
+
+ const statsChangeKeys = Object.keys(DB.statusEffects[this.slug]).filter((key) => key.startsWith('stat'));
+ for (const statChangeKey of statsChangeKeys) {
+ stats[statChangeKey.replace('stat', '')] = DB.statusEffects[this.slug][statChangeKey];
+ }
+
+ return stats;
+ }
+}
diff --git a/resources/js/classes/Technique.js b/resources/js/classes/Technique.js
new file mode 100644
index 0000000..a24e094
--- /dev/null
+++ b/resources/js/classes/Technique.js
@@ -0,0 +1,77 @@
+class Technique {
+ #accuracy = 0;
+ #potency = 0;
+ #power = 0;
+
+ constructor (slug) {
+ this.slug = slug;
+
+ this.resetStats();
+ }
+
+ get name () {
+ return slugToName(this.slug);
+ }
+
+ get types () {
+ return DB.techniques[this.slug].types;
+ }
+
+ get range () {
+ return DB.techniques[this.slug].range;
+ }
+
+ get animation () {
+ return DB.techniques[this.slug].animation;
+ }
+
+ get sfx () {
+ return DB.techniques[this.slug].sfx;
+ }
+
+ /**
+ * @returns {string[]}
+ */
+ get effects () {
+ return DB.techniques[this.slug].effects;
+ }
+
+ get accuracy () {
+ return this.#accuracy;
+ }
+ set accuracy (accuracy) {
+ this.#accuracy = accuracy;
+ }
+
+ get potency () {
+ return this.#potency;
+ }
+ set potency (potency) {
+ this.#potency = potency;
+ }
+
+ get power () {
+ return this.#power;
+ }
+ set power (power) {
+ this.#power = power;
+ }
+
+ get stats () {
+ const accuracy = DB.techniques[this.slug].accuracy;
+ const potency = DB.techniques[this.slug].potency;
+ const power = DB.techniques[this.slug].power;
+
+ return {
+ accuracy,
+ potency,
+ power,
+ };
+ }
+
+ resetStats () {
+ this.accuracy = this.stats.accuracy;
+ this.potency = this.stats.potency;
+ this.power = this.stats.power;
+ }
+}
diff --git a/resources/js/db.js b/resources/js/db.js
new file mode 100644
index 0000000..96b971a
--- /dev/null
+++ b/resources/js/db.js
@@ -0,0 +1,62 @@
+const DB = {
+ allMonsters: [],
+ allAnimations: {},
+ monsters: {},
+ shapes: {},
+ elements: {},
+ techniques: {},
+ statusEffects: {},
+};
+
+async function initializeDB () {
+ DB.allMonsters = await fetch('/db/all-monsters.json').then((response) => response.json());
+ DB.allAnimations = await fetch('/db/animations.json').then((response) => response.json());
+
+ DB.shapes = await fetch('/modules/tuxemon/mods/tuxemon/db/shape/shapes.json').then((response) => response.json());
+
+ for (const element of Object.keys(ElementType)) {
+ DB.elements[element] = await fetch(`/modules/tuxemon/mods/tuxemon/db/element/${element}.json`).then((response) => response.json());
+ }
+}
+
+/**
+ * @param {MonsterSlug} slug
+ *
+ * @returns {Promise<Monster>}
+ */
+async function fetchMonster (slug) {
+ if (! DB.monsters[slug]) {
+ DB.monsters[slug] = await fetch(`/modules/tuxemon/mods/tuxemon/db/monster/${slug}.json`).then((response) => response.json());
+ }
+
+ const monster = new Monster(slug);
+ await monster.initialize();
+
+ return monster;
+}
+
+/**
+ * @param {TechniqueSlug} slug
+ *
+ * @returns {Promise<Technique>}
+ */
+async function fetchTechnique (slug) {
+ if (! DB.techniques[slug]) {
+ DB.techniques[slug] = await fetch(`/modules/tuxemon/mods/tuxemon/db/technique/${slug}.json`).then((response) => response.json());
+ }
+
+ return new Technique(slug);
+}
+
+/**
+ * @param {string} slug
+ *
+ * @returns {Promise<StatusEffect>}
+ */
+async function fetchStatusEffect (slug) {
+ if (! DB.statusEffects[slug]) {
+ DB.statusEffects[slug] = await fetch(`/modules/tuxemon/mods/tuxemon/db/technique/status_${slug}.json`).then((response) => response.json());
+ }
+
+ return new StatusEffect(slug);
+}
diff --git a/resources/js/definitions.js b/resources/js/definitions.js
new file mode 100644
index 0000000..b8c5071
--- /dev/null
+++ b/resources/js/definitions.js
@@ -0,0 +1,136 @@
+/**
+ * @typedef {string} MonsterSlug
+ * @typedef {string} TechniqueSlug
+ */
+
+//
+
+const ElementType = {
+ aether: 'aether',
+ wood: 'wood',
+ fire: 'fire',
+ earth: 'earth',
+ metal: 'metal',
+ water: 'water',
+
+ /* lightning: 'lightning',
+ frost: 'frost',
+ venom: 'venom',
+ //vermin: 'vermin',
+ cosmic: 'cosmic',
+ battle: 'battle',
+ psionic: 'psionic',
+ darkness: 'darkness',
+ heaven: 'heaven',
+
+ combineTypes(typeA, typeB) {
+ if (typeA === ElementType.earth & typeB === ElementType.fire) {
+ return ElementType.lightning;
+ }
+ if (typeA === ElementType.earth & typeB === ElementType.water) {
+ return ElementType.frost;
+ }
+ if (typeA === ElementType.earth & typeB === ElementType.wood) {
+ return ElementType.venom;
+ }
+ // if (typeA === ElementType.earth & typeB === ElementType.metal) {
+ // return ElementType.vermin;
+ // }
+ if (typeA === ElementType.fire && typeB === ElementType.water) {
+ return ElementType.cosmic;
+ }
+ } */
+};
+
+const ElementTypeColor = {
+ [ElementType.aether]: 'rgba(255, 255, 255, 1)',
+ [ElementType.wood]: '#3ca6a6',
+ [ElementType.fire]: '#ca3c3c',
+ [ElementType.earth]: '#eac93c',
+ [ElementType.metal]: '#e4e4e4',
+ [ElementType.water]: '#3c3c3c',
+};
+
+
+/**
+ * @readonly
+ * @enum {string}
+ */
+const TasteWarm = {
+ tasteless: 'tasteless',
+ peppy: 'peppy',
+ salty: 'salty',
+ hearty: 'hearty',
+ zesty: 'zesty',
+ refined: 'refined',
+};
+
+const TasteCold = {
+ tasteless: 'tasteless',
+ mild: 'mild',
+ sweet: 'sweet',
+ soft: 'soft',
+ flakey: 'flakey',
+ dry: 'dry',
+};
+
+
+const TechniqueRange = {
+ melee: 'melee',
+ touch: 'touch',
+ ranged: 'ranged',
+ reach: 'reach',
+ reliable: 'reliable',
+};
+
+
+const StatType = {
+ // hp: 'hp',
+ melee: 'melee',
+ armour: 'armour',
+ ranged: 'ranged',
+ dodge: 'dodge',
+ speed: 'speed',
+};
+
+
+const StatusEffectType = {
+ blinded: 'blinded',
+ burn: 'burn',
+ chargedup: 'chargedup',
+ charging: 'charging',
+ confused: 'confused',
+ diehard: 'diehard',
+ dozing: 'dozing',
+ elementalshield: 'elementalshield',
+ eliminated: 'eliminated',
+ enraged: 'enraged',
+ exhausted: 'exhausted',
+ faint: 'faint',
+ feedback: 'feedback',
+ festering: 'festering',
+ flinching: 'flinching',
+ focused: 'focused',
+ grabbed: 'grabbed',
+ hardshell: 'hardshell',
+ harpooned: 'harpooned',
+ lifeleech: 'lifeleech',
+ lockdown: 'lockdown',
+ noddingoff: 'noddingoff',
+ poison: 'poison',
+ prickly: 'prickly',
+ recover: 'recover',
+ slow: 'slow',
+ sniping: 'sniping',
+ softened: 'softened',
+ stuck: 'stuck',
+ tired: 'tired',
+ wasting: 'wasting',
+ wild: 'wild',
+};
+
+const StatusEffectTypeColor = {
+ [StatusEffectType.burn]: 'red',
+ [StatusEffectType.poison]: 'purple',
+ [StatusEffectType.recover]: 'white',
+};
diff --git a/resources/js/formula.js b/resources/js/formula.js
new file mode 100644
index 0000000..5a3433f
--- /dev/null
+++ b/resources/js/formula.js
@@ -0,0 +1,93 @@
+/**
+ * @param {Technique} technique
+ * @param {Monster} user
+ * @param {Monster} target
+ *
+ * @returns {number}
+ */
+function simpleDamageCalculation (technique, user, target) {
+ if (technique.power === 0) {
+ return 0;
+ }
+
+ let userBaseStrength = user.level + 7;
+ let userStrength = 1;
+ let targetResist = 1;
+
+ if (technique.range === TechniqueRange.melee) {
+ userStrength = userBaseStrength * user.stats.melee;
+ targetResist = target.stats.armour;
+ }
+ else if (technique.range === TechniqueRange.touch) {
+ userStrength = userBaseStrength * user.stats.melee;
+ targetResist = target.stats.dodge;
+ }
+ else if (technique.range === TechniqueRange.ranged) {
+ userStrength = userBaseStrength * user.stats.ranged;
+ targetResist = target.stats.dodge;
+ }
+ else if (technique.range === TechniqueRange.reach) {
+ userStrength = userBaseStrength * user.stats.ranged;
+ targetResist = target.stats.armour;
+ }
+ else if (technique.range === TechniqueRange.reliable) {
+ userStrength = userBaseStrength;
+ targetResist = 1;
+ }
+
+ const multiplier = simpleDamageMultiplier(technique.types, target.types);
+ const moveStrength = technique.power * multiplier;
+ const damage = Math.floor((userStrength * moveStrength) / targetResist);
+
+ return Math.max(damage, 1);
+}
+
+/**
+ * @param {(ElementType[]|string[])} techniqueTypes
+ * @param {(ElementType[]|string[])} targetTypes
+ *
+ * @returns {number}
+ */
+function simpleDamageMultiplier (techniqueTypes, targetTypes) {
+ let multiplier = 1;
+
+ for (const techniqueType of techniqueTypes) {
+ if (techniqueType === ElementType.aether) {
+ continue;
+ }
+
+ for (const targetType of targetTypes) {
+ if (targetType === ElementType.aether) {
+ continue;
+ }
+
+ multiplier *= DB.elements[techniqueType].types.find((type) => type.against === targetType).multiplier;
+ }
+ }
+
+ return Math.max(0.25, Math.min(multiplier, 4));
+}
+
+/**
+ * @param {Monster} opposingMonster
+ * @param {Monster[]} participants
+ *
+ * @returns {number[]}
+ */
+function calculateAwardedExperience (opposingMonster, participants) {
+ const awardedExperienceDistribution = [];
+
+ for (const participantIndex in participants) {
+ const participant = participants[participantIndex];
+ const awardedExperience = Math.max(
+ Math.floor(
+ opposingMonster.getExperienceRequired(-1) / opposingMonster.level * opposingMonster.experienceModifier / participant.level
+ ),
+ 1
+ );
+
+ awardedExperienceDistribution[participantIndex] = awardedExperience;
+ }
+
+ return awardedExperienceDistribution;
+}
diff --git a/resources/js/game.js b/resources/js/game.js
new file mode 100644
index 0000000..1f1f92b
--- /dev/null
+++ b/resources/js/game.js
@@ -0,0 +1,459 @@
+const state = new State();
+
+
+const Game = {
+ phases: {
+ preAction: [],
+ action: [],
+ postAction: [],
+ },
+
+ didTechniqueHit: false,
+
+
+ async progressTurn () {
+ await Game.applyStatusEffect(state.enemy.monster);
+ await Game.applyStatusEffect(state.activeMonster);
+
+ for (const event of Game.phases.preAction) {
+ event();
+ }
+ Game.phases.preAction = [];
+ for (const event of Game.phases.action) {
+ event();
+ }
+ Game.phases.action = [];
+ for (const event of Game.phases.postAction) {
+ event();
+ }
+ Game.phases.postAction = [];
+
+ UI.drawEnemyMonster();
+ UI.drawActiveMonster();
+ UI.drawActiveTechniques();
+
+ UI.elements.money.textContent = state.money;
+ },
+
+ /**
+ * @param {Technique} technique
+ * @param {Monster} user
+ * @param {Monster} target
+ */
+ async useTechnique (technique, user, target) {
+ if (!Game.didTechniqueHit) {
+ UI.drawDamageMiss(UI.createDamageMiss());
+ return;
+ }
+
+ if (state.activeMonster.hp === state.activeMonster.stats.hp) {
+ state.activeMonster.hp = 1;
+ }
+
+ for (const techniqueEffect of technique.effects) {
+
+ // damage
+ if (['damage', 'splash', 'area'].includes(techniqueEffect)) {
+ Game.phases.action.push(() => {
+ const damage = simpleDamageCalculation(state.activeTechnique, state.activeMonster, state.enemy.monster);
+
+ state.enemy.monster.hp -= damage;
+ const damageNode = UI.createDamage(damage);
+ UI.applyMultiplierToDamage(damageNode, simpleDamageMultiplier(state.activeTechnique.types, state.enemy.monster.types));
+ UI.applyTechniqueToDamage(damageNode, state.activeTechnique);
+ UI.drawDamage(damageNode);
+ });
+ }
+
+ else if (techniqueEffect === 'money') {
+ state.money += Math.floor(Math.random() * target.level);
+ }
+
+ else if (techniqueEffect === 'enhance') {
+ UI.drawDamage(UI.createDamage('!!ENHANCE!!'));
+ }
+
+ // status effect
+ else if (techniqueEffect.includes('status_')) {
+ const statusEffect_recipient = techniqueEffect.split(',')[1];
+ const statusEffect_application = techniqueEffect.split(' ')[0];
+ const statusEffect_type = techniqueEffect.split(',')[0].split(' ')[1].split('_')[0];
+ const statusEffect_effect = techniqueEffect.split(',')[0].split(' ')[1].split('_')[1];
+
+ const statusEffect = await fetchStatusEffect(statusEffect_effect);
+ statusEffect.issuer = user;
+
+ let recipient;
+ if (statusEffect_recipient === 'user') {
+ recipient = user;
+ } else {
+ recipient = target;
+ }
+
+ Game.phases.postAction.push(() => {
+ // add status effect
+ const potency = Math.random();
+ const success = technique.potency >= potency;
+
+ if (success) {
+ // TODO: check replace
+ if (recipient.statusEffect) return;
+
+ recipient.statusEffect = statusEffect;
+ }
+ });
+
+ UI.drawTechniqueAnimation();
+ }
+ }
+ },
+
+ /**
+ * @param {Monster} monster
+ */
+ async applyStatusEffect (monster) {
+ if (!monster.statusEffect) {
+ return;
+ }
+
+ if (monster.statusEffect.turnsLeft === 0) {
+ Game.phases.preAction.push(() => {
+ monster.statusEffect.onRemove && monster.statusEffect.onRemove();
+
+ // if still 0 turns left after remove action
+ if (monster.statusEffect.turnsLeft === 0) {
+ monster.statusEffect = null;
+ } else {
+ Game.applyStatusEffect(monster);
+ }
+ });
+
+ return;
+ }
+
+ // poison / burn
+ if (monster.statusEffect.slug === 'poison' || monster.statusEffect.slug === 'burn') {
+ const statusEffectDamage = Math.floor(monster.stats.hp / 8);
+
+ Game.phases.postAction.push(() => {
+ monster.hp -= statusEffectDamage;
+
+ const damageNode = UI.createDamage(statusEffectDamage);
+ UI.applyStatusEffectToDamage(damageNode, monster.statusEffect);
+ UI.drawDamage(damageNode);
+ });
+ }
+
+ // lifeleech
+ else if (monster.statusEffect.slug === 'lifeleech') {
+ const statusEffectLeech = Math.floor(monster.stats.hp / 16);
+
+ Game.phases.postAction.push(() => {
+ monster.hp -= statusEffectLeech;
+ monster.statusEffect.issuer.hp += statusEffectLeech;
+
+ const damageNode = UI.createDamage(statusEffectLeech);
+ UI.applyStatusEffectToDamage(damageNode, monster.statusEffect);
+ UI.drawDamage(damageNode);
+ });
+ }
+
+ // recover
+ else if (monster.statusEffect.slug === 'recover') {
+ const statusEffectHeal = Math.floor(monster.stats.hp / 16);
+
+ Game.phases.postAction.push(() => {
+ monster.hp += statusEffectHeal;
+
+ const damageNode = UI.createDamage(statusEffectHeal);
+ UI.applyStatusEffectToDamage(damageNode, monster.statusEffect);
+ UI.drawDamage(damageNode);
+ });
+ }
+
+ // stuck
+ else if (monster.statusEffect.slug === 'stuck') {
+ for (const technique of monster.activeTechniques) {
+ if ([TechniqueRange.melee, TechniqueRange.touch].includes(technique.range)) {
+ Game.phases.preAction.push(() => {
+ technique.potency = technique.stats.potency * 0.5;
+ technique.power = technique.stats.power * 0.5;
+ });
+
+ monster.statusEffect.onRemove = () => {
+ technique.resetStats();
+ };
+ }
+ }
+ }
+
+ // grabbed
+ else if (monster.statusEffect.slug === 'grabbed') {
+ for (const technique of monster.activeTechniques) {
+ if ([TechniqueRange.ranged, TechniqueRange.reach].includes(technique.range)) {
+ Game.phases.preAction.push(() => {
+ technique.potency = technique.stats.potency * 0.5;
+ technique.power = technique.stats.power * 0.5;
+ });
+
+ monster.statusEffect.onRemove = () => {
+ technique.resetStats();
+ };
+ }
+ }
+ }
+
+ // charging
+ else if (monster.statusEffect.slug === 'charging') {
+ const nextStatusEffect = await fetchStatusEffect('chargedup');
+
+ monster.statusEffect.onRemove = () => {
+ monster.statusEffect = nextStatusEffect;
+ };
+ }
+
+ // statchange
+ else if (monster.statusEffect.effects.includes('statchange')) {
+ monster.resetStatModifiers();
+
+ for (const statType in monster.statusEffect.stats) {
+ const statChange = monster.statusEffect.stats[statType];
+ const modifiedValue = Math.floor(eval(`${monster.stats[statType]} ${statChange.operation} ${statChange.value}`));
+
+ Game.phases.preAction.push(() => {
+ monster.setStatModifier(statType, modifiedValue);
+ });
+ }
+
+ monster.statusEffect.onRemove = () => {
+ monster.resetStatModifiers();
+ };
+ }
+
+ Game.phases.postAction.push(() => {
+ monster.statusEffect.turnsLeft--;
+ });
+ },
+
+ async spawnEnemyMonster () {
+ state.enemy.monster = await fetchMonster(DB.allMonsters[Math.floor(Math.random() * DB.allMonsters.length)]);
+ state.enemy.monster.level = Math.ceil(Math.random() * state.activeMonster.level);
+
+ // state.enemy.monster.experienceModifier = state.enemy.monster.level;
+ state.enemy.monster.moneyModifier = state.enemy.monster.level;
+
+ UI.drawEnemyMonster();
+ },
+
+ /**
+ * @param {MouseEvent} event
+ */
+ async battleClick (event) {
+ UI.battleClickEvent = event;
+
+ // hit?
+ const accuracy = Math.random();
+ Game.didTechniqueHit = state.activeTechnique.accuracy >= accuracy;
+
+ await Game.useTechnique(state.activeTechnique, state.activeMonster, state.enemy.monster);
+
+ Game.phases.postAction.push(async () => {
+ // enemy defeated
+ if (state.enemy.monster.hp <= 0) {
+ // money
+ state.money += state.enemy.monster.level * state.enemy.monster.moneyModifier;
+
+ // exp
+ state.activeMonster.exp += calculateAwardedExperience(state.enemy.monster, [state.activeMonster])[0];
+
+ if (state.activeMonster.canLevelUp()) {
+ state.activeMonster.levelUp();
+ }
+ if (state.activeMonster.canEvolve()) {
+ await fetchMonster(state.activeMonster.evolutions[0].monster_slug);
+ state.activeMonster.evolve();
+ }
+
+ await Game.spawnEnemyMonster();
+ }
+ });
+
+ Game.progressTurn();
+ },
+
+ /**
+ * @param {MouseEvent} event
+ */
+ techniqueClick (event) {
+ if (event.target === UI.elements.techniques) {
+ return;
+ }
+
+ let target = event.target;
+ while (!target.classList.contains('techniques__technique')) {
+ target = target.parentNode;
+ }
+
+ const idx = [...UI.elements.techniques.children].indexOf(target);
+ state.activeTechnique = state.activeMonster.activeTechniques[idx];
+
+ // trigger battle click
+ const rect = UI.elements.battleEnemy.getBoundingClientRect();
+ const xMin = rect.left + 64;
+ const xMax = rect.right - 64;
+ const yMin = rect.top + 32;
+ const yMax = rect.bottom - 32;
+ UI.elements.battleEnemy.dispatchEvent(new MouseEvent('click', {
+ clientX: Math.random() * (xMax - xMin) + xMin,
+ clientY: Math.random() * (yMax - yMin) + yMin,
+ }));
+ },
+
+ catchMonster () {
+ const caughtMonster = new Monster(state.enemy.monster.slug);
+ caughtMonster.initialize();
+ caughtMonster.level = state.enemy.monster.level;
+
+ state.partyMonsters.push(caughtMonster);
+
+ Game.spawnEnemyMonster();
+ },
+
+ /**
+ * @returns {string}
+ */
+ save () {
+ const saveMonster = (monsterData, monsterState) => {
+ monsterData.level = monsterState.level;
+ monsterData.hp = monsterState.hp;
+
+ return monsterData;
+ };
+
+ const saveData = JSON.parse(JSON.stringify(state));
+
+ for (const idx in saveData.monsters) {
+ saveData.monsters[idx] = saveMonster(saveData.monsters[idx], state.monsters[idx]);
+ }
+
+ for (const idx in saveData.partyMonsters) {
+ saveData.partyMonsters[idx] = saveMonster(saveData.partyMonsters[idx], state.partyMonsters[idx]);
+ }
+
+ saveData.activeMonsterIdx = state.partyMonsters.indexOf(state.activeMonster);
+
+ saveData.enemy.monster = saveMonster(saveData.enemy.monster, state.enemy.monster);
+
+ return btoa(JSON.stringify(saveData));
+ },
+
+ /**
+ * @param {string} saveData
+ */
+ async load (saveData) {
+ /**
+ * @param {Monster} monsterData
+ */
+ const loadMonster = async (monsterData) => {
+ const monster = await fetchMonster(monsterData.slug);
+
+ monster.level = monsterData.level;
+ monster.hp = monsterData.hp;
+ monster.exp = monsterData.exp;
+ monster.tasteWarm = monsterData.tasteWarm;
+ monster.tasteCold = monsterData.tasteCold;
+ monster.gender = monsterData.gender;
+ monster.heldItem = await loadItem(monsterData.heldItem);
+ monster.statusEffect = await loadStatusEffect(monsterData.statusEffect);
+ monster.statModifiers = monsterData.statModifiers;
+ monster.experienceModifier = monsterData.experienceModifier;
+ monster.moneyModifier = monsterData.moneyModifier;
+ monster.activeTechniques = await Promise.all(monsterData.activeTechniques.map(async (technique) => {
+ return loadTechnique(technique);
+ }));
+
+ return monster;
+ };
+
+ /**
+ * @param {Item} itemData
+ */
+ const loadItem = async (itemData) => {};
+
+ /**
+ * @param {StatusEffect} statusEffectData
+ */
+ const loadStatusEffect = async (statusEffectData) => {
+ if (!statusEffectData) {
+ return null;
+ }
+
+ const statusEffect = await fetchStatusEffect(statusEffectData.slug);
+
+ statusEffect.turnsLeft = statusEffectData.turnsLeft;
+
+ return statusEffect;
+ };
+
+ /**
+ * @param {Technique} techniqueData
+ */
+ const loadTechnique = async (techniqueData) => {
+ const technique = await fetchTechnique(techniqueData.slug);
+
+ return technique;
+ };
+
+ /**
+ * @type {State}
+ */
+ const loadedState = JSON.parse(atob(saveData));
+
+ state.money = loadedState.money;
+ state.monsters = await Promise.all(loadedState.monsters.map(async (monsterData) => await loadMonster(monsterData)));
+ state.inventory = await Promise.all(loadedState.inventory.map(async (itemData) => await loadItem(itemData)));
+ state.partyMonsters = await Promise.all(loadedState.partyMonsters.map(async (monsterData) => await loadMonster(monsterData)));
+ state.activeMonster = state.partyMonsters[loadedState.activeMonsterIdx];
+ state.activeTechnique = await loadTechnique(loadedState.activeTechnique);
+ state.enemy.monster = await loadMonster(loadedState.enemy.monster);
+
+ UI.drawEnemyMonster();
+ UI.drawActiveMonster();
+ UI.drawActiveTechniques();
+ },
+};
+
+// Game click bindings
+UI.elements.battleEnemy.addEventListener('click', Game.battleClick);
+UI.elements.techniques.addEventListener('click', Game.techniqueClick);
+UI.elements.menuCatch.addEventListener('click', Game.catchMonster);
+
+
+(async function () {
+ await initializeDB();
+
+ const possibleStarterMonsters = ['budaye', 'dollfin', 'grintot', 'ignibus', 'memnomnom'];
+
+ // state.enemy.monster = await fetchMonster('grintot');
+ state.enemy.monster = await fetchMonster(possibleStarterMonsters[Math.round(Math.random() * (possibleStarterMonsters.length - 1))]);
+
+ state.partyMonsters = [
+ await fetchMonster(possibleStarterMonsters[Math.round(Math.random() * (possibleStarterMonsters.length - 1))]),
+ await fetchMonster('corvix'),
+ await fetchMonster('lunight'),
+ await fetchMonster('prophetoise'),
+ await fetchMonster('drashimi'),
+ await fetchMonster('glombroc'),
+ await fetchMonster('uneye'),
+ await fetchMonster('nostray'),
+ await fetchMonster('dragarbor'),
+ await fetchMonster('mk01_omega'),
+ ];
+
+ state.activeMonster = state.partyMonsters[0];
+ state.activeTechnique = state.activeMonster.activeTechniques[0];
+
+ UI.drawEnemyMonster();
+ UI.drawActiveMonster();
+ UI.drawActiveTechniques();
+})();
diff --git a/resources/js/helpers.js b/resources/js/helpers.js
new file mode 100644
index 0000000..019f822
--- /dev/null
+++ b/resources/js/helpers.js
@@ -0,0 +1,45 @@
+/**
+ * @param {string} slug
+ *
+ * @returns {(string|MonsterSlug|TechniqueSlug)}
+ */
+function slugToName (slug) {
+ return slug.split('_').map((item) => item.charAt(0).toUpperCase() + item.slice(1)).join(' ');
+}
+
+/**
+ * @param {string} color
+ *
+ * @returns {string}
+ */
+function standardizeColor (color) {
+ var ctx = document.createElement('canvas').getContext('2d');
+ ctx.fillStyle = color;
+
+ return ctx.fillStyle;
+}
+
+/**
+ * @param {...string} colors
+ *
+ * @returns {string} rgb
+ */
+function mixColors(...colors) {
+ let r = 0;
+ let g = 0;
+ let b = 0;
+
+ for (const color of colors) {
+ const [cr, cg, cb] = color.match(/\w\w/g).map((c) => parseInt(c, 16));
+
+ r += cr;
+ g += cg;
+ b += cb;
+ }
+
+ r = r / colors.length;
+ g = g / colors.length;
+ b = b / colors.length;
+
+ return `rgb(${r}, ${g}, ${b})`;
+}
diff --git a/resources/js/ui.js b/resources/js/ui.js
new file mode 100644
index 0000000..f6a36b8
--- /dev/null
+++ b/resources/js/ui.js
@@ -0,0 +1,643 @@
+const Template = {
+ popup: document.querySelector('#tpl___popup'),
+
+ battleMonster: document.querySelector('#tpl___battle__monster'),
+ battleHpBar: document.querySelector('#tpl___battle__hp-bar'),
+ battleExpBar: document.querySelector('#tpl___battle__exp-bar'),
+ battleDamage: document.querySelector('#tpl___battle__damage'),
+
+ movesetList: document.querySelector('#tpl___moveset__list'),
+ movesetItem: document.querySelector('#tpl___moveset__item'),
+
+ techniques: document.querySelector('#tpl___techniques'),
+ technique: document.querySelector('#tpl___technique'),
+
+ party: document.querySelector('#tpl___party'),
+ partyMonster: document.querySelector('#tpl___party__monster'),
+
+ menuJournal: document.querySelector('#tpl___menu__journal'),
+};
+
+const UI = {
+ elements: {
+ battleEnemy: document.querySelector('#battle__enemy'),
+ battleEnemySprite: null,
+ battleEnemyAnimation: document.querySelector('.battle__monster-sprite__animation'),
+ battlePlayer: document.querySelector('#battle__player'),
+
+ techniques: document.querySelector('#techniques'),
+
+ money: document.querySelector('#money'),
+
+ menuParty: document.querySelector('#menu__party'),
+ menuInventory: document.querySelector('#menu__inventory'),
+ menuCatch: document.querySelector('#menu__catch'),
+ menuJournal: document.querySelector('#menu__journal'),
+ },
+
+ events: document.createElement('div'),
+
+
+ /**
+ * @param {HTMLElement} template
+ */
+ createTemplate (template) {
+ const templateBase = document.createElement('div');
+ templateBase.innerHTML = template.innerHTML.trim();
+
+ return templateBase.firstChild;
+ },
+
+ /**
+ * @returns {HTMLElement}
+ */
+ createPopup () {
+ const popup = UI.createTemplate(Template.popup);
+
+ popup.addEventListener('click', ({ target }) => {
+ if (target === popup) {
+ popup.dispatchEvent(new Event('close'));
+ popup.remove();
+ }
+ });
+
+ return popup;
+ },
+
+ /**
+ * @param {HTMLElement} slotNode
+ * @param {HTMLElement} replacingNode
+ *
+ * @returns {HTMLElement}
+ */
+ replaceTemplateSlot (slotNode, replacingNode) {
+ replacingNode.dataset.templateSlot = slotNode.dataset.templateSlot;
+ slotNode.replaceWith(replacingNode);
+
+ return replacingNode;
+ },
+
+ /**
+ * @param {HTMLElement} popup
+ */
+ drawPopup (popup) {
+ const otherPopupExists = document.querySelector('.popup__overlay');
+ if (otherPopupExists) {
+ popup.classList.add('popup--is-multiple');
+ }
+
+ document.body.appendChild(popup);
+ },
+
+
+ /* Battle */
+
+ /**
+ * @type {MouseEvent}
+ */
+ battleClickEvent: null,
+
+ techniqueAnimationIsRunning: false,
+ techniqueAnimationNumber: 0,
+ techniqueAnimationFps: 20,
+
+
+ /**
+ * @param {Monster} monster
+ *
+ * @returns {HTMLElement}
+ */
+ createHpBar (monster) {
+ const template = UI.createTemplate(Template.battleHpBar);
+ const bar = template.querySelector('[data-template-slot="bar"]');
+ const text = template.querySelector('[data-template-slot="text"]');
+
+ let barColor;
+ const percentHp = (monster.hp / monster.stats.hp) * 100;
+ if (percentHp > 60) {
+ barColor = 'green';
+ } else if (percentHp > 15) {
+ barColor = 'rgb(240, 240, 100)';
+ } else {
+ barColor = 'red';
+ }
+
+ bar.style.backgroundColor = barColor;
+ bar.style.width = `${percentHp}%`;
+ text.textContent = `${monster.hp} / ${monster.stats.hp}`;
+
+ return template;
+ },
+
+ /**
+ * @param {Monster} monster
+ *
+ * @returns {HTMLElement}
+ */
+ createExpBar (monster) {
+ const template = UI.createTemplate(Template.battleExpBar);
+ const bar = template.querySelector('[data-template-slot="bar"]');
+ const text = template.querySelector('[data-template-slot="text"]');
+
+ const expToNextLevel = monster.getExperienceRequired() - monster.getExperienceRequired(-1);
+ const currentRelativeExp = monster.exp - monster.getExperienceRequired(-1);
+ const expPercent = (currentRelativeExp / expToNextLevel) * 100;
+
+ bar.style.width = `${expPercent}%`;
+ text.textContent = `${monster.exp} / ${monster.getExperienceRequired()}`;
+
+ return template;
+ },
+
+ /**
+ * @param {string} type
+ *
+ * @returns {HTMLElement}
+ */
+ createElementTypeIcon (type) {
+ const img = document.createElement('img');
+ img.src = `/modules/tuxemon/mods/tuxemon/gfx/ui/icons/element/${type}_type.png`;
+ img.title = slugToName(type);
+
+ return img;
+ },
+
+ /**
+ * @param {StatusEffect} statusEffect
+ *
+ * @returns {HTMLElement}
+ */
+ createStatusEffectIcon (statusEffect) {
+ if (!statusEffect) {
+ return document.createElement('i');
+ }
+
+ const img = document.createElement('img');
+ img.src = `/modules/tuxemon/mods/tuxemon/gfx/ui/icons/status/icon_${statusEffect.slug}.png`;
+ img.title = statusEffect.name;
+
+ return img;
+ },
+
+ /**
+ * @param {Monster} monster
+ */
+ createBattleMonster (monster) {
+ const template = UI.createTemplate(Template.battleMonster);
+
+ template.querySelector('[data-template-slot="name"]').textContent = monster.name;
+ template.querySelector('[data-template-slot="gender"]').textContent = monster.gender === 'male' ? '♂' : monster.gender === 'female' ? '♀' : '⚲';
+ template.querySelector('[data-template-slot="level"]').textContent = monster.level;
+ template.querySelector('[data-template-slot="statusEffect"]').innerHTML = UI.createStatusEffectIcon(monster.statusEffect).outerHTML;
+ template.querySelector('[data-template-slot="sprite"]').src = `/modules/tuxemon/mods/tuxemon/gfx/sprites/battle/${monster.slug}-front.png`;
+
+ UI.replaceTemplateSlot(template.querySelector('[data-template-slot="hpBar"]'), UI.createHpBar(monster));
+
+ return template;
+ },
+
+ /**
+ * @returns {HTMLElement}
+ */
+ createEnemyMonster () {
+ const battleMonsterNode = UI.createBattleMonster(state.enemy.monster);
+
+ battleMonsterNode.classList.add('battle__monster--enemy');
+
+ return battleMonsterNode;
+ },
+
+ /**
+ * @returns {HTMLElement}
+ */
+ createActiveMonster () {
+ const battleMonsterNode = UI.createBattleMonster(state.activeMonster);
+
+ UI.replaceTemplateSlot(
+ battleMonsterNode.querySelector('[data-template-slot="expBar"]'),
+ UI.createExpBar(state.activeMonster)
+ );
+
+ battleMonsterNode.classList.add('battle__monster--player');
+
+ battleMonsterNode.querySelector('[data-template-slot="sprite"]').addEventListener('click', () => {
+ UI.openStatsMenu(state.activeMonster);
+ });
+
+ return battleMonsterNode;
+ },
+
+ /**
+ * @param {Monster} monster
+ *
+ * @returns {HTMLElement}
+ */
+ createActiveTechniques (monster) {
+ const template = UI.createTemplate(Template.techniques);
+
+ for (const technique of monster.activeTechniques) {
+ const techniqueNode = UI.createTemplate(Template.technique);
+
+ techniqueNode.querySelector('[data-template-slot="name"]').textContent = technique.name;
+ techniqueNode.querySelector('[data-template-slot="types"]').innerHTML = technique.types.map((type) => UI.createElementTypeIcon(type).outerHTML).join('');
+ techniqueNode.querySelector('[data-template-slot="power"]').textContent = technique.power;
+ techniqueNode.querySelector('[data-template-slot="accuracy"]').textContent = technique.accuracy;
+ techniqueNode.querySelector('[data-template-slot="range"]').textContent = technique.range;
+
+ template.appendChild(techniqueNode);
+ }
+
+ return template;
+ },
+
+ /**
+ * @param {HTMLElement} battleMonsterNode
+ */
+ drawEnemyMonster () {
+ const battleMonsterNode = UI.createEnemyMonster();
+
+ UI.elements.battleEnemySprite = battleMonsterNode.querySelector('[data-template-slot="sprite"]');
+ UI.elements.battleEnemySprite.style.transitionDuration = `${UI.damageHighlightClickDuration}s`;
+
+ const previousBattleMonsterNode = UI.elements.battleEnemy.querySelector('.battle__monster');
+ if (previousBattleMonsterNode) {
+ UI.elements.battleEnemySprite.classList = previousBattleMonsterNode.querySelector('[data-template-slot="sprite"]').classList;
+
+ UI.elements.battleEnemy.removeChild(previousBattleMonsterNode);
+ }
+ UI.elements.battleEnemy.appendChild(battleMonsterNode);
+ },
+
+ /**
+ * @returns {void}
+ */
+ drawActiveMonster () {
+ const battleMonsterNode = UI.createActiveMonster();
+
+ const previousBattleMonsterNode = UI.elements.battlePlayer.querySelector('.battle__monster');
+ if (previousBattleMonsterNode) {
+ UI.elements.battlePlayer.removeChild(previousBattleMonsterNode);
+ }
+ UI.elements.battlePlayer.appendChild(battleMonsterNode);
+ },
+
+ /**
+ * @returns {void}
+ */
+ drawActiveTechniques () {
+ const activeTechniques = UI.createActiveTechniques(state.activeMonster);
+ activeTechniques.id = 'techniques';
+
+ document.querySelector('#techniques').innerHTML = activeTechniques.innerHTML;
+ },
+
+ /**
+ * @returns {void}
+ */
+ drawTechniqueAnimation () {
+ if (!UI.techniqueAnimationIsRunning && state.activeTechnique.animation && DB.allAnimations[state.activeTechnique.animation]) {
+ UI.techniqueAnimationIsRunning = true;
+
+ const techniqueAnimationLoop = () => {
+ UI.elements.battleEnemyAnimation.src = `/modules/tuxemon/mods/tuxemon/animations/technique/${state.activeTechnique.animation}_${("00" + UI.techniqueAnimationNumber).slice(-2)}.png`;
+ UI.elements.battleEnemyAnimation.style.top = UI.battleClickEvent.clientY - (UI.elements.battleEnemyAnimation.clientHeight / 2);
+ UI.elements.battleEnemyAnimation.style.left = UI.battleClickEvent.clientX - (UI.elements.battleEnemyAnimation.clientWidth / 2);
+ // console.log(UI.elements.battleEnemyAnimation.src);
+
+ UI.techniqueAnimationNumber++;
+
+ if (UI.techniqueAnimationNumber >= DB.allAnimations[state.activeTechnique.animation].length) {
+ UI.techniqueAnimationIsRunning = false;
+ UI.techniqueAnimationNumber = 0;
+ UI.elements.battleEnemyAnimation.src = '';
+ return;
+ }
+
+ setTimeout(() => requestAnimationFrame(techniqueAnimationLoop), 1000 / UI.techniqueAnimationFps);
+ };
+
+ requestAnimationFrame(techniqueAnimationLoop);
+ }
+ },
+
+
+ /* Battle - Damage */
+
+ damageHighlightClickDuration: 0.1,
+ damageHighlightClickTimeout: null,
+
+ /**
+ * @param {number|string} damage
+ *
+ * @returns {HTMLElement}
+ */
+ createDamage (damage) {
+ const damageNode = UI.createTemplate(Template.battleDamage);
+ damageNode.innerHTML = damage;
+
+ damageNode.style.top = UI.battleClickEvent.pageY - UI.elements.battleEnemy.offsetTop + (Math.random() * 40 - 20);
+ damageNode.style.left = UI.battleClickEvent.pageX - UI.elements.battleEnemy.offsetLeft + (Math.random() * 40 - 20);
+
+ damageNode.dataset.duration = 2;
+ damageNode.style.animationDuration = `${damageNode.dataset.duration}s`;
+
+ return damageNode;
+ },
+
+ /**
+ * @returns {HTMLElement}
+ */
+ createDamageMiss () {
+ return UI.createDamage('MISS!');
+ },
+
+ /**
+ * @param {HTMLElement} damageNode
+ * @param {number} multiplier
+ *
+ * @returns {HTMLElement}
+ */
+ applyMultiplierToDamage (damageNode, multiplier) {
+ damageNode.style.fontSize = `${multiplier * 2}rem`;
+
+ return damageNode;
+ },
+
+ /**
+ * @param {HTMLElement} damageNode
+ * @param {Technique} technique
+ *
+ * @returns {HTMLElement}
+ */
+ applyTechniqueToDamage (damageNode, technique) {
+ damageNode.style.color = mixColors(
+ ...technique.types.map((type) => standardizeColor(ElementTypeColor[type]))
+ );
+
+ return damageNode;
+ },
+
+ /**
+ * @param {HTMLElement} damageNode
+ * @param {StatusEffect} statusEffect
+ *
+ * @returns {HTMLElement}
+ */
+ applyStatusEffectToDamage (damageNode, statusEffect) {
+ damageNode.style.color = StatusEffectTypeColor[statusEffect.slug];
+
+ return damageNode;
+ },
+
+ /**
+ * @param {HTMLElement} damageNode
+ */
+ drawDamage (damageNode) {
+ UI.elements.battleEnemy.appendChild(damageNode);
+ setTimeout(() => damageNode.remove(), (damageNode.dataset.duration * 1000) - 500);
+
+ UI.elements.battleEnemySprite.classList.add('damaged');
+ clearTimeout(UI.damageHighlightClickTimeout);
+ UI.damageHighlightClickTimeout = setTimeout(() => UI.elements.battleEnemySprite.classList.remove('damaged'), UI.damageHighlightClickDuration * 1000);
+
+ UI.drawTechniqueAnimation();
+ },
+
+ /**
+ * @param {HTMLElement} damageNode
+ */
+ drawDamageMiss (damageNode) {
+ UI.elements.battleEnemy.appendChild(damageNode);
+ setTimeout(() => damageNode.remove(), (damageNode.dataset.duration * 1000) - 500);
+ },
+
+
+
+ /* Menu */
+
+ partySelectionMode: 'select',
+
+
+ openPartyMenu () {
+ const popup = UI.createPopup();
+
+ const party = UI.createTemplate(Template.party);
+ party.id = 'party';
+ for (const monsterIdx in state.partyMonsters) {
+ const monster = state.partyMonsters[monsterIdx];
+ const partyMonster = UI.createTemplate(Template.partyMonster);
+
+ partyMonster.querySelector('[data-template-slot="sprite"]').src = `/modules/tuxemon/mods/tuxemon/gfx/sprites/battle/${monster.slug}-front.png`;
+
+ partyMonster.addEventListener('click', async (event) => {
+ // bubble up to partyNode
+ let target = event.target;
+ while (target.parentNode.id !== party.id) {
+ target = target.parentNode;
+ }
+
+ if (UI.partySelectionMode === 'select') {
+ state.activeMonster = monster;
+ state.activeTechnique = state.activeMonster.activeTechniques[0];
+
+ UI.drawActiveMonster();
+ UI.drawActiveTechniques();
+
+ popup.remove();
+ }
+ else if (UI.partySelectionMode === 'stats') {
+ UI.openStatsMenu(monster);
+ }
+ else if (UI.partySelectionMode === 'techniques') {
+ UI.openMovesetSelection(monster);
+ }
+
+ UI.events.dispatchEvent(new CustomEvent('party:monsterSelected', {
+ detail: {
+ monster: monster,
+ mode: UI.partySelectionMode,
+ },
+ }));
+ });
+
+ party.querySelector('[data-template-slot="monsters"]').appendChild(partyMonster);
+ }
+
+ const selectionModesNode = party.querySelector('[data-template-slot="modes"]');
+ const selectionModeNodes = selectionModesNode.querySelectorAll('[data-party-selection-mode]');
+ selectionModeNodes.forEach((node) => {
+ if (node.dataset.partySelectionMode === UI.partySelectionMode) {
+ node.setAttribute('selected', true);
+ }
+
+ node.addEventListener('click', () => {
+ selectionModesNode.querySelector(`[data-party-selection-mode="${UI.partySelectionMode}"]`).removeAttribute('selected');
+
+ UI.partySelectionMode = node.dataset.partySelectionMode;
+
+ node.setAttribute('selected', true);
+ });
+ });
+
+ popup.querySelector('.popup').appendChild(party);
+ UI.drawPopup(popup);
+ },
+
+ openInventoryMenu () { // TODO
+ const popup = UI.createPopup();
+
+ const inventory = document.createElement('div');
+ inventory.id = 'inventory';
+ for (const item of state.inventory) {
+ }
+
+ popup.querySelector('.popup').appendChild(inventory);
+ UI.drawPopup(popup);
+ },
+
+ openJournalMenu () { // TODO
+ const popup = UI.createPopup();
+ const journal = UI.createTemplate(Template.menuJournal);
+
+ journal.querySelector('[data-template-slot="save"]').addEventListener('click', () => {
+ UI.openSaveDialog();
+ });
+
+ journal.querySelector('[data-template-slot="load"]').addEventListener('click', () => {
+ UI.openLoadDialog();
+ });
+
+ popup.querySelector('.popup').appendChild(journal);
+ UI.drawPopup(popup);
+ },
+
+ openSaveDialog () { // TODO
+ const popup = UI.createPopup();
+
+ const textarea = document.createElement('textarea');
+ textarea.value = Game.save();
+
+ popup.querySelector('.popup').appendChild(textarea);
+ UI.drawPopup(popup);
+ },
+
+ openLoadDialog () { // TODO
+ const popup = UI.createPopup();
+
+ const textarea = document.createElement('textarea');
+
+ const loadButton = document.createElement('button');
+ loadButton.textContent = "Load";
+ loadButton.addEventListener('click', () => Game.load(textarea.value.trim()));
+
+ popup.querySelector('.popup').appendChild(textarea);
+ popup.querySelector('.popup').appendChild(loadButton);
+
+ UI.drawPopup(popup);
+ },
+
+
+ /* Menu - Monster */
+
+ /**
+ * @param {Monster} monster
+ *
+ * @returns {HTMLElement}
+ */
+ createStatsMenu (monster) { // TODO
+ const template = document.createElement('div');
+ template.textContent = "select moves for " + monster.name;
+ template.style.width = '90vw';
+ template.style.height = '90vh';
+
+ template.addEventListener('click', () => UI.openMovesetSelection(monster));
+
+ return template;
+ },
+
+ /**
+ * @param {Monster} monster
+ *
+ * @returns {Promise<HTMLElement>}
+ */
+ async createMovesetSelection (monster) {
+ const movesetListNode = UI.createTemplate(Template.movesetList);
+ for (const move of monster.moveset) {
+ const technique = await fetchTechnique(move.technique);
+ const movesetItemNode = UI.createTemplate(Template.movesetItem);
+
+ movesetItemNode.querySelector('[data-template-slot="name"]').textContent = slugToName(technique.slug);
+ movesetItemNode.querySelector('[data-template-slot="types"]').innerHTML = technique.types.map((type) => UI.createElementTypeIcon(type).outerHTML).join('');
+ movesetItemNode.querySelector('[data-template-slot="power"]').textContent = technique.power;
+ movesetItemNode.querySelector('[data-template-slot="level"]').textContent = move.level_learned;
+
+ // disabled?
+ if (monster.level < move.level_learned) {
+ movesetItemNode.setAttribute('disabled', true);
+ }
+
+ // selected?
+ if (monster.activeTechniques.find((item) => item.slug == technique.slug)) {
+ movesetItemNode.toggleAttribute('selected');
+ }
+
+ // clicked
+ movesetItemNode.addEventListener('click', () => {
+ if (movesetItemNode.getAttribute('disabled')) {
+ return false;
+ }
+
+ // un/select
+ movesetItemNode.toggleAttribute('selected');
+
+ const isSelected = movesetItemNode.hasAttribute('selected');
+ if (isSelected) {
+ monster.activeTechniques.push(technique);
+ } else {
+ const idxTechniqueToRemove = monster.activeTechniques.findIndex((item) => item.slug == technique.slug);
+ if (idxTechniqueToRemove > -1) {
+ monster.activeTechniques.splice(idxTechniqueToRemove, 1);
+ }
+ }
+
+ const event = new CustomEvent('movesetSelection:moveSelected', {
+ detail: {
+ isSelected: movesetItemNode.hasAttribute('selected'),
+ technique: technique,
+ },
+ });
+ UI.events.dispatchEvent(event);
+ });
+
+ movesetListNode.appendChild(movesetItemNode);
+ }
+
+ return movesetListNode;
+ },
+
+ openStatsMenu (monster) {
+ const popup = UI.createPopup();
+ const statusMenu = UI.createStatsMenu(monster);
+
+ popup.querySelector('.popup').appendChild(statusMenu);
+ UI.drawPopup(popup);
+ },
+
+ /**
+ * @param {Monster} monster
+ */
+ async openMovesetSelection (monster) {
+ const popup = UI.createPopup();
+ const movesetSelection = await UI.createMovesetSelection(monster);
+
+ popup.querySelector('.popup').appendChild(movesetSelection);
+ popup.addEventListener('close', () => UI.drawActiveTechniques());
+
+ UI.drawPopup(popup);
+ },
+};
+
+// UI element click bindings
+UI.elements.menuParty.addEventListener('click', UI.openPartyMenu);
+UI.elements.menuInventory.addEventListener('click', UI.openInventoryMenu);
+UI.elements.menuJournal.addEventListener('click', UI.openJournalMenu);