Browse Source

refactor

main
Stephanie Gredell 4 months ago
parent
commit
339f7a2dbe
  1. 589
      src/engine/events.js
  2. 466
      src/input/InputManager.js
  3. 17
      src/main.js
  4. 274
      src/ui/render-clean.js
  5. 74
      src/ui/render.js

589
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 = `
<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();
}
}

466
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 = `
<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
}
}

17
src/main.js

@ -4,7 +4,8 @@ import { ENEMIES } from "./data/enemies.js";
import { MAPS } from "./data/maps.js"; import { MAPS } from "./data/maps.js";
import { makePlayer, initDeck, draw } from "./engine/core.js"; import { makePlayer, initDeck, draw } from "./engine/core.js";
import { createBattle, startPlayerTurn, playCard, endTurn, makeBattleContext, attachRelics } from "./engine/battle.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"); const app = document.getElementById("app");
@ -16,6 +17,10 @@ const root = {
relicStates: [], relicStates: [],
completedNodes: [], completedNodes: [],
enemy: null, 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); }, log(m) { this.logs.push(m); this.logs = this.logs.slice(-200); },
async render() { await renderBattle(this); }, 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(); initializeGame();

274
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 = `
<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';
}

74
src/ui/render.js

@ -288,39 +288,10 @@ export async function renderBattle(root) {
</div> </div>
`; `;
app.querySelectorAll("[data-play]").forEach(btn => { // Event listeners are now handled by InputManager
btn.addEventListener("mouseenter", () => { // Set up card hover sounds through InputManager
if (btn.classList.contains('playable')) { if (root.inputManager) {
playSound('swipe.mp3'); root.inputManager.setupCardHoverSounds();
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);
}
});
} }
// Initialize card selection state if not exists // Initialize card selection state if not exists
@ -676,16 +647,7 @@ But cake lies ahead at the top of the Spire. </p>
</div> </div>
`; `;
root.app.querySelectorAll("[data-node]").forEach(el => { // Event listeners are now handled by InputManager
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());
}
window.showTooltip = function(event) { window.showTooltip = function(event) {
const tooltip = document.getElementById('custom-tooltip'); const tooltip = document.getElementById('custom-tooltip');
@ -735,11 +697,7 @@ But cake lies ahead at the top of the Spire. </p>
}; };
const resetBtn = root.app.querySelector("[data-reset]"); // Event listeners are now handled by InputManager
resetBtn.addEventListener("click", () => {
root.clearSave();
root.reset();
});
} }
export async function renderReward(root, choices) { export async function renderReward(root, choices) {
@ -781,13 +739,7 @@ export async function renderReward(root, choices) {
</div> </div>
</div> </div>
`; `;
root.app.querySelectorAll("[data-pick]").forEach(btn => { // Event listeners are now handled by InputManager
btn.addEventListener("click", () => {
const idx = parseInt(btn.dataset.pick, 10);
root.takeReward(idx);
});
});
root.app.querySelector("[data-skip]").addEventListener("click", () => root.skipReward());
} }
export async function renderRest(root) { export async function renderRest(root) {
@ -822,15 +774,7 @@ export async function renderRest(root) {
</div> </div>
</div> </div>
`; `;
root.app.querySelector("[data-act='heal']").addEventListener("click", () => { // Event listeners are now handled by InputManager
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);
});
} }
export function renderUpgrade(root) { export function renderUpgrade(root) {
@ -1112,7 +1056,7 @@ export function renderShop(root) {
}); });
} }
function updateCardSelection(root) { export function updateCardSelection(root) {
// Remove selection from all cards // Remove selection from all cards
root.app.querySelectorAll('.battle-card').forEach(card => { root.app.querySelectorAll('.battle-card').forEach(card => {
card.classList.remove('card-selected'); card.classList.remove('card-selected');

Loading…
Cancel
Save