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"); | |
// 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); | |
} | |
} | |