5 changed files with 1354 additions and 66 deletions
@ -0,0 +1,589 @@ |
|||||||
|
/** |
||||||
|
* Centralized event handling system for Birthday Spire |
||||||
|
* Manages all user interactions, keyboard shortcuts, and UI events |
||||||
|
*/ |
||||||
|
|
||||||
|
export class EventHandler { |
||||||
|
constructor(root) { |
||||||
|
this.root = root; |
||||||
|
this.listeners = new Map(); // Track active listeners for cleanup
|
||||||
|
this.keyHandlers = new Map(); // Track keyboard shortcuts
|
||||||
|
this.globalHandlers = new Set(); // Track global event handlers
|
||||||
|
this.currentScreen = null; |
||||||
|
|
||||||
|
this.setupGlobalEvents(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Setup global event handlers that persist across screens |
||||||
|
*/ |
||||||
|
setupGlobalEvents() { |
||||||
|
// Global keyboard handler
|
||||||
|
this.globalKeyHandler = (e) => { |
||||||
|
const handler = this.keyHandlers.get(e.key.toLowerCase()); |
||||||
|
if (handler) { |
||||||
|
e.preventDefault(); |
||||||
|
handler(e); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
document.addEventListener('keydown', this.globalKeyHandler); |
||||||
|
this.globalHandlers.add('keydown'); |
||||||
|
|
||||||
|
// Global escape handler for modals
|
||||||
|
this.globalEscapeHandler = (e) => { |
||||||
|
if (e.key === 'Escape') { |
||||||
|
this.closeTopModal(); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
document.addEventListener('keydown', this.globalEscapeHandler); |
||||||
|
this.globalHandlers.add('escape'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Switch to a new screen and cleanup old events |
||||||
|
*/ |
||||||
|
switchScreen(screenName) { |
||||||
|
this.cleanup(); |
||||||
|
this.currentScreen = screenName; |
||||||
|
this.keyHandlers.clear(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Add event listener with automatic tracking for cleanup |
||||||
|
*/ |
||||||
|
on(element, event, handler, options = {}) { |
||||||
|
if (!element) return; |
||||||
|
|
||||||
|
const wrappedHandler = (e) => { |
||||||
|
try { |
||||||
|
handler(e); |
||||||
|
} catch (error) { |
||||||
|
console.error(`Event handler error (${event}):`, error); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
element.addEventListener(event, wrappedHandler, options); |
||||||
|
|
||||||
|
// Track for cleanup
|
||||||
|
if (!this.listeners.has(element)) { |
||||||
|
this.listeners.set(element, []); |
||||||
|
} |
||||||
|
this.listeners.get(element).push({ event, handler: wrappedHandler, options }); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Add keyboard shortcut |
||||||
|
*/ |
||||||
|
addKeyHandler(key, handler, description = '') { |
||||||
|
this.keyHandlers.set(key.toLowerCase(), handler); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Remove keyboard shortcut |
||||||
|
*/ |
||||||
|
removeKeyHandler(key) { |
||||||
|
this.keyHandlers.delete(key.toLowerCase()); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Battle screen event handlers |
||||||
|
*/ |
||||||
|
setupBattleEvents() { |
||||||
|
this.switchScreen('battle'); |
||||||
|
|
||||||
|
// Card play events
|
||||||
|
this.root.app.querySelectorAll("[data-play]").forEach(btn => { |
||||||
|
this.on(btn, "mouseenter", () => { |
||||||
|
if (btn.classList.contains('playable')) { |
||||||
|
this.playSound('swipe.mp3'); |
||||||
|
this.root.selectedCardIndex = null; |
||||||
|
this.updateCardSelection(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
this.on(btn, "click", () => { |
||||||
|
const index = parseInt(btn.dataset.play, 10); |
||||||
|
const card = this.root.player.hand[index]; |
||||||
|
if (this.root.player.energy >= card.cost) { |
||||||
|
this.playSound('played-card.mp3'); |
||||||
|
this.root.play(index); |
||||||
|
this.root.selectedCardIndex = null; |
||||||
|
this.updateCardSelection(); |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// End turn button
|
||||||
|
const endTurnBtn = this.root.app.querySelector("[data-action='end']"); |
||||||
|
if (endTurnBtn) { |
||||||
|
this.on(endTurnBtn, "click", () => { |
||||||
|
try { |
||||||
|
this.root.end(); |
||||||
|
} catch (error) { |
||||||
|
console.error("Error ending turn:", error); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Keyboard shortcuts for battle
|
||||||
|
this.addKeyHandler('e', () => { |
||||||
|
try { |
||||||
|
this.root.end(); |
||||||
|
} catch (error) { |
||||||
|
console.error("Error ending turn via keyboard:", error); |
||||||
|
} |
||||||
|
}, 'End Turn'); |
||||||
|
|
||||||
|
// Number keys for card selection and play
|
||||||
|
for (let i = 1; i <= 9; i++) { |
||||||
|
this.addKeyHandler(i.toString(), (e) => { |
||||||
|
const cardIndex = i - 1; |
||||||
|
const hand = this.root.player.hand; |
||||||
|
|
||||||
|
if (cardIndex >= hand.length) return; |
||||||
|
|
||||||
|
const card = hand[cardIndex]; |
||||||
|
|
||||||
|
if (this.root.selectedCardIndex === cardIndex) { |
||||||
|
// Second press - play the card
|
||||||
|
if (this.root.player.energy >= card.cost) { |
||||||
|
this.root.play(cardIndex); |
||||||
|
this.root.selectedCardIndex = null; |
||||||
|
this.updateCardSelection(); |
||||||
|
} |
||||||
|
} else { |
||||||
|
// First press - select the card
|
||||||
|
this.root.selectedCardIndex = cardIndex; |
||||||
|
this.updateCardSelection(); |
||||||
|
this.playSound('swipe.mp3'); |
||||||
|
} |
||||||
|
}, `Select/Play Card ${i}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Map screen event handlers |
||||||
|
*/ |
||||||
|
setupMapEvents() { |
||||||
|
this.switchScreen('map'); |
||||||
|
|
||||||
|
// Node navigation
|
||||||
|
this.root.app.querySelectorAll("[data-node]").forEach(el => { |
||||||
|
if (!el.dataset.node) return; |
||||||
|
|
||||||
|
this.on(el, "click", () => this.root.go(el.dataset.node)); |
||||||
|
}); |
||||||
|
|
||||||
|
// Tooltip handling for all nodes (including non-clickable ones)
|
||||||
|
this.root.app.querySelectorAll(".spire-node").forEach(el => { |
||||||
|
this.on(el, "mouseenter", (e) => this.showTooltip(e)); |
||||||
|
this.on(el, "mouseleave", () => this.hideTooltip()); |
||||||
|
}); |
||||||
|
|
||||||
|
// Messages button
|
||||||
|
const messagesBtn = this.root.app.querySelector("[data-action='show-messages']"); |
||||||
|
if (messagesBtn) { |
||||||
|
this.on(messagesBtn, "click", () => this.showMessagesModal()); |
||||||
|
} |
||||||
|
|
||||||
|
// Reset button
|
||||||
|
const resetBtn = this.root.app.querySelector("[data-reset]"); |
||||||
|
if (resetBtn) { |
||||||
|
this.on(resetBtn, "click", () => { |
||||||
|
this.root.clearSave(); |
||||||
|
this.root.reset(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Keyboard shortcut for messages
|
||||||
|
this.addKeyHandler('m', () => this.showMessagesModal(), 'Show Messages'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Reward screen event handlers |
||||||
|
*/ |
||||||
|
setupRewardEvents(choices) { |
||||||
|
this.switchScreen('reward'); |
||||||
|
|
||||||
|
this.root.app.querySelectorAll("[data-pick]").forEach(btn => { |
||||||
|
this.on(btn, "click", () => { |
||||||
|
const idx = parseInt(btn.dataset.pick, 10); |
||||||
|
this.root.takeReward(idx); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
const skipBtn = this.root.app.querySelector("[data-skip]"); |
||||||
|
if (skipBtn) { |
||||||
|
this.on(skipBtn, "click", () => this.root.skipReward()); |
||||||
|
} |
||||||
|
|
||||||
|
// Keyboard shortcuts for reward selection
|
||||||
|
for (let i = 1; i <= choices.length; i++) { |
||||||
|
this.addKeyHandler(i.toString(), () => { |
||||||
|
this.root.takeReward(i - 1); |
||||||
|
}, `Select Reward ${i}`); |
||||||
|
} |
||||||
|
|
||||||
|
this.addKeyHandler('s', () => this.root.skipReward(), 'Skip Reward'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Rest screen event handlers |
||||||
|
*/ |
||||||
|
setupRestEvents() { |
||||||
|
this.switchScreen('rest'); |
||||||
|
|
||||||
|
const healBtn = this.root.app.querySelector("[data-act='heal']"); |
||||||
|
const upgradeBtn = this.root.app.querySelector("[data-act='upgrade']"); |
||||||
|
|
||||||
|
if (healBtn) { |
||||||
|
this.on(healBtn, "click", () => { |
||||||
|
const heal = Math.floor(this.root.player.maxHp * 0.2); |
||||||
|
this.root.player.hp = Math.min(this.root.player.maxHp, this.root.player.hp + heal); |
||||||
|
this.root.log(`Rested: +${heal} HP`); |
||||||
|
this.root.afterNode(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
if (upgradeBtn) { |
||||||
|
this.on(upgradeBtn, "click", () => { |
||||||
|
// Import and call renderUpgrade
|
||||||
|
import("../ui/render.js").then(({ renderUpgrade }) => { |
||||||
|
renderUpgrade(this.root); |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
this.addKeyHandler('h', () => healBtn?.click(), 'Heal'); |
||||||
|
this.addKeyHandler('u', () => upgradeBtn?.click(), 'Upgrade'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Shop screen event handlers |
||||||
|
*/ |
||||||
|
setupShopEvents(shopCards = [], shopRelic = null) { |
||||||
|
this.switchScreen('shop'); |
||||||
|
|
||||||
|
// Card purchase events
|
||||||
|
this.root.app.querySelectorAll("[data-buy-card]").forEach(btn => { |
||||||
|
this.on(btn, "click", () => { |
||||||
|
const idx = parseInt(btn.dataset.buyCard, 10); |
||||||
|
const card = shopCards[idx]; |
||||||
|
if (this.root.player.gold >= 50) { |
||||||
|
this.root.player.gold -= 50; |
||||||
|
this.root.player.deck.push(card.id); |
||||||
|
this.root.log(`Bought ${card.name} for 50 gold.`); |
||||||
|
btn.disabled = true; |
||||||
|
btn.textContent = "SOLD"; |
||||||
|
|
||||||
|
this.updateGoldDisplay(); |
||||||
|
this.updateShopAffordability(); |
||||||
|
} else { |
||||||
|
this.root.log("Not enough gold!"); |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// Relic purchase
|
||||||
|
if (shopRelic) { |
||||||
|
const relicBtn = this.root.app.querySelector("[data-buy-relic]"); |
||||||
|
if (relicBtn) { |
||||||
|
this.on(relicBtn, "click", async () => { |
||||||
|
if (this.root.player.gold >= 100) { |
||||||
|
this.root.player.gold -= 100; |
||||||
|
this.root.log(`Bought ${shopRelic.name} for 100 gold.`); |
||||||
|
|
||||||
|
const { attachRelics } = await import("../engine/battle.js"); |
||||||
|
const currentRelicIds = this.root.relicStates.map(r => r.id); |
||||||
|
const newRelicIds = [...currentRelicIds, shopRelic.id]; |
||||||
|
attachRelics(this.root, newRelicIds); |
||||||
|
|
||||||
|
relicBtn.disabled = true; |
||||||
|
relicBtn.textContent = "SOLD"; |
||||||
|
|
||||||
|
this.updateGoldDisplay(); |
||||||
|
this.updateShopAffordability(); |
||||||
|
} else { |
||||||
|
this.root.log("Not enough gold!"); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Leave shop
|
||||||
|
const leaveBtn = this.root.app.querySelector("[data-leave]"); |
||||||
|
if (leaveBtn) { |
||||||
|
this.on(leaveBtn, "click", () => this.root.afterNode()); |
||||||
|
} |
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
this.addKeyHandler('escape', () => this.root.afterNode(), 'Leave Shop'); |
||||||
|
this.addKeyHandler('l', () => this.root.afterNode(), 'Leave Shop'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Event screen event handlers |
||||||
|
*/ |
||||||
|
setupEventEvents(event) { |
||||||
|
this.switchScreen('event'); |
||||||
|
|
||||||
|
this.root.app.querySelectorAll("[data-choice]").forEach(btn => { |
||||||
|
this.on(btn, "click", () => { |
||||||
|
const idx = parseInt(btn.dataset.choice, 10); |
||||||
|
event.choices[idx].effect(); |
||||||
|
this.root.afterNode(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// Keyboard shortcuts for event choices
|
||||||
|
for (let i = 1; i <= event.choices.length; i++) { |
||||||
|
this.addKeyHandler(i.toString(), () => { |
||||||
|
event.choices[i - 1].effect(); |
||||||
|
this.root.afterNode(); |
||||||
|
}, `Event Choice ${i}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Relic selection event handlers |
||||||
|
*/ |
||||||
|
setupRelicSelectionEvents(relicChoices) { |
||||||
|
this.switchScreen('relic-selection'); |
||||||
|
|
||||||
|
this.root.app.querySelectorAll("[data-relic]").forEach(btn => { |
||||||
|
this.on(btn, "click", () => { |
||||||
|
const relicId = btn.dataset.relic; |
||||||
|
this.root.selectStartingRelic(relicId); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// Messages button
|
||||||
|
const messagesBtn = this.root.app.querySelector("[data-action='show-messages']"); |
||||||
|
if (messagesBtn) { |
||||||
|
this.on(messagesBtn, "click", () => this.showMessagesModal()); |
||||||
|
} |
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
for (let i = 1; i <= relicChoices.length; i++) { |
||||||
|
this.addKeyHandler(i.toString(), () => { |
||||||
|
const relicBtn = this.root.app.querySelector(`[data-relic="${relicChoices[i-1]}"]`); |
||||||
|
relicBtn?.click(); |
||||||
|
}, `Select Relic ${i}`); |
||||||
|
} |
||||||
|
|
||||||
|
this.addKeyHandler('m', () => this.showMessagesModal(), 'Show Messages'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Win/Lose screen event handlers |
||||||
|
*/ |
||||||
|
setupEndGameEvents() { |
||||||
|
this.switchScreen('endgame'); |
||||||
|
|
||||||
|
const replayBtn = this.root.app.querySelector("[data-replay]"); |
||||||
|
const restartAct2Btn = this.root.app.querySelector("[data-restart-act2]"); |
||||||
|
const menuBtn = this.root.app.querySelector("[data-menu]"); |
||||||
|
|
||||||
|
if (replayBtn) { |
||||||
|
this.on(replayBtn, "click", () => this.root.reset()); |
||||||
|
} |
||||||
|
|
||||||
|
if (restartAct2Btn) { |
||||||
|
this.on(restartAct2Btn, "click", async () => { |
||||||
|
if (this.root.loadAct2Checkpoint()) { |
||||||
|
const { renderMap } = await import("../ui/render.js"); |
||||||
|
await renderMap(this.root); |
||||||
|
} else { |
||||||
|
this.root.reset(); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
if (menuBtn) { |
||||||
|
this.on(menuBtn, "click", () => this.root.reset()); |
||||||
|
} |
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
this.addKeyHandler('r', () => replayBtn?.click(), 'Replay'); |
||||||
|
this.addKeyHandler('2', () => restartAct2Btn?.click(), 'Restart Act 2'); |
||||||
|
this.addKeyHandler('m', () => menuBtn?.click(), 'Main Menu'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Utility methods |
||||||
|
*/ |
||||||
|
updateCardSelection() { |
||||||
|
// Remove selection from all cards
|
||||||
|
this.root.app.querySelectorAll('.battle-card').forEach(card => { |
||||||
|
card.classList.remove('card-selected'); |
||||||
|
}); |
||||||
|
|
||||||
|
// Add selection to currently selected card
|
||||||
|
if (this.root.selectedCardIndex !== null) { |
||||||
|
const selectedCard = this.root.app.querySelector(`[data-play="${this.root.selectedCardIndex}"]`); |
||||||
|
if (selectedCard) { |
||||||
|
selectedCard.classList.add('card-selected'); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
updateGoldDisplay() { |
||||||
|
const goldDisplay = this.root.app.querySelector('.gold-amount'); |
||||||
|
if (goldDisplay) { |
||||||
|
goldDisplay.textContent = this.root.player.gold; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
updateShopAffordability() { |
||||||
|
// Implementation would go here - update visual affordability indicators
|
||||||
|
// This would need to be implemented based on the current shop logic
|
||||||
|
} |
||||||
|
|
||||||
|
playSound(soundFile) { |
||||||
|
try { |
||||||
|
const audio = new Audio(`assets/sounds/${soundFile}`); |
||||||
|
audio.volume = 0.3; |
||||||
|
audio.play().catch(e => console.log(e)); |
||||||
|
} catch (e) { |
||||||
|
// Silently fail if audio not available
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
showTooltip(event) { |
||||||
|
const tooltip = document.getElementById('custom-tooltip'); |
||||||
|
if (!tooltip) return; |
||||||
|
|
||||||
|
const node = event.target.closest('.spire-node'); |
||||||
|
if (!node) return; |
||||||
|
|
||||||
|
const content = node.dataset.tooltip; |
||||||
|
const avatarPath = node.dataset.avatar; |
||||||
|
|
||||||
|
let tooltipHTML = ''; |
||||||
|
if (avatarPath) { |
||||||
|
tooltipHTML = ` |
||||||
|
<div class="tooltip-with-avatar"> |
||||||
|
<div class="tooltip-avatar"> |
||||||
|
<img src="${avatarPath}" alt="Enemy Avatar" class="tooltip-avatar-img"
|
||||||
|
onerror="this.style.display='none';"> |
||||||
|
</div> |
||||||
|
<div class="tooltip-content">${content}</div> |
||||||
|
</div> |
||||||
|
`;
|
||||||
|
} else { |
||||||
|
tooltipHTML = content; |
||||||
|
} |
||||||
|
|
||||||
|
tooltip.innerHTML = tooltipHTML; |
||||||
|
tooltip.style.display = 'block'; |
||||||
|
|
||||||
|
// Position tooltip
|
||||||
|
const rect = node.getBoundingClientRect(); |
||||||
|
tooltip.style.left = (rect.right + 15) + 'px'; |
||||||
|
tooltip.style.top = (rect.top + rect.height / 2 - tooltip.offsetHeight / 2) + 'px'; |
||||||
|
|
||||||
|
// Keep tooltip in viewport
|
||||||
|
const tooltipRect = tooltip.getBoundingClientRect(); |
||||||
|
if (tooltipRect.right > window.innerWidth) { |
||||||
|
tooltip.style.left = (rect.left - tooltip.offsetWidth - 15) + 'px'; |
||||||
|
} |
||||||
|
if (tooltipRect.top < 0) { |
||||||
|
tooltip.style.top = '10px'; |
||||||
|
} |
||||||
|
if (tooltipRect.bottom > window.innerHeight) { |
||||||
|
tooltip.style.top = (window.innerHeight - tooltip.offsetHeight - 10) + 'px'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
hideTooltip() { |
||||||
|
const tooltip = document.getElementById('custom-tooltip'); |
||||||
|
if (tooltip) { |
||||||
|
tooltip.style.display = 'none'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async showMessagesModal() { |
||||||
|
const { getAllMessages } = await import("../data/messages.js"); |
||||||
|
const messages = getAllMessages(); |
||||||
|
|
||||||
|
const modal = document.createElement('div'); |
||||||
|
modal.className = 'messages-modal-overlay'; |
||||||
|
modal.innerHTML = ` |
||||||
|
<div class="messages-modal"> |
||||||
|
<div class="messages-modal-header"> |
||||||
|
<h2>Messages for Prime</h2> |
||||||
|
<button class="messages-close-btn" aria-label="Close">×</button> |
||||||
|
</div> |
||||||
|
<div class="messages-modal-content"> |
||||||
|
${messages.length > 0 ? messages.map((msg, index) => ` |
||||||
|
<div class="message-item"> |
||||||
|
<div class="message-from">From: ${msg.from}</div> |
||||||
|
<div class="message-text">${msg.message}</div> |
||||||
|
</div> |
||||||
|
`).join('') : ` |
||||||
|
<div class="no-messages-placeholder"> |
||||||
|
<p>No messages added yet!</p> |
||||||
|
<p>Add your birthday messages to <code>src/data/messages.js</code></p> |
||||||
|
</div> |
||||||
|
`}
|
||||||
|
</div> |
||||||
|
</div> |
||||||
|
`;
|
||||||
|
|
||||||
|
const closeModal = () => { |
||||||
|
modal.remove(); |
||||||
|
}; |
||||||
|
|
||||||
|
// Setup modal events
|
||||||
|
const closeBtn = modal.querySelector('.messages-close-btn'); |
||||||
|
this.on(closeBtn, 'click', closeModal); |
||||||
|
this.on(modal, 'click', (e) => { |
||||||
|
if (e.target === modal) closeModal(); |
||||||
|
}); |
||||||
|
|
||||||
|
document.body.appendChild(modal); |
||||||
|
} |
||||||
|
|
||||||
|
closeTopModal() { |
||||||
|
const modal = document.querySelector('.messages-modal-overlay'); |
||||||
|
if (modal) { |
||||||
|
modal.remove(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Cleanup all event listeners |
||||||
|
*/ |
||||||
|
cleanup() { |
||||||
|
// Remove tracked listeners
|
||||||
|
for (const [element, handlers] of this.listeners) { |
||||||
|
for (const { event, handler, options } of handlers) { |
||||||
|
element.removeEventListener(event, handler, options); |
||||||
|
} |
||||||
|
} |
||||||
|
this.listeners.clear(); |
||||||
|
|
||||||
|
// Clear keyboard handlers
|
||||||
|
this.keyHandlers.clear(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Complete cleanup on destroy |
||||||
|
*/ |
||||||
|
destroy() { |
||||||
|
this.cleanup(); |
||||||
|
|
||||||
|
// Remove global handlers
|
||||||
|
if (this.globalHandlers.has('keydown')) { |
||||||
|
document.removeEventListener('keydown', this.globalKeyHandler); |
||||||
|
} |
||||||
|
if (this.globalHandlers.has('escape')) { |
||||||
|
document.removeEventListener('keydown', this.globalEscapeHandler); |
||||||
|
} |
||||||
|
|
||||||
|
this.globalHandlers.clear(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,466 @@ |
|||||||
|
/** |
||||||
|
* InputManager - Centralized event handling for Birthday Spire |
||||||
|
*
|
||||||
|
* This class consolidates ALL event listeners from the render functions |
||||||
|
* into one place while maintaining exact same functionality. |
||||||
|
*
|
||||||
|
* Following Nystrom's Input Handling patterns from Game Programming Patterns |
||||||
|
*/ |
||||||
|
|
||||||
|
export class InputManager { |
||||||
|
constructor(gameRoot) { |
||||||
|
this.root = gameRoot; |
||||||
|
this.activeHandlers = new Map(); // Track active event listeners for cleanup
|
||||||
|
this.globalHandlers = new Set(); // Track global document listeners
|
||||||
|
|
||||||
|
// Bind methods to preserve 'this' context
|
||||||
|
this.handleGlobalKeydown = this.handleGlobalKeydown.bind(this); |
||||||
|
this.handleGlobalClick = this.handleGlobalClick.bind(this); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Initialize global event listeners (always active) |
||||||
|
*/ |
||||||
|
initGlobalListeners() { |
||||||
|
// Global keyboard handling
|
||||||
|
document.addEventListener('keydown', this.handleGlobalKeydown); |
||||||
|
this.globalHandlers.add('keydown'); |
||||||
|
|
||||||
|
// Global click handling for data attributes
|
||||||
|
document.addEventListener('click', this.handleGlobalClick); |
||||||
|
this.globalHandlers.add('click'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Global keyboard event handler |
||||||
|
*/ |
||||||
|
handleGlobalKeydown(event) { |
||||||
|
// Handle Escape key for modals
|
||||||
|
if (event.key === 'Escape') { |
||||||
|
this.handleEscapeKey(event); |
||||||
|
} |
||||||
|
|
||||||
|
// Add other global shortcuts here as needed
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Global click handler using event delegation |
||||||
|
*/ |
||||||
|
handleGlobalClick(event) { |
||||||
|
const target = event.target; |
||||||
|
|
||||||
|
// Event delegation for game interactions
|
||||||
|
|
||||||
|
// Handle clicks on elements with data attributes (check both direct and parent elements)
|
||||||
|
|
||||||
|
// Check for card play (battle-card with data-play)
|
||||||
|
const cardElement = target.closest('[data-play]'); |
||||||
|
if (cardElement) { |
||||||
|
this.handleCardPlay(cardElement, event); |
||||||
|
return; // Early return to avoid duplicate handling
|
||||||
|
} |
||||||
|
|
||||||
|
// Check for other interactive elements (using closest to handle child elements)
|
||||||
|
const actionElement = target.closest('[data-action]'); |
||||||
|
if (actionElement) { |
||||||
|
this.handleActionButton(actionElement, event); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const actElement = target.closest('[data-act]'); |
||||||
|
if (actElement) { |
||||||
|
this.handleRestAction(actElement, event); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const pickElement = target.closest('[data-pick]'); |
||||||
|
if (pickElement) { |
||||||
|
this.handleRewardPick(pickElement, event); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const choiceElement = target.closest('[data-choice]'); |
||||||
|
if (choiceElement) { |
||||||
|
this.handleEventChoice(choiceElement, event); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const upgradeElement = target.closest('[data-upgrade]'); |
||||||
|
if (upgradeElement) { |
||||||
|
this.handleCardUpgrade(upgradeElement, event); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const buyCardElement = target.closest('[data-buy-card]'); |
||||||
|
if (buyCardElement) { |
||||||
|
this.handleShopCardBuy(buyCardElement, event); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const relicElement = target.closest('[data-relic]'); |
||||||
|
if (relicElement) { |
||||||
|
this.handleRelicSelection(relicElement, event); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Check for direct data attributes on target (fallback)
|
||||||
|
if (target.dataset.node !== undefined) { |
||||||
|
this.handleMapNodeClick(target, event); |
||||||
|
} |
||||||
|
|
||||||
|
// Handle spire node clicks (check if clicked element is inside a spire-node)
|
||||||
|
const spireNode = target.closest('.spire-node'); |
||||||
|
if (spireNode && spireNode.dataset.node) { |
||||||
|
this.handleMapNodeClick(spireNode, event); |
||||||
|
} |
||||||
|
|
||||||
|
// Handle other specific buttons
|
||||||
|
this.handleSpecificButtons(target, event); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle card play clicks |
||||||
|
*/ |
||||||
|
handleCardPlay(element, event) { |
||||||
|
if (!element.classList.contains('playable')) return; |
||||||
|
|
||||||
|
const index = parseInt(element.dataset.play, 10); |
||||||
|
const card = this.root.player.hand[index]; |
||||||
|
|
||||||
|
if (!card) return; |
||||||
|
if (this.root.player.energy < card.cost) return; |
||||||
|
|
||||||
|
try { |
||||||
|
// Play sound
|
||||||
|
this.playSound('played-card.mp3'); |
||||||
|
|
||||||
|
// Use the root.play method which calls playCard internally
|
||||||
|
this.root.play(index); |
||||||
|
|
||||||
|
// Clear card selection
|
||||||
|
this.root.selectedCardIndex = null; |
||||||
|
if (window.gameModules?.render?.updateCardSelection) { |
||||||
|
window.gameModules.render.updateCardSelection(this.root); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Error playing card:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle map node clicks |
||||||
|
*/ |
||||||
|
handleMapNodeClick(element, event) { |
||||||
|
if (!element.dataset.node) return; |
||||||
|
this.root.go(element.dataset.node); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle reward card picks |
||||||
|
*/ |
||||||
|
handleRewardPick(element, event) { |
||||||
|
const idx = parseInt(element.dataset.pick, 10); |
||||||
|
this.root.takeReward(idx); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle event choice clicks |
||||||
|
*/ |
||||||
|
handleEventChoice(element, event) { |
||||||
|
const idx = parseInt(element.dataset.choice, 10); |
||||||
|
// Get the current event from the root (this will need to be accessible)
|
||||||
|
if (this.root.currentEvent && this.root.currentEvent.choices[idx]) { |
||||||
|
this.root.currentEvent.choices[idx].effect(); |
||||||
|
this.root.afterNode(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle card upgrade clicks |
||||||
|
*/ |
||||||
|
handleCardUpgrade(element, event) { |
||||||
|
const deckIndex = parseInt(element.dataset.upgrade, 10); |
||||||
|
const oldCardId = this.root.player.deck[deckIndex]; |
||||||
|
|
||||||
|
// Find the upgraded version and replace it
|
||||||
|
const { CARDS } = window.gameModules?.cards || {}; |
||||||
|
if (CARDS && CARDS[oldCardId]?.upgrades) { |
||||||
|
this.root.player.deck[deckIndex] = CARDS[oldCardId].upgrades; |
||||||
|
this.root.log(`Upgraded ${CARDS[oldCardId].name} to ${CARDS[CARDS[oldCardId].upgrades].name}`); |
||||||
|
this.root.afterNode(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle shop card purchases |
||||||
|
*/ |
||||||
|
handleShopCardBuy(element, event) { |
||||||
|
const idx = parseInt(element.dataset.buyCard, 10); |
||||||
|
// This will need access to the current shop cards
|
||||||
|
if (this.root.currentShopCards && this.root.currentShopCards[idx]) { |
||||||
|
const card = this.root.currentShopCards[idx]; |
||||||
|
if (this.root.player.gold >= 50) { |
||||||
|
this.root.player.gold -= 50; |
||||||
|
this.root.player.deck.push(card.id); |
||||||
|
this.root.log(`Bought ${card.name} for 50 gold.`); |
||||||
|
element.disabled = true; |
||||||
|
element.textContent = "SOLD"; |
||||||
|
|
||||||
|
// Update gold display
|
||||||
|
const goldDisplay = this.root.app.querySelector('.gold-amount'); |
||||||
|
if (goldDisplay) { |
||||||
|
goldDisplay.textContent = this.root.player.gold; |
||||||
|
} |
||||||
|
} else { |
||||||
|
this.root.log("Not enough gold!"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle relic selection |
||||||
|
*/ |
||||||
|
handleRelicSelection(element, event) { |
||||||
|
const relicId = element.dataset.relic; |
||||||
|
this.root.selectStartingRelic(relicId); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle action buttons (like show-messages, end) |
||||||
|
*/ |
||||||
|
handleActionButton(element, event) { |
||||||
|
const action = element.dataset.action; |
||||||
|
|
||||||
|
switch (action) { |
||||||
|
case 'show-messages': |
||||||
|
this.handleShowMessages(); |
||||||
|
break; |
||||||
|
case 'end': |
||||||
|
this.handleEndTurn(element, event); |
||||||
|
break; |
||||||
|
default: |
||||||
|
console.warn(`Unknown action: ${action}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle rest screen actions |
||||||
|
*/ |
||||||
|
handleRestAction(element, event) { |
||||||
|
const action = element.dataset.act; |
||||||
|
|
||||||
|
switch (action) { |
||||||
|
case 'heal': |
||||||
|
const heal = Math.floor(this.root.player.maxHp * 0.2); |
||||||
|
this.root.player.hp = Math.min(this.root.player.maxHp, this.root.player.hp + heal); |
||||||
|
this.root.log(`Healed for ${heal} HP.`); |
||||||
|
this.root.afterNode(); |
||||||
|
break; |
||||||
|
case 'upgrade': |
||||||
|
// This will need to call renderUpgrade
|
||||||
|
if (window.gameModules?.render?.renderUpgrade) { |
||||||
|
window.gameModules.render.renderUpgrade(this.root); |
||||||
|
} |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle end turn button |
||||||
|
*/ |
||||||
|
handleEndTurn(element, event) { |
||||||
|
try { |
||||||
|
this.root.end(); |
||||||
|
|
||||||
|
// Clear card selection
|
||||||
|
this.root.selectedCardIndex = null; |
||||||
|
if (window.gameModules?.render?.updateCardSelection) { |
||||||
|
window.gameModules.render.updateCardSelection(this.root); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Error ending turn:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle specific buttons that don't use data attributes |
||||||
|
*/ |
||||||
|
handleSpecificButtons(element, event) { |
||||||
|
// Skip reward button
|
||||||
|
if (element.dataset.skip !== undefined) { |
||||||
|
if (this.root._pendingChoices) { |
||||||
|
this.root.skipReward(); |
||||||
|
} else { |
||||||
|
this.root.afterNode(); |
||||||
|
} |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Reset button
|
||||||
|
if (element.dataset.reset !== undefined) { |
||||||
|
this.root.clearSave(); |
||||||
|
this.root.reset(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Replay button
|
||||||
|
if (element.dataset.replay !== undefined) { |
||||||
|
this.root.reset(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Menu button
|
||||||
|
if (element.dataset.menu !== undefined) { |
||||||
|
this.root.reset(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Restart Act 2 button
|
||||||
|
if (element.dataset.restartAct2 !== undefined) { |
||||||
|
if (this.root.loadAct2Checkpoint) { |
||||||
|
this.root.loadAct2Checkpoint().then(() => { |
||||||
|
if (window.gameModules?.render?.renderMap) { |
||||||
|
window.gameModules.render.renderMap(this.root); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Buy relic button
|
||||||
|
if (element.dataset.buyRelic !== undefined) { |
||||||
|
if (this.root.currentShopRelic && this.root.player.gold >= 100) { |
||||||
|
this.root.player.gold -= 100; |
||||||
|
this.root.log(`Bought ${this.root.currentShopRelic.name} for 100 gold.`); |
||||||
|
|
||||||
|
// Add relic logic here
|
||||||
|
element.disabled = true; |
||||||
|
element.textContent = "SOLD"; |
||||||
|
} else { |
||||||
|
this.root.log("Not enough gold!"); |
||||||
|
} |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Leave shop button
|
||||||
|
if (element.dataset.leave !== undefined) { |
||||||
|
this.root.afterNode(); |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle Escape key presses |
||||||
|
*/ |
||||||
|
handleEscapeKey(event) { |
||||||
|
// Close any open modals
|
||||||
|
const modals = document.querySelectorAll('.messages-modal-overlay'); |
||||||
|
modals.forEach(modal => modal.remove()); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle show messages action |
||||||
|
*/ |
||||||
|
async handleShowMessages() { |
||||||
|
try { |
||||||
|
const { getAllMessages } = await import("../data/messages.js"); |
||||||
|
const messages = getAllMessages(); |
||||||
|
|
||||||
|
const modal = document.createElement('div'); |
||||||
|
modal.className = 'messages-modal-overlay'; |
||||||
|
modal.innerHTML = ` |
||||||
|
<div class="messages-modal"> |
||||||
|
<div class="messages-modal-header"> |
||||||
|
<h2>Messages for Prime</h2> |
||||||
|
<button class="messages-close-btn" aria-label="Close">×</button> |
||||||
|
</div> |
||||||
|
<div class="messages-modal-content"> |
||||||
|
${messages.length > 0 ? messages.map((msg, index) => ` |
||||||
|
<div class="message-item"> |
||||||
|
<div class="message-from">From: ${msg.from}</div> |
||||||
|
<div class="message-text">${msg.message}</div> |
||||||
|
</div> |
||||||
|
`).join('') : ` |
||||||
|
<div class="no-messages-placeholder"> |
||||||
|
<p>No messages added yet!</p> |
||||||
|
<p>Add your birthday messages to <code>src/data/messages.js</code></p> |
||||||
|
</div> |
||||||
|
`}
|
||||||
|
</div> |
||||||
|
</div> |
||||||
|
`;
|
||||||
|
|
||||||
|
// Close functionality
|
||||||
|
const closeModal = () => modal.remove(); |
||||||
|
|
||||||
|
const closeBtn = modal.querySelector('.messages-close-btn'); |
||||||
|
closeBtn.addEventListener('click', closeModal); |
||||||
|
|
||||||
|
// Close on overlay click
|
||||||
|
modal.addEventListener('click', (e) => { |
||||||
|
if (e.target === modal) closeModal(); |
||||||
|
}); |
||||||
|
|
||||||
|
document.body.appendChild(modal); |
||||||
|
} catch (error) { |
||||||
|
console.error('Error showing messages:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Setup card hover sound effects |
||||||
|
*/ |
||||||
|
setupCardHoverSounds() { |
||||||
|
// This will be called after battle screen renders
|
||||||
|
this.root.app.querySelectorAll("[data-play]").forEach(btn => { |
||||||
|
if (!btn.dataset.hoverSetup) { |
||||||
|
btn.addEventListener("mouseenter", () => { |
||||||
|
if (btn.classList.contains('playable')) { |
||||||
|
this.playSound('swipe.mp3'); |
||||||
|
} |
||||||
|
}); |
||||||
|
btn.dataset.hoverSetup = 'true'; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Play sound utility |
||||||
|
*/ |
||||||
|
playSound(soundFile) { |
||||||
|
try { |
||||||
|
const audio = new Audio(`assets/sounds/${soundFile}`); |
||||||
|
audio.volume = 0.3; |
||||||
|
audio.play().catch(() => {}); // Ignore audio play failures
|
||||||
|
} catch (e) { |
||||||
|
// Ignore audio errors
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clean up all event listeners |
||||||
|
*/ |
||||||
|
cleanup() { |
||||||
|
// Remove global listeners
|
||||||
|
if (this.globalHandlers.has('keydown')) { |
||||||
|
document.removeEventListener('keydown', this.handleGlobalKeydown); |
||||||
|
} |
||||||
|
if (this.globalHandlers.has('click')) { |
||||||
|
document.removeEventListener('click', this.handleGlobalClick); |
||||||
|
} |
||||||
|
|
||||||
|
this.globalHandlers.clear(); |
||||||
|
this.activeHandlers.clear(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Simple audio utility function (moved from render.js)
|
||||||
|
function playSound(soundFile) { |
||||||
|
try { |
||||||
|
const audio = new Audio(`assets/sounds/${soundFile}`); |
||||||
|
audio.volume = 0.3; |
||||||
|
audio.play().catch(() => {}); // Ignore failures in restrictive environments
|
||||||
|
} catch (e) { |
||||||
|
// Audio not supported or file missing, ignore
|
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,274 @@ |
|||||||
|
/** |
||||||
|
* Clean render functions without event handling logic |
||||||
|
* Event handling is now managed by the EventHandler class |
||||||
|
*/ |
||||||
|
|
||||||
|
// Simple audio utility (kept for compatibility)
|
||||||
|
function playSound(soundFile) { |
||||||
|
try { |
||||||
|
const audio = new Audio(`assets/sounds/${soundFile}`); |
||||||
|
audio.volume = 0.3; |
||||||
|
audio.play().catch(e => { console.log(e) }); |
||||||
|
} catch (e) { |
||||||
|
// Silently fail if audio not available
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function showDamageNumber(damage, target, isPlayer = false) { |
||||||
|
const targetElement = isPlayer ? |
||||||
|
document.querySelector('.player-battle-zone') : |
||||||
|
document.querySelector('.enemy-battle-zone'); |
||||||
|
|
||||||
|
if (!targetElement) return; |
||||||
|
|
||||||
|
const damageNumber = document.createElement('div'); |
||||||
|
damageNumber.className = 'damage-number'; |
||||||
|
damageNumber.textContent = damage; |
||||||
|
|
||||||
|
const rect = targetElement.getBoundingClientRect(); |
||||||
|
damageNumber.style.left = `${rect.left + rect.width / 2}px`; |
||||||
|
damageNumber.style.top = `${rect.top + rect.height / 2}px`; |
||||||
|
|
||||||
|
document.body.appendChild(damageNumber); |
||||||
|
|
||||||
|
requestAnimationFrame(() => { |
||||||
|
damageNumber.classList.add('damage-number-animate'); |
||||||
|
}); |
||||||
|
|
||||||
|
setTimeout(() => { |
||||||
|
if (damageNumber.parentNode) { |
||||||
|
damageNumber.parentNode.removeChild(damageNumber); |
||||||
|
} |
||||||
|
}, 1000); |
||||||
|
} |
||||||
|
|
||||||
|
export async function renderBattleClean(root) { |
||||||
|
const app = root.app; |
||||||
|
const p = root.player, e = root.enemy; |
||||||
|
|
||||||
|
const { ENEMIES } = await import("../data/enemies.js"); |
||||||
|
const { CARDS } = await import("../data/cards.js"); |
||||||
|
const { RELICS } = await import("../data/relics.js"); |
||||||
|
const enemyData = ENEMIES[e.id]; |
||||||
|
const backgroundImage = enemyData?.background || null; |
||||||
|
|
||||||
|
const intentInfo = { |
||||||
|
attack: { emoji: '', text: `Will attack for ${e.intent.value} damage`, color: 'danger' }, |
||||||
|
block: { emoji: '', text: `Will gain ${e.intent.value} block`, color: 'info' }, |
||||||
|
debuff: { emoji: '', text: 'Will apply a debuff', color: 'warning' }, |
||||||
|
heal: { emoji: '', text: `Will heal for ${e.intent.value} HP`, color: 'success' } |
||||||
|
}[e.intent.type] || { emoji: '', text: 'Unknown intent', color: 'neutral' }; |
||||||
|
|
||||||
|
app.innerHTML = ` |
||||||
|
<div class="battle-scene"> |
||||||
|
<!-- Battle Arena with background --> |
||||||
|
<div class="battle-arena" ${backgroundImage ? `style="background-image: url('${backgroundImage}'); background-size: cover; background-position: center; background-repeat: no-repeat;"` : ''}> |
||||||
|
|
||||||
|
<!-- Enemy Section --> |
||||||
|
<div class="enemy-battle-zone"> |
||||||
|
<div class="enemy-container"> |
||||||
|
<div class="enemy-character"> |
||||||
|
<div class="enemy-sprite"> |
||||||
|
<div class="enemy-avatar">${getEnemyArt(e.id, ENEMIES)}</div> |
||||||
|
<div class="enemy-shadow"></div> |
||||||
|
${e.block > 0 ? `<div class="shield-effect"><img src="assets/card-art/shield.png" alt="Shield" class="shield-effect-img"></div>` : ''} |
||||||
|
${e.weak > 0 ? `<div class="debuff-effect"><img src="assets/card-art/heart_damaged.png" alt="Weak" class="debuff-effect-img"></div>` : ''} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="enemy-ui-panel"> |
||||||
|
<div class="enemy-nameplate"> |
||||||
|
<h2 class="enemy-title">${e.name}</h2> |
||||||
|
<div class="enemy-level">${getEnemyType(e.id)}</div> |
||||||
|
</div> |
||||||
|
<div class="enemy-health-section"> |
||||||
|
<div class="health-bar-container"> |
||||||
|
<div class="health-bar enemy-health"> |
||||||
|
<div class="health-fill" style="width: ${(e.hp / e.maxHp) * 100}%"></div> |
||||||
|
<div class="health-text">${e.hp} / ${e.maxHp}</div> |
||||||
|
<div class="health-glow"></div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
${e.block > 0 ? ` |
||||||
|
<div class="status-effect block-status"> |
||||||
|
<img src="assets/card-art/shield.png" alt="Block" class="status-icon-img"> |
||||||
|
<span class="status-value">${e.block}</span> |
||||||
|
<span class="status-label">Block</span> |
||||||
|
</div> |
||||||
|
` : ''}
|
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="intent-panel intent-${intentInfo.color}"> |
||||||
|
<div class="intent-header"> |
||||||
|
<span class="intent-label">Next Action</span> |
||||||
|
</div> |
||||||
|
<div class="intent-content"> |
||||||
|
<div class="intent-icon-large">${intentInfo.emoji}</div> |
||||||
|
<div class="intent-description">${intentInfo.text}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Player Section --> |
||||||
|
<div class="player-battle-zone"> |
||||||
|
<div class="player-container"> |
||||||
|
<div class="player-character"> |
||||||
|
<div class="player-sprite"> |
||||||
|
<div class="player-avatar"> |
||||||
|
<img src="assets/prime.webp" alt="Prime" class="player-avatar-img" /> |
||||||
|
</div> |
||||||
|
<div class="player-shadow"></div> |
||||||
|
${p.block > 0 ? `<div class="shield-effect"><img src="assets/card-art/shield.png" alt="Shield" class="shield-effect-img"></div>` : ''} |
||||||
|
${p.weak > 0 ? `<div class="debuff-effect"><img src="assets/card-art/heart_damaged.png" alt="Weak" class="debuff-effect-img"></div>` : ''} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="player-ui-panel"> |
||||||
|
<div class="player-nameplate"> |
||||||
|
<h2 class="player-title">ThePrimeagen</h2> |
||||||
|
<div class="player-level">PLAYER</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="player-health-section"> |
||||||
|
<div class="health-bar-container"> |
||||||
|
<div class="health-bar player-health"> |
||||||
|
<div class="health-fill" style="width: ${(p.hp / p.maxHp) * 100}%"></div> |
||||||
|
<div class="health-text">${p.hp} / ${p.maxHp}</div> |
||||||
|
<div class="health-glow"></div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
${p.block > 0 ? ` |
||||||
|
<div class="status-effect block-status"> |
||||||
|
<img src="assets/card-art/shield.png" alt="Block" class="status-icon-img"> |
||||||
|
<span class="status-value">${p.block}</span> |
||||||
|
<span class="status-label">Block</span> |
||||||
|
</div> |
||||||
|
` : ''}
|
||||||
|
${p.weak > 0 ? ` |
||||||
|
<div class="status-effect weak-status"> |
||||||
|
<img src="assets/card-art/heart_damaged.png" alt="Weak" class="status-icon-img"> |
||||||
|
<span class="status-value">${p.weak}</span> |
||||||
|
<span class="status-label">Weak</span> |
||||||
|
</div> |
||||||
|
` : ''}
|
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="player-energy-section"> |
||||||
|
<div class="energy-display"> |
||||||
|
<span class="energy-label">⚡</span> |
||||||
|
<div class="energy-orbs"> |
||||||
|
${Array.from({ length: p.maxEnergy }, (_, i) => |
||||||
|
`<div class="energy-orb ${i < p.energy ? 'active' : 'inactive'}"></div>` |
||||||
|
).join('')} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Battle Action Zone --> |
||||||
|
<div class="battle-action-zone"> |
||||||
|
<div class="hand-area"> |
||||||
|
<div class="hand-header"> |
||||||
|
<div class="deck-counters"></div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="cards-battlefield"> |
||||||
|
${p.hand.length === 0 ? |
||||||
|
'<div class="no-cards-message">🎴 No cards in hand - End turn to draw new cards</div>' : |
||||||
|
p.hand.map((card, i) => { |
||||||
|
const canPlay = p.energy >= card.cost; |
||||||
|
const cardType = card.type === 'attack' ? 'attack' : card.type === 'skill' ? 'skill' : 'power'; |
||||||
|
return ` |
||||||
|
<div class="battle-card ${cardType} ${!canPlay ? 'unplayable' : 'playable'}" data-play="${i}"> |
||||||
|
<div class="card-glow"></div> |
||||||
|
<div class="card-frame"> |
||||||
|
<div class="card-header-row"> |
||||||
|
<div class="card-title">${card.name}</div> |
||||||
|
<div class="card-cost-orb ${!canPlay ? 'insufficient' : ''}">${card.cost}</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="card-artwork"> |
||||||
|
<div class="card-art-icon">${getCardArt(card.id, CARDS)}</div> |
||||||
|
<div class="card-type-badge ${cardType}">${card.type}</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="card-description-box"> |
||||||
|
<div class="card-text">${card.text}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
${!canPlay ? `<div class="card-disabled-overlay"><span>Need ${card.cost} energy</span></div>` : ''} |
||||||
|
</div> |
||||||
|
`;
|
||||||
|
}).join('') |
||||||
|
} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="hand-controls"> |
||||||
|
<button class="end-turn-btn" data-action="end"> |
||||||
|
<span class="end-turn-text">End Turn</span> |
||||||
|
<span class="end-turn-hotkey">E</span> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Fight Log Panel --> |
||||||
|
<div class="fight-log-panel"> |
||||||
|
<div class="fight-log-header"> |
||||||
|
<span class="fight-log-title">Combat Log</span> |
||||||
|
</div> |
||||||
|
<div class="fight-log-content" id="fight-log-content"> |
||||||
|
${root.logs.slice(-20).map(log => `<div class="log-entry">${log}</div>`).join('')} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
`;
|
||||||
|
|
||||||
|
// Initialize card selection state if not exists
|
||||||
|
if (root.selectedCardIndex === undefined) { |
||||||
|
root.selectedCardIndex = null; |
||||||
|
} |
||||||
|
|
||||||
|
// Auto-scroll fight log to bottom
|
||||||
|
const logContent = document.getElementById('fight-log-content'); |
||||||
|
if (logContent) { |
||||||
|
logContent.scrollTop = logContent.scrollHeight; |
||||||
|
} |
||||||
|
|
||||||
|
// Note: Event handling is now managed by EventHandler class
|
||||||
|
// No addEventListener calls needed here!
|
||||||
|
} |
||||||
|
|
||||||
|
// Utility functions (kept for compatibility)
|
||||||
|
function getRelicArt(relicId, RELICS = null) { |
||||||
|
if (RELICS && RELICS[relicId]?.art) { |
||||||
|
const imagePath = RELICS[relicId].art; |
||||||
|
return `<img src="assets/skill-art/${imagePath}" alt="${relicId}" class="relic-skill-art">`; |
||||||
|
} |
||||||
|
return '💎'; |
||||||
|
} |
||||||
|
|
||||||
|
function getCardArt(cardId, CARDS = null) { |
||||||
|
if (CARDS && CARDS[cardId]?.art) { |
||||||
|
const imagePath = CARDS[cardId].art; |
||||||
|
return `<img src="assets/skill-art/${imagePath}" alt="${cardId}" class="card-art-image">`; |
||||||
|
} |
||||||
|
return `<span>🃏</span>`; |
||||||
|
} |
||||||
|
|
||||||
|
function getEnemyArt(enemyId, ENEMIES = null) { |
||||||
|
const enemyData = ENEMIES?.[enemyId]; |
||||||
|
const avatarPath = enemyData?.avatar || `assets/avatars/${enemyId}.png`; |
||||||
|
return `<img src="${avatarPath}" alt="${enemyId}" class="enemy-avatar-img">`; |
||||||
|
} |
||||||
|
|
||||||
|
function getEnemyType(enemyId) { |
||||||
|
if (enemyId.includes('boss_')) return 'BOSS'; |
||||||
|
if (enemyId.includes('elite_')) return 'ELITE'; |
||||||
|
return 'ENEMY'; |
||||||
|
} |
||||||
Loading…
Reference in new issue