diff --git a/src/engine/events.js b/src/engine/events.js new file mode 100644 index 0000000..169e085 --- /dev/null +++ b/src/engine/events.js @@ -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 = ` +
+
+ Enemy Avatar +
+
${content}
+
+ `; + } 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 = ` +
+
+

Messages for Prime

+ +
+
+ ${messages.length > 0 ? messages.map((msg, index) => ` +
+
From: ${msg.from}
+
${msg.message}
+
+ `).join('') : ` +
+

No messages added yet!

+

Add your birthday messages to src/data/messages.js

+
+ `} +
+
+ `; + + 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(); + } +} \ No newline at end of file diff --git a/src/input/InputManager.js b/src/input/InputManager.js new file mode 100644 index 0000000..19a899e --- /dev/null +++ b/src/input/InputManager.js @@ -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 = ` +
+
+

Messages for Prime

+ +
+
+ ${messages.length > 0 ? messages.map((msg, index) => ` +
+
From: ${msg.from}
+
${msg.message}
+
+ `).join('') : ` +
+

No messages added yet!

+

Add your birthday messages to src/data/messages.js

+
+ `} +
+
+ `; + + // 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 + } +} diff --git a/src/main.js b/src/main.js index 8d44a2b..efb0e46 100644 --- a/src/main.js +++ b/src/main.js @@ -4,7 +4,8 @@ import { ENEMIES } from "./data/enemies.js"; import { MAPS } from "./data/maps.js"; import { makePlayer, initDeck, draw } from "./engine/core.js"; import { createBattle, startPlayerTurn, playCard, endTurn, makeBattleContext, attachRelics } from "./engine/battle.js"; -import { renderBattle, renderMap, renderReward, renderRest, renderShop, renderWin, renderLose, renderEvent, renderRelicSelection, showDamageNumber } from "./ui/render.js"; +import { renderBattle, renderMap, renderReward, renderRest, renderShop, renderWin, renderLose, renderEvent, renderRelicSelection, renderUpgrade, updateCardSelection, showDamageNumber } from "./ui/render.js"; +import { InputManager } from "./input/InputManager.js"; const app = document.getElementById("app"); @@ -16,6 +17,10 @@ const root = { relicStates: [], completedNodes: [], enemy: null, + inputManager: null, // Will be initialized later + currentEvent: null, // For event handling + currentShopCards: null, // For shop handling + currentShopRelic: null, // For shop relic handling log(m) { this.logs.push(m); this.logs = this.logs.slice(-200); }, async render() { await renderBattle(this); }, @@ -536,5 +541,15 @@ async function loadNormalGame() { } } +// Initialize InputManager +root.inputManager = new InputManager(root); +root.inputManager.initGlobalListeners(); + +// Make modules available globally for InputManager +window.gameModules = { + cards: { CARDS }, + render: { renderMap, renderUpgrade, updateCardSelection } +}; + initializeGame(); diff --git a/src/ui/render-clean.js b/src/ui/render-clean.js new file mode 100644 index 0000000..52bfda1 --- /dev/null +++ b/src/ui/render-clean.js @@ -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 = ` +
+ +
+ + +
+
+
+
+
${getEnemyArt(e.id, ENEMIES)}
+
+ ${e.block > 0 ? `
Shield
` : ''} + ${e.weak > 0 ? `
Weak
` : ''} +
+
+ +
+
+

${e.name}

+
${getEnemyType(e.id)}
+
+
+
+
+
+
${e.hp} / ${e.maxHp}
+
+
+
+ ${e.block > 0 ? ` +
+ Block + ${e.block} + Block +
+ ` : ''} +
+ +
+
+ Next Action +
+
+
${intentInfo.emoji}
+
${intentInfo.text}
+
+
+
+
+
+ + +
+
+
+
+
+ Prime +
+
+ ${p.block > 0 ? `
Shield
` : ''} + ${p.weak > 0 ? `
Weak
` : ''} +
+
+ +
+
+

ThePrimeagen

