diff options
Diffstat (limited to 'resources')
-rw-r--r-- | resources/css/battle.css | 231 | ||||
-rw-r--r-- | resources/css/menu.css | 70 | ||||
-rw-r--r-- | resources/css/page.css | 24 | ||||
-rw-r--r-- | resources/css/variables.css | 3 | ||||
-rw-r--r-- | resources/js/classes/Item.js | 1 | ||||
-rw-r--r-- | resources/js/classes/Monster.js | 223 | ||||
-rw-r--r-- | resources/js/classes/State.js | 38 | ||||
-rw-r--r-- | resources/js/classes/StatusEffect.js | 55 | ||||
-rw-r--r-- | resources/js/classes/Technique.js | 77 | ||||
-rw-r--r-- | resources/js/db.js | 62 | ||||
-rw-r--r-- | resources/js/definitions.js | 136 | ||||
-rw-r--r-- | resources/js/formula.js | 93 | ||||
-rw-r--r-- | resources/js/game.js | 459 | ||||
-rw-r--r-- | resources/js/helpers.js | 45 | ||||
-rw-r--r-- | resources/js/ui.js | 643 |
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); |