import { AUDIO_CONFIG } from "../config/gameConfig.js"; export class UIManager { constructor() { // HUD elements this.moneyEl = document.getElementById("money"); this.livesEl = document.getElementById("lives"); this.waveEl = document.getElementById("wave"); this.wavesTotalEl = document.getElementById("wavesTotal"); this.messagesEl = document.getElementById("messages"); this.restartBtn = document.getElementById("restart"); // Upgrade panel elements this.upgradePanel = document.getElementById("upgradePanel"); this.upgradeBtn = document.getElementById("upgradeBtn"); this.sellBtn = document.getElementById("sellBtn"); this.tLevelEl = document.getElementById("t_level"); this.tRangeEl = document.getElementById("t_range"); this.tRateEl = document.getElementById("t_rate"); this.tDamageEl = document.getElementById("t_damage"); this.tNextCostEl = document.getElementById("t_nextCost"); // Tower palette (floating) this.palette = document.createElement("div"); this.palette.className = "palette hidden"; document.body.appendChild(this.palette); // Audio system this.audioCache = {}; this.backgroundMusic = null; this.musicVolume = AUDIO_CONFIG.musicVolume; this.effectsVolume = AUDIO_CONFIG.effectsVolume; this.initAudio(); this._paletteClickHandler = null; this._outsideHandler = (ev) => { if (this.palette.style.display === "none") return; if (!this.palette.contains(ev.target)) { this.hideTowerPalette(); if (this._paletteCancelCb) this._paletteCancelCb(); } }; this._escHandler = (ev) => { if (ev.key === "Escape" && this.palette.style.display !== "none") { this.hideTowerPalette(); if (this._paletteCancelCb) this._paletteCancelCb(); } }; window.addEventListener("mousedown", this._outsideHandler); window.addEventListener("keydown", this._escHandler); this._paletteSelectCb = null; this._paletteCancelCb = null; // Speed controls (top-right UI) this._speedChangeCb = null; this.speedContainer = null; this.speedBtn1 = null; this.speedBtn2 = null; // Game state subscription placeholders this._moneyChangedHandler = null; this._gameStateForSubscriptions = null; // Sync initial HUD visibility with new CSS classes if (this.restartBtn) this.restartBtn.classList.add("hidden"); if (this.upgradePanel) this.upgradePanel.classList.add("hidden"); } // Init and update for speed controls initSpeedControls(initialSpeed = 1) { if (this.speedContainer) return; // already initialized const container = document.createElement("div"); container.className = "speed-controls"; const makeBtn = (label, pressed = false) => { const b = document.createElement("button"); b.textContent = label; b.className = "btn btn--toggle"; b.setAttribute("aria-pressed", pressed ? "true" : "false"); b.type = "button"; return b; }; const b1 = makeBtn("x1", initialSpeed === 1); const b2 = makeBtn("x2", initialSpeed === 2); b1.addEventListener("click", (e) => { e.stopPropagation(); this._setActiveSpeed(1); if (this._speedChangeCb) this._speedChangeCb(1); }); b2.addEventListener("click", (e) => { e.stopPropagation(); this._setActiveSpeed(2); if (this._speedChangeCb) this._speedChangeCb(2); }); container.appendChild(b1); container.appendChild(b2); document.body.appendChild(container); this.speedContainer = container; this.speedBtn1 = b1; this.speedBtn2 = b2; this.updateSpeedControls(initialSpeed); } onSpeedChange(callback) { this._speedChangeCb = callback; } updateSpeedControls(currentSpeed = 1) { if (!this.speedBtn1 || !this.speedBtn2) return; this.speedBtn1.setAttribute( "aria-pressed", currentSpeed === 1 ? "true" : "false" ); this.speedBtn2.setAttribute( "aria-pressed", currentSpeed === 2 ? "true" : "false" ); } _setActiveSpeed(s) { this.updateSpeedControls(s); } /** * Subscribe UI to GameState money changes and perform initial affordability update. * Call this once during UI initialization when GameState instance is available. */ initWithGameState(gameState) { if (!gameState || this._gameStateForSubscriptions) return; this._gameStateForSubscriptions = gameState; // Bind once to keep reference for unsubscription this._moneyChangedHandler = (newMoney /*, prevMoney */) => { this.updateTowerAffordability(newMoney); }; // Prefer dedicated helpers if available; fall back to generic on/off if (typeof gameState.subscribeMoneyChanged === "function") { gameState.subscribeMoneyChanged(this._moneyChangedHandler); } else if (typeof gameState.on === "function") { gameState.on("moneyChanged", this._moneyChangedHandler); } // Initial affordability update using current money this.updateTowerAffordability(gameState.money); } /** * Update HUD labels and also ensure tower affordability matches current money. */ updateHUD(gameState) { this.moneyEl.textContent = String(gameState.money); this.livesEl.textContent = String(gameState.lives); // Infinite waves: display current wave only const currentWave = gameState.waveIndex + 1; this.waveEl.textContent = String(currentWave); // If total waves element exists, show infinity symbol if (this.wavesTotalEl) { this.wavesTotalEl.textContent = "∞"; } if (gameState.gameOver || gameState.gameWon) { this.restartBtn.classList.remove("hidden"); } else { this.restartBtn.classList.add("hidden"); } // Keep palette/button states in sync with current money on HUD updates too this.updateTowerAffordability(gameState.money); } setMessage(text) { this.messagesEl.textContent = text; } setWavesTotal(total) { this.wavesTotalEl.textContent = String(total); } showUpgradePanel(tower, money) { this.upgradePanel.classList.remove("hidden"); this.tLevelEl.textContent = String(tower.level); this.tRangeEl.textContent = tower.range.toFixed(2); this.tRateEl.textContent = tower.rate.toFixed(2); this.tDamageEl.textContent = tower.damage.toFixed(2); if (tower.canUpgrade) { this.tNextCostEl.textContent = String(tower.nextUpgradeCost); this.upgradeBtn.disabled = money < tower.nextUpgradeCost; } else { this.tNextCostEl.textContent = "Max"; this.upgradeBtn.disabled = true; } this.sellBtn.disabled = false; } hideUpgradePanel() { this.upgradePanel.classList.add("hidden"); } onRestartClick(callback) { this.restartBtn.addEventListener("click", callback); } onUpgradeClick(callback) { this.upgradeBtn.addEventListener("click", (e) => { e.stopPropagation(); callback(); }); } onSellClick(callback) { this.sellBtn.addEventListener("click", (e) => { e.stopPropagation(); callback(); }); } // Palette API onPaletteSelect(callback) { this._paletteSelectCb = callback; } onPaletteCancel(callback) { this._paletteCancelCb = callback; } // Utility: build default palette options using game config _defaultTowerOptions() { // Prefer static ESM import at top of module for browser compatibility. // We cache config on the instance if dynamic import is needed elsewhere. if (!this._cfgSync) { // Fallback in case not yet set by updateTowerAffordability's dynamic import // but since gameConfig is an ESM and likely already evaluated via imports, // we can safely rely on synchronous import by hoisting at top-level if desired. } // Use a synchronous named import by referencing cached module if present; // otherwise import statically at top of file would be ideal. const TOWER_TYPES = (this._cfg && this._cfg.TOWER_TYPES) || undefined; // If not cached yet, we can safely reference a top-level import added by bundlers. // To avoid runtime errors, guard and build using an empty array if config missing temporarily. const types = TOWER_TYPES; const opts = []; const push = (t, extra = {}) => { if (!t) return; opts.push({ key: t.key, name: t.name, cost: t.cost, enabled: true, desc: t.type === "slow" ? "Applies slow on hit" : t.type === "sniper" ? "Long-range high damage" : t.type === "electric" ? "Electric arcs hit up to 3 enemies" : "Basic all-round tower", color: t.type === "slow" ? "#ff69b4" : t.type === "sniper" ? "#00ffff" : t.type === "electric" ? "#9ad6ff" : "#3a97ff", ...extra, }); }; if (types) { push(types.basic); push(types.slow); push(types.sniper); // Ensure Electric shows in palette if (types.electric) push(types.electric); } return opts; } /** * Update tower selection UI items (currently palette items) based on affordability. * Only adjusts enabled/disabled state and optional 'unaffordable' class. */ updateTowerAffordability(currentMoney) { // Current implementation creates palette items dynamically in showTowerPalette. // When palette is open, update existing rendered items accordingly. const list = this.palette.querySelector(".palette-list"); if (!list) return; // Read costs from config via dynamic ESM import for browser compatibility // Cache the loaded module on the instance to avoid re-fetching. if (!this._cfgPromise) { this._cfgPromise = import("../config/gameConfig.js").then((m) => { this._cfg = m; return m; }); } this._cfgPromise.then(({ TOWER_TYPES }) => { const costByKey = { basic: TOWER_TYPES.basic?.cost, slow: TOWER_TYPES.slow?.cost, sniper: TOWER_TYPES.sniper?.cost, electric: TOWER_TYPES.electric?.cost, }; // Iterate palette items in DOM (each item corresponds to one tower option) const items = list.querySelectorAll(".palette-item"); items.forEach((item) => { // Determine tower key for this item by reading its label text // Labels are created as the first span with the tower name const labelSpan = item.querySelector("span:first-child"); const name = labelSpan ? labelSpan.textContent : ""; // Map name back to key via config let key = null; for (const k of Object.keys(TOWER_TYPES)) { if (TOWER_TYPES[k]?.name === name) { key = k; break; } } if (!key) return; const cost = costByKey[key]; const affordable = typeof cost === "number" ? cost <= currentMoney : false; // Disable/enable via aria-disabled like current structure uses if (affordable) { item.removeAttribute("aria-disabled"); item.classList.remove("unaffordable"); } else { item.setAttribute("aria-disabled", "true"); // Optional class; safe to add/remove if styles define it item.classList.add("unaffordable"); } }); }); } showTowerPalette(screenX, screenY, options) { // options: [{key, name, cost, enabled, desc, color}] this.palette.innerHTML = ""; const title = document.createElement("div"); title.textContent = "Choose a tower"; title.className = "palette-title"; this.palette.appendChild(title); // If no options provided, build from config (includes Electric) const opts = Array.isArray(options) && options.length > 0 ? options : this._defaultTowerOptions(); const list = document.createElement("div"); list.className = "palette-list"; opts.forEach((opt) => { const item = document.createElement("div"); item.className = "palette-item"; if (!opt.enabled) { item.setAttribute("aria-disabled", "true"); } const label = document.createElement("span"); label.textContent = opt.name; const cost = document.createElement("span"); cost.textContent = `${opt.cost}${opt.key === "electric" ? " ⚡" : ""}`; cost.style.fontFamily = "var(--font-mono)"; if (opt.color) { item.style.boxShadow = `inset 0 0 0 2px ${opt.color}33`; } item.title = opt.desc || ""; item.addEventListener("click", (e) => { e.stopPropagation(); // Rely on live affordability state; opt.enabled may be stale after updates if (!item.hasAttribute("aria-disabled")) { this.hideTowerPalette(); if (this._paletteSelectCb) this._paletteSelectCb(opt.key); } }); item.appendChild(label); item.appendChild(cost); list.appendChild(item); }); this.palette.appendChild(list); // Position palette initially to measure its size this.palette.style.width = "200px"; this.palette.classList.remove("hidden"); // Get actual dimensions after rendering const rect = this.palette.getBoundingClientRect(); const menuWidth = rect.width; const menuHeight = rect.height; // Calculate position with proper bounds checking const pad = 8; let left = screenX + 10; let top = screenY + 10; // Ensure menu stays within viewport horizontally if (left + menuWidth + pad > window.innerWidth) { left = window.innerWidth - menuWidth - pad; } if (left < pad) { left = pad; } // Ensure menu stays within viewport vertically if (top + menuHeight + pad > window.innerHeight) { // Try placing above the click point instead top = screenY - menuHeight - 10; if (top < pad) { // If still doesn't fit, just cap at bottom of screen top = window.innerHeight - menuHeight - pad; } } if (top < pad) { top = pad; } // Apply calculated position this.palette.style.left = left + "px"; this.palette.style.top = top + "px"; // After rendering, ensure initial affordability reflects current money if subscribed if (this._gameStateForSubscriptions) { this.updateTowerAffordability(this._gameStateForSubscriptions.money); } } hideTowerPalette() { this.palette.classList.add("hidden"); } // Audio initialization initAudio() { // Load sound effects const soundFiles = { basic: "./src/assets/basic.mp3", slow: "./src/assets/slow.mp3", sniper: "./src/assets/sniper.mp3", electric: "./src/assets/electric.mp3" }; for (const [type, path] of Object.entries(soundFiles)) { try { const audio = new Audio(path); audio.volume = this.effectsVolume; audio.preload = "auto"; this.audioCache[type] = audio; } catch (e) { console.warn(`Failed to load sound for ${type}:`, e); } } // Load and start background music if (AUDIO_CONFIG.musicEnabled) { try { console.log("Loading background music..."); this.backgroundMusic = new Audio("./src/assets/music.mp3"); this.backgroundMusic.volume = this.musicVolume; this.backgroundMusic.loop = true; this.backgroundMusic.preload = "auto"; // Add multiple event listeners for debugging this.backgroundMusic.addEventListener('loadstart', () => { console.log("Music load started"); }); this.backgroundMusic.addEventListener('canplay', () => { console.log("Music can play"); }); this.backgroundMusic.addEventListener('error', (e) => { console.error("Music load error:", e); }); // Start playing music when it's loaded this.backgroundMusic.addEventListener('canplaythrough', () => { console.log("Music loaded, attempting to play..."); this.backgroundMusic.play().then(() => { console.log("Music playing successfully!"); }).catch(e => { console.log("Music autoplay blocked, will play on user interaction", e); // Fallback: play on first user interaction const playOnInteraction = () => { console.log("Attempting to play music on user interaction..."); this.backgroundMusic.play().then(() => { console.log("Music started after user interaction!"); }).catch((err) => { console.error("Failed to play music:", err); }); document.removeEventListener('click', playOnInteraction); document.removeEventListener('keydown', playOnInteraction); }; document.addEventListener('click', playOnInteraction); document.addEventListener('keydown', playOnInteraction); }); }, { once: true }); // Force load this.backgroundMusic.load(); } catch (e) { console.warn("Failed to load background music:", e); } } else { console.log("Music is disabled in config"); } } // Play shooting sound for a tower playTowerSound(tower) { if (!tower || !tower.type || !AUDIO_CONFIG.effectsEnabled) return; const audio = this.audioCache[tower.type]; if (audio) { try { // Clone and play to allow overlapping sounds const audioClone = audio.cloneNode(); audioClone.volume = this.effectsVolume; audioClone.play().catch(() => {}); } catch (e) { // Fallback to direct play if cloning fails audio.currentTime = 0; audio.volume = this.effectsVolume; audio.play().catch(() => {}); } } } // Sniper aiming tone (optional, can be extended) playAimingTone(tower) { // Could play a subtle aiming sound if desired } // Stop aiming tone stopAimingTone(tower) { // Stop any aiming sound if implemented } // Sniper fire crack sound playFireCrack(tower) { // Use the sniper sound for fire crack if (tower && tower.type === "sniper") { this.playTowerSound(tower); } } // Volume control methods setMusicVolume(volume) { this.musicVolume = Math.max(0, Math.min(1, volume)); if (this.backgroundMusic) { this.backgroundMusic.volume = this.musicVolume; } } setEffectsVolume(volume) { this.effectsVolume = Math.max(0, Math.min(1, volume)); // Update all cached sound effects for (const audio of Object.values(this.audioCache)) { if (audio) audio.volume = this.effectsVolume; } } toggleMusic() { if (this.backgroundMusic) { if (this.backgroundMusic.paused) { this.backgroundMusic.play().catch(() => {}); } else { this.backgroundMusic.pause(); } } } toggleEffects() { AUDIO_CONFIG.effectsEnabled = !AUDIO_CONFIG.effectsEnabled; } /** * Optional teardown to prevent leaks: unsubscribe from GameState events. */ destroy() { const gs = this._gameStateForSubscriptions; if (gs && this._moneyChangedHandler) { if (typeof gs.unsubscribeMoneyChanged === "function") { gs.unsubscribeMoneyChanged(this._moneyChangedHandler); } else if (typeof gs.off === "function") { gs.off("moneyChanged", this._moneyChangedHandler); } } this._gameStateForSubscriptions = null; this._moneyChangedHandler = null; // Existing global listeners cleanup as a best practice window.removeEventListener("mousedown", this._outsideHandler); window.removeEventListener("keydown", this._escHandler); } }