tower-defense / src /ui /UIManager.js
victor's picture
victor HF Staff
Refactor tower system and UI improvements
9e6ef9c
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);
}
}