Spaces:
Running
Running
| 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"); | |
| // Slow-specific UI (only visible for slow towers) | |
| this.tSlowLabelEl = document.getElementById("t_slow_label"); | |
| this.tSlowEl = document.getElementById("t_slow"); | |
| // 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); | |
| // Show slow percentage for slow towers; hide for others | |
| if (tower.type === "slow" && this.tSlowEl && this.tSlowLabelEl) { | |
| const mult = tower.projectileEffect?.mult ?? 1.0; | |
| // Convert to percentage slow (e.g., mult 0.75 => 25% slow) | |
| const pct = Math.max(0, Math.min(100, Math.round((1 - mult) * 100))); | |
| this.tSlowEl.textContent = `${pct}%`; | |
| this.tSlowLabelEl.style.display = ""; | |
| this.tSlowEl.style.display = ""; | |
| } else if (this.tSlowEl && this.tSlowLabelEl) { | |
| this.tSlowLabelEl.style.display = "none"; | |
| this.tSlowEl.style.display = "none"; | |
| } | |
| 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); | |
| } | |
| } | |