+
PLAYER
+
+ +
+
+
+
+
${p.hp} / ${p.maxHp}
+
+
+
+ ${p.block > 0 ? ` +
+ Block + ${p.block} + Block +
+ ` : ''} + ${p.weak > 0 ? ` +
+ Weak + ${p.weak} + Weak +
+ ` : ''} +
+ +
+
+ +
+ ${Array.from({ length: p.maxEnergy }, (_, i) => + `
` + ).join('')} +
+
+
+
+
+
+
+ + +
+
+
+
+
+ +
+ ${p.hand.length === 0 ? + '
🎴 No cards in hand - End turn to draw new cards
' : + p.hand.map((card, i) => { + const canPlay = p.energy >= card.cost; + const cardType = card.type === 'attack' ? 'attack' : card.type === 'skill' ? 'skill' : 'power'; + return ` +
+
+
+
+
${card.name}
+
${card.cost}
+
+ +
+
${getCardArt(card.id, CARDS)}
+
${card.type}
+
+ +
+
${card.text}
+
+
+ ${!canPlay ? `
Need ${card.cost} energy
` : ''} +
+ `; + }).join('') + } +
+ +
+ +
+
+
+ + +
+
+ Combat Log +
+
+ ${root.logs.slice(-20).map(log => `
${log}
`).join('')} +
+
+
+ `; + + // 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 `${relicId}`; + } + return '💎'; +} + +function getCardArt(cardId, CARDS = null) { + if (CARDS && CARDS[cardId]?.art) { + const imagePath = CARDS[cardId].art; + return `${cardId}`; + } + return `🃏`; +} + +function getEnemyArt(enemyId, ENEMIES = null) { + const enemyData = ENEMIES?.[enemyId]; + const avatarPath = enemyData?.avatar || `assets/avatars/${enemyId}.png`; + return `${enemyId}`; +} + +function getEnemyType(enemyId) { + if (enemyId.includes('boss_')) return 'BOSS'; + if (enemyId.includes('elite_')) return 'ELITE'; + return 'ENEMY'; +} \ No newline at end of file diff --git a/src/ui/render.js b/src/ui/render.js index 4dde043..1380a9b 100644 --- a/src/ui/render.js +++ b/src/ui/render.js @@ -288,39 +288,10 @@ export async function renderBattle(root) { `; - app.querySelectorAll("[data-play]").forEach(btn => { - btn.addEventListener("mouseenter", () => { - if (btn.classList.contains('playable')) { - playSound('swipe.mp3'); - root.selectedCardIndex = null; - updateCardSelection(root); - } - }); - - btn.addEventListener("click", () => { - const index = parseInt(btn.dataset.play, 10); - const card = p.hand[index]; - if (p.energy >= card.cost) { - playSound('played-card.mp3') - root.play(index); - // Clear selection when card is played via mouse - root.selectedCardIndex = null; - updateCardSelection(root); - } - }); - }); - - const endTurnBtn = app.querySelector("[data-action='end']"); - if (endTurnBtn) { - - endTurnBtn.addEventListener("click", () => { - - try { - root.end(); - } catch (error) { - console.error("Error ending turn:", error); - } - }); + // Event listeners are now handled by InputManager + // Set up card hover sounds through InputManager + if (root.inputManager) { + root.inputManager.setupCardHoverSounds(); } // Initialize card selection state if not exists @@ -676,16 +647,7 @@ But cake lies ahead at the top of the Spire.

`; - root.app.querySelectorAll("[data-node]").forEach(el => { - if (!el.dataset.node) return; - el.addEventListener("click", () => root.go(el.dataset.node)); - }); - - // Add Messages button event listener - const messagesBtn = root.app.querySelector("[data-action='show-messages']"); - if (messagesBtn) { - messagesBtn.addEventListener("click", () => showMessagesModal()); - } + // Event listeners are now handled by InputManager window.showTooltip = function(event) { const tooltip = document.getElementById('custom-tooltip'); @@ -735,11 +697,7 @@ But cake lies ahead at the top of the Spire.

}; - const resetBtn = root.app.querySelector("[data-reset]"); - resetBtn.addEventListener("click", () => { - root.clearSave(); - root.reset(); - }); + // Event listeners are now handled by InputManager } export async function renderReward(root, choices) { @@ -781,13 +739,7 @@ export async function renderReward(root, choices) { `; - root.app.querySelectorAll("[data-pick]").forEach(btn => { - btn.addEventListener("click", () => { - const idx = parseInt(btn.dataset.pick, 10); - root.takeReward(idx); - }); - }); - root.app.querySelector("[data-skip]").addEventListener("click", () => root.skipReward()); + // Event listeners are now handled by InputManager } export async function renderRest(root) { @@ -822,15 +774,7 @@ export async function renderRest(root) { `; - root.app.querySelector("[data-act='heal']").addEventListener("click", () => { - const heal = Math.floor(root.player.maxHp * 0.2); - root.player.hp = Math.min(root.player.maxHp, root.player.hp + heal); - root.log(`Rested: +${heal} HP`); - root.afterNode(); - }); - root.app.querySelector("[data-act='upgrade']").addEventListener("click", () => { - renderUpgrade(root); - }); + // Event listeners are now handled by InputManager } export function renderUpgrade(root) { @@ -1112,7 +1056,7 @@ export function renderShop(root) { }); } -function updateCardSelection(root) { +export function updateCardSelection(root) { // Remove selection from all cards root.app.querySelectorAll('.battle-card').forEach(card => { card.classList.remove('card-selected');