const Template = { popup: document.querySelector('#tpl___popup'), tabs: document.querySelector('#tpl___tabs'), tabHeading: document.querySelector('#tpl___tabs__tab-heading'), tabPanels: document.querySelector('#tpl___tabs__panels'), tabPanel: document.querySelector('#tpl___tabs__tab-panel'), 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'), inventory: document.querySelector('#tpl___inventory'), inventoryItem: document.querySelector('#tpl___inventory__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(); const templateNode = templateBase.firstChild; /** * @param {HTMLElement} targetElement */ templateNode.appendTo = function (targetElement) { if (templateNode.dataset.templateType && templateNode.dataset.templateType === 'multiple') { for (const child of [...this.children]) { targetElement.appendChild(child); } } else { targetElement.appendChild(this); } }; return templateNode; }, /** * @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; }, /** * @typedef {Object} Tab * @property {HTMLElement} heading * @property {HTMLElement} content * @inner * * @param {Tab[]} tabs * * @returns {HTMLElement} */ createTabs (tabs) { const wrap = UI.createTemplate(Template.tabs); const panelsNode = UI.createTemplate(Template.tabPanels); wrap.style.gridTemplateColumns = '1fr '.repeat(tabs.length); const name = randomString(); for (const idx in tabs) { const tab = tabs[idx]; const tabHeading = UI.createTemplate(Template.tabHeading); const tabPanel = UI.createTemplate(Template.tabPanel); const inputId = `${name}_${idx}`; const panelId = randomString(); const tabHeadingInput = tabHeading.querySelector('[data-template-slot="input"]'); tabHeadingInput.name = name; tabHeadingInput.id = inputId; tabHeadingInput.setAttribute('aria-controls', panelId); if (idx == 0) { tabHeadingInput.checked = true; } const tabHeadingLabel = tabHeading.querySelector('[data-template-slot="label"]'); tabHeadingLabel.setAttribute('for', inputId); tabHeadingLabel.appendChild(tab.heading); tabPanel.id = panelId; tabPanel.appendChild(tab.content); tabHeading.appendTo(wrap); panelsNode.appendChild(tabPanel); } wrap.appendChild(panelsNode); return wrap; }, /** * @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.isRecharging()) { 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; }, /** * @param {Technique} technique * * @returns {void} */ drawTechniqueAnimation (technique) { const animation = technique.animation; if (!(animation && DB.allAnimations[animation])) { return; } if (UI.techniqueAnimationIsRunning) { return; } UI.techniqueAnimationIsRunning = true; const x = UI.battleClickEvent.clientX; const y = UI.battleClickEvent.clientY; const techniqueAnimationLoop = () => { UI.elements.battleEnemyAnimation.src = `/modules/tuxemon/mods/tuxemon/animations/technique/${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[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 () { const popup = UI.createPopup(); const inventory = UI.createTemplate(Template.inventory); const tabs = { heal: { heading: 'Heal', items: [], }, stats: { heading: 'Stats', items: [], }, balls: { heading: 'Balls', items: [], }, techniques: { heading: 'Techniques', items: [], }, other: { heading: 'Other', items: [], }, keyItems: { heading: 'Key Items', items: [], }, }; for (const item of state.inventory) { const inventoryItemNode = UI.createTemplate(Template.inventoryItem); inventoryItemNode.querySelector('[data-template-slot="sprite"]').src = `/modules/tuxemon/mods/tuxemon/${item.sprite}`; inventoryItemNode.querySelector('[data-template-slot="name"]').textContent = item.name; inventoryItemNode.querySelector('[data-template-slot="quantity"]').textContent = item.quantity; inventoryItemNode.addEventListener('click', () => { alert(item.conditions + item.effects); }); if (['potion', 'revive'].includes(item.category)) { tabs['heal'].items.push(inventoryItemNode); } else if (['stats'].includes(item.category)) { tabs['stats'].items.push(inventoryItemNode); } else if (['capture'].includes(item.category)) { tabs['balls'].items.push(inventoryItemNode); } else if (['technique'].includes(item.category)) { tabs['techniques'].items.push(inventoryItemNode); } else if (['KeyItem'].includes(item.type)) { tabs['keyItems'].items.push(inventoryItemNode); } else { tabs['other'].items.push(inventoryItemNode); } } const tabsNode = UI.createTabs(Object.values(tabs).map((tab) => { const content = document.createElement('div'); content.classList.add('inventory__tab'); for (const item of tab.items) { content.appendChild(item); } return { heading: document.createTextNode(tab.heading), content: content, }; })); tabsNode.style.gridTemplateColumns = '1fr 1fr 1fr'; inventory.appendChild(tabsNode); popup.querySelector('.popup').appendChild(inventory); popup.classList.add('inventory__popup'); 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); const saveData = Game.save(); dialog.querySelector('[data-template-slot="saveData"]').value = saveData; dialog.querySelector('[data-template-slot="saveClipboard"]').addEventListener('click', async () => { if (navigator.clipboard) { await navigator.clipboard.writeText(saveData); alert('Saved to clipboard!'); } else { alert('ERROR: Browser can\'t copy to clipboard! You have to do it manually.'); } }); 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);