Files
idle-game/js/ui.js
T
hermes 34074b8e65 Session 14: Buy All Upgrades (bulk-buy for upgrades) + empty-slot boost leak fix
- Added Game.buyAllUpgrades(): buys every affordable upgrade at once,
  respecting per-upgrade requirements and looping until nothing remains.
- Added a 'Buy All' button to the Upgrades tab header; UI.updateBuyAllButton()
  shows/hides & enables/disables it live each tick based on affordability.
- Fixed empty-slot branch of switchSlot(): added productionBoostUntil: 0 so a
  temporary golden-bloom boost cannot leak into a fresh slot.
- Added .upgrades-header / .buy-all-btn CSS (gold accent theme).

Verified: headless test buy-all-upgrades-test.mjs 24/24 pass; existing
golden-bloom (22/22) and buy-max (6/6) tests re-run clean, no regressions.
2026-08-24 01:30:10 +00:00

455 lines
16 KiB
JavaScript

/**
* Cosmic Garden - UI Manager
* Handles all rendering and user interface updates
*/
const UI = {
/**
* Initialize the UI
*/
init() {
this.renderBuildings();
this.renderUpgrades();
this.renderAchievements();
this.updateSlotUI();
this.update();
},
/**
* Update all UI elements
*/
update() {
// Header
document.getElementById('stardust-count').textContent = Game.formatNumber(Game.state.stardust);
document.getElementById('stardust-ps').textContent = Game.formatNumber(Game.getProductionPerSecond());
document.getElementById('click-power').textContent = Game.formatNumber(Game.getClickPower());
document.getElementById('total-click-count').textContent = Game.formatNumber(Game.state.totalClicks);
// Buildings
this.updateBuildings();
// Upgrades
this.updateUpgrades();
// Achievements
this.updateAchievements();
// Stats
Stats.update();
// Prestige
PrestigeUI.update();
// Golden Starbloom countdown badge
this.updateGoldenBloomBadge();
// Save Slot System highlight / toggle state
this.updateSlotUI();
// Save Slot System live preview in the footer
this.updateSlotPreview();
},
/**
* Render building cards
*/
renderBuildings() {
const container = document.getElementById('buildings-list');
container.innerHTML = '';
for (const [id, building] of Object.entries(BUILDINGS)) {
const card = document.createElement('div');
card.className = 'building-card';
card.id = `building-${id}`;
const count = Game.state.buildings[id] || 0;
const cost = Game.getBuildingCost(id);
const canAfford = Game.state.stardust >= cost;
const locked = Game.state.totalStardust < building.unlockAt && count === 0;
card.classList.toggle('locked', locked);
card.innerHTML = `
<div class="building-info">
<h4>${building.icon} ${building.name}</h4>
<div class="building-desc">${building.description}</div>
<div class="building-production">+${Game.formatNumber(building.baseProduction)}/s each</div>
</div>
<div class="building-stats">
<div class="building-cost">${Game.formatNumber(cost)} ✨</div>
<div class="building-count">${count}</div>
<button class="buy-btn" ${!canAfford || locked ? 'disabled' : ''}>Buy</button>
<button class="buy-max-btn" data-building="${id}" ${locked || count === 0 ? 'title="Buy as many as you can afford"' : 'disabled'}>${Game.formatNumber(Game.getMaxAffordable(id))}x Max</button>
</div>
`;
if (!locked) {
card.querySelector('.buy-btn').addEventListener('click', () => {
Game.buyBuilding(id);
});
const maxBtn = card.querySelector('.buy-max-btn');
if (maxBtn) {
maxBtn.addEventListener('click', () => {
Game.buyMaxBuilding(id);
});
}
}
container.appendChild(card);
}
},
/**
* Update building states
*/
updateBuildings() {
for (const [id, building] of Object.entries(BUILDINGS)) {
const card = document.getElementById(`building-${id}`);
if (!card) continue;
const count = Game.state.buildings[id] || 0;
const cost = Game.getBuildingCost(id);
const canAfford = Game.state.stardust >= cost;
const locked = Game.state.totalStardust < building.unlockAt && count === 0;
card.querySelector('.building-cost').textContent = `${Game.formatNumber(cost)} ✨`;
card.querySelector('.building-count').textContent = count;
const btn = card.querySelector('.buy-btn');
if (btn) {
btn.disabled = !canAfford || locked;
}
// Buy Max button - reflect how many can be bought at current stardust
const maxBtn = card.querySelector('.buy-max-btn');
if (maxBtn) {
const affordable = Game.getMaxAffordable(id);
maxBtn.textContent = `${Game.formatNumber(affordable)}x Max`;
maxBtn.disabled = locked || affordable === 0;
maxBtn.title = affordable > 0 ? `Buy up to ${affordable} ${building.name}(s)` : 'Not enough stardust';
}
card.classList.toggle('locked', locked);
}
},
/**
* Render upgrade cards
*/
renderUpgrades() {
const container = document.getElementById('upgrades-list');
container.innerHTML = '';
for (const [id, upgrade] of Object.entries(UPGRADES)) {
const card = document.createElement('div');
card.className = 'upgrade-card';
card.id = `upgrade-${id}`;
const purchased = Game.state.upgrades.includes(id);
const canAfford = Game.state.stardust >= upgrade.cost;
// Check requirements
let requirementsMet = true;
let reqText = '';
if (upgrade.requirements) {
const reqParts = [];
for (const [reqId, reqCount] of Object.entries(upgrade.requirements)) {
const currentCount = Game.state.buildings[reqId] || 0;
const building = BUILDINGS[reqId];
if (currentCount < reqCount) requirementsMet = false;
reqParts.push(`${building?.name || reqId}: ${currentCount}/${reqCount}`);
}
reqText = `<div class="upgrade-desc">Requires: ${reqParts.join(', ')}</div>`;
}
card.classList.toggle('purchased', purchased);
card.classList.toggle('locked', !requirementsMet && !purchased);
card.innerHTML = `
<div class="upgrade-name">${upgrade.icon} ${upgrade.name}</div>
<div class="upgrade-desc">${upgrade.description}</div>
${reqText}
<div class="upgrade-cost">${purchased ? '✓ Owned' : `${Game.formatNumber(upgrade.cost)} ✨`}</div>
`;
if (!purchased && requirementsMet) {
card.addEventListener('click', () => {
Game.buyUpgrade(id);
});
}
container.appendChild(card);
}
},
/**
* Update upgrade states
*/
updateUpgrades() {
for (const [id, upgrade] of Object.entries(UPGRADES)) {
const card = document.getElementById(`upgrade-${id}`);
if (!card) continue;
const purchased = Game.state.upgrades.includes(id);
const costEl = card.querySelector('.upgrade-cost');
if (costEl && !purchased) {
const canAfford = Game.state.stardust >= upgrade.cost;
costEl.textContent = canAfford ? `${Game.formatNumber(upgrade.cost)} ✨` : `❌ ${Game.formatNumber(upgrade.cost)} ✨`;
}
}
// Toggle the "Buy All" button: show it once at least one unowned,
// requirement-met upgrade exists, and disable it when nothing is
// affordable. Its enabled state is refreshed live each tick.
this.updateBuyAllButton();
},
/**
* Show/hide and enable/disable the "Buy All upgrades" button based on
* whether any unowned upgrade (whose requirements are met) is currently
* affordable. Read of game state only; never mutates it.
*/
updateBuyAllButton() {
const btn = document.getElementById('buy-all-upgrades');
if (!btn) return;
let affordableAny = false;
for (const [id, upgrade] of Object.entries(UPGRADES)) {
if (Game.state.upgrades.includes(id)) continue;
if (Game.state.stardust < upgrade.cost) continue;
// Requirement check mirrors Game.buyUpgrade / buyAllUpgrades.
if (upgrade.requirements) {
let met = true;
for (const [reqId, reqCount] of Object.entries(upgrade.requirements)) {
if ((Game.state.buildings[reqId] || 0) < reqCount) {
met = false;
break;
}
}
if (!met) continue;
}
affordableAny = true;
break;
}
btn.style.display = affordableAny ? '' : 'none';
btn.disabled = !affordableAny;
},
/**
* Render achievements
*/
renderAchievements() {
const container = document.getElementById('achievements-list');
container.innerHTML = '';
for (const [id, achievement] of Object.entries(ACHIEVEMENTS)) {
const el = document.createElement('div');
el.className = 'achievement';
const unlocked = Game.state.achievements.includes(id);
el.classList.toggle('unlocked', unlocked);
el.classList.toggle('locked', !unlocked);
el.innerHTML = `
<div class="achievement-icon">${unlocked ? achievement.icon : '🔒'}</div>
<div class="achievement-name">${unlocked ? achievement.name : '???'}</div>
`;
if (unlocked) {
el.title = `${achievement.name}: ${achievement.description}`;
}
container.appendChild(el);
}
},
/**
* Update achievements display
*/
updateAchievements() {
// Only re-render when needed (on new unlocks)
const countEl = document.querySelector('#achievements-tab h3');
if (countEl) {
countEl.textContent = `Achievements (${Achievements.getCount()}/${Achievements.getTotal()})`;
}
},
/**
* Spawn a Golden Starbloom in the click area. Called by Game when the
* random event becomes active. The bloom floats at a random position and
* is clickable to claim its bonus.
*/
spawnGoldenBloom() {
const container = document.getElementById('golden-bloom-container');
if (!container) return;
// Remove any existing bloom first (shouldn't be one, but stay safe).
this.removeGoldenBloom();
const bloom = document.createElement('div');
bloom.className = 'golden-bloom';
bloom.textContent = '⭐';
bloom.title = 'Golden Starbloom! Click for a bonus!';
// Random position within the click area.
const rect = container.getBoundingClientRect();
const x = 20 + Math.random() * (Math.max(1, rect.width - 80));
const y = 20 + Math.random() * (Math.max(1, rect.height - 80));
bloom.style.left = `${x}px`;
bloom.style.top = `${y}px`;
// Click handler claims the bonus.
bloom.addEventListener('click', () => {
const earned = Game.claimGoldenBloom(rect.left + x, rect.top + y);
this.removeGoldenBloom();
if (earned > 0) {
UI.showFloatingText(
rect.left + x, rect.top + y,
`+${Game.formatNumber(earned)} ✨`
);
}
});
container.appendChild(bloom);
},
/**
* Remove any visible Golden Starbloom from the click area.
*/
removeGoldenBloom() {
const container = document.getElementById('golden-bloom-container');
if (!container) return;
const existing = container.querySelector('.golden-bloom');
if (existing) existing.remove();
},
/**
* Update the Golden Starbloom countdown badge shown in the header.
*/
updateGoldenBloomBadge() {
const el = document.getElementById('golden-bloom-badge');
const remaining = Game.getGoldenBloomRemaining();
if (!el) return;
if (remaining > 0) {
el.classList.add('active');
el.textContent = `⭐ Golden Bloom: ${remaining}s`;
} else {
el.classList.remove('active');
}
},
/**
* Reflect the active save slot highlight and autosave toggle state.
*/
updateSlotUI() {
// Highlight the selected slot button.
document.querySelectorAll('.slot-btn').forEach(btn => {
const idx = parseInt(btn.dataset.slot, 10);
btn.classList.toggle('active', idx === Game.state.activeSlot);
btn.disabled = idx === Game.state.activeSlot;
});
// Reflect the autosave toggle state.
const autosaveBtn = document.getElementById('autosave-btn');
if (autosaveBtn) {
if (Game.state.autoSave) {
autosaveBtn.classList.add('on');
autosaveBtn.textContent = 'ON';
} else {
autosaveBtn.classList.remove('on');
autosaveBtn.textContent = 'OFF';
}
}
},
/**
* Update the one-line live preview of the active slot in the footer.
* Also wires hover handlers onto each slot button so hovering previews a
* different slot's contents (stardust, buildings, achievements, last saved).
* This is a read-only display helper and never mutates game state.
*/
updateSlotPreview() {
const el = document.getElementById('slot-preview');
if (!el) return;
// Hovering a slot button previews that slot's contents instead of the
// active one. Track which slot is currently being previewed on hover.
let hovering = null;
const render = (slot) => {
const preview = Game.getSlotPreview(slot);
if (!preview.present) {
el.textContent = `Slot ${slot + 1}: empty`;
el.classList.add('empty');
return;
}
el.classList.remove('empty');
el.textContent =
`Slot ${slot + 1}: ${Game.formatNumber(preview.stardust)} ✨ · ` +
`${preview.buildings} plants · ${preview.achievements[0]}/${preview.achievements[1]} · ${preview.lastSaved}`;
};
// Default: preview the active slot live (updates every tick).
render(Game.state.activeSlot);
// Remove any previously-attached listeners to avoid duplicates on re-render.
document.querySelectorAll('.slot-btn').forEach(btn => {
btn.replaceWith(btn.cloneNode(true));
});
// On hover, preview the hovered slot; on leave, fall back to active.
document.querySelectorAll('.slot-btn').forEach(btn => {
const idx = parseInt(btn.dataset.slot, 10);
btn.addEventListener('mouseenter', () => { hovering = idx; render(idx); });
btn.addEventListener('mouseleave', () => { hovering = null; render(Game.state.activeSlot); });
});
},
/**
* Show floating text animation
*/
showFloatingText(x, y, text) {
const el = document.createElement('div');
el.className = 'float-text';
el.textContent = text;
el.style.left = `${x}px`;
el.style.top = `${y - 20}px`;
document.body.appendChild(el);
setTimeout(() => el.remove(), 1000);
},
/**
* Show notification
*/
showNotification(message, type = 'info') {
const container = document.getElementById('notifications');
const el = document.createElement('div');
el.className = `notification ${type}`;
el.textContent = message;
container.appendChild(el);
setTimeout(() => el.remove(), 3000);
},
/**
* Show import modal
*/
showImportModal() {
document.getElementById('import-modal').classList.remove('hidden');
},
/**
* Hide import modal
*/
hideImportModal() {
document.getElementById('import-modal').classList.add('hidden');
document.getElementById('import-data').value = '';
}
};
window.UI = UI;