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'), techniques: document.querySelector('#tpl___techniques'), technique: document.querySelector('#tpl___technique'), party: document.querySelector('#tpl___party'), partyMonster: document.querySelector('#tpl___party__monster'), monsterStats: document.querySelector('#tpl___monster-stats'), movesetList: document.querySelector('#tpl___moveset__list'), movesetItem: document.querySelector('#tpl___moveset__item'), menuJournal: document.querySelector('#tpl___menu__journal'), dialogSave: document.querySelector('#tpl___dialog__save'), dialogLoad: document.querySelector('#tpl___dialog__load'), }; 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 */ 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} gender * * @returns {HTMLElement} */ createGenderIcon (gender) { const icon = document.createElement('span'); icon.textContent = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '⚲'; icon.title = slugToName(gender); icon.classList.add('gender-icon'); return icon; }, /** * @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"]').innerHTML = UI.createGenderIcon(monster.gender).outerHTML; 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="recharge"]').textContent = technique.rechargeLength; techniqueNode.querySelector('[data-template-slot="types"]').innerHTML = technique.types.map((type) => UI.createElementTypeIcon(type).outerHTML).join(''); techniqueNode.querySelector('[data-template-slot="range"]').textContent = technique.range; techniqueNode.querySelector('[data-template-slot="power"]').textContent = technique.power; techniqueNode.querySelector('[data-template-slot="accuracy"]').textContent = technique.accuracy; if (!technique.isUsable()) { techniqueNode.setAttribute('disabled', true); } 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 () { const x = UI.battleClickEvent.clientX; const y = UI.battleClickEvent.clientY; 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 = y - (UI.elements.battleEnemyAnimation.clientHeight / 2) + 'px'; UI.elements.battleEnemyAnimation.style.left = x - (UI.elements.battleEnemyAnimation.clientWidth / 2) + 'px'; // 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 */ /** * @type {MouseEvent} */ battleClickEvent: null, damageHighlightClickDuration: 0.1, damageHighlightClickTimeout: null, /** * @param {any} feedback * * @returns {HTMLElement} */ createActionFeedback (feedback) { const feedbackNode = UI.createTemplate(Template.battleDamage); feedbackNode.innerHTML = feedback; feedbackNode.style.top = UI.battleClickEvent.pageY - UI.elements.battleEnemy.offsetTop + (Math.random() * 40 - 20) + 'px'; feedbackNode.style.left = UI.battleClickEvent.pageX - UI.elements.battleEnemy.offsetLeft + (Math.random() * 40 - 20) + 'px'; feedbackNode.dataset.duration = 2; feedbackNode.style.animationDuration = `${feedbackNode.dataset.duration}s`; return feedbackNode; }, /** * @param {number|string} damage * * @returns {HTMLElement} */ createDamage (damage) { return UI.createActionFeedback(damage); }, /** * @returns {HTMLElement} */ createDamageMiss () { return UI.createActionFeedback('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 */ drawActionFeedback (node) { UI.elements.battleEnemy.appendChild(node); setTimeout(() => node.remove(), (node.dataset.duration * 1000) - 500); }, /** * @param {HTMLElement} damageNode */ drawDamage (damageNode) { UI.drawActionFeedback(damageNode); UI.elements.battleEnemySprite.classList.add('damaged'); clearTimeout(UI.damageHighlightClickTimeout); UI.damageHighlightClickTimeout = setTimeout(() => UI.elements.battleEnemySprite.classList.remove('damaged'), UI.damageHighlightClickDuration * 1000); }, /** * @param {HTMLElement} damageNode */ drawDamageMiss (damageNode) { UI.drawActionFeedback(damageNode); }, /* 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.querySelector('[data-template-slot="name"]').textContent = monster.name; partyMonster.querySelector('[data-template-slot="gender"]').innerHTML = UI.createGenderIcon(monster.gender).outerHTML; partyMonster.querySelector('[data-template-slot="level"]').textContent = monster.level; partyMonster.querySelector('[data-template-slot="hpText"]').textContent = `${monster.hp} / ${monster.stats.hp}`; 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 () { 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 () { const popup = UI.createPopup(); const dialog = UI.createTemplate(Template.dialogSave); dialog.querySelector('[data-template-slot="saveData"]').value = Game.save(); dialog.querySelector('[data-template-slot="saveClipboard"]').addEventListener('click', () => { alert('Saved to clipboard!'); }); popup.querySelector('.popup').appendChild(dialog); UI.drawPopup(popup); }, openLoadDialog () { const popup = UI.createPopup(); const dialog = UI.createTemplate(Template.dialogLoad); dialog.querySelector('[data-template-slot="load"]').addEventListener('click', () => { Game.load( dialog.querySelector('[data-template-slot="saveData"]').value.trim() ); document.querySelectorAll('.popup__overlay').forEach((element) => element.remove()) }); popup.querySelector('.popup').appendChild(dialog); UI.drawPopup(popup); }, /* Menu - Monster */ /** * @param {Monster} monster * * @returns {HTMLElement} */ createStatsMenu (monster) { // TODO const template = UI.createTemplate(Template.monsterStats); template.querySelector('[data-template-slot="name"]').textContent = monster.name; template.querySelector('[data-template-slot="gender"]').innerHTML = UI.createGenderIcon(monster.gender).outerHTML; template.querySelector('[data-template-slot="level"]').textContent = monster.level; template.querySelector('[data-template-slot="types"]').innerHTML = monster.types.map((type) => UI.createElementTypeIcon(type).outerHTML).join(''); template.querySelector('[data-template-slot="stats.melee"]').textContent = monster.stats.melee; template.querySelector('[data-template-slot="stats.armour"]').textContent = monster.stats.armour; template.querySelector('[data-template-slot="stats.ranged"]').textContent = monster.stats.ranged; template.querySelector('[data-template-slot="stats.dodge"]').textContent = monster.stats.dodge; template.querySelector('[data-template-slot="stats.speed"]').textContent = monster.stats.speed; template.querySelector('[data-template-slot="techniques"]').addEventListener('click', () => UI.openMovesetSelection(monster)); return template; }, /** * @param {Monster} monster * * @returns {Promise} */ 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 = technique.name; 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; } if (monster.activeTechniques.length === 4 && !movesetItemNode.hasAttribute('selected')) { return; } // 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);