Files
hermes cc1fa6bc4d Session 16: Global 'Buy All Buildings' bulk-buy feature Added
Adds a global Buy All button to the Buildings panel that purchases as many
of every building type the player can afford at once, bringing Plants in line
with the Upgrades (Buy All) and Prestige (Buy Max) panels.

- game.js: Game.buyAllBuildings() loops buyMaxBuilding() per building type
- ui.js: UI.updateBuyAllBuildings() shows/hides + enables/disables live each tick
- index.html: Buy All button in Buildings panel header (reuses .upgrades-header/.buy-all-btn)
- Added headless test buy-all-buildings-test.mjs (31/31 pass); all existing suites green
2026-08-24 18:00:27 +00:00

484 lines
17 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);
}
// Toggle the global "Buy All buildings" button: show it once at least
// one building type is currently affordable, and disable it otherwise.
this.updateBuyAllBuildings();
},
/**
* Show/hide and enable/disable the global "Buy All buildings" button based
* on whether at least one building type is currently affordable (at least
* one unit). Read of game state only; never mutates it. Mirrors
* Game.buyAllBuildings / buyMaxBuilding semantics.
*/
updateBuyAllBuildings() {
const btn = document.getElementById('buy-all-buildings');
if (!btn) return;
let affordableAny = false;
for (const buildingId of Object.keys(BUILDINGS)) {
const count = Game.state.buildings[buildingId] || 0;
const locked = Game.state.totalStardust < BUILDINGS[buildingId].unlockAt && count === 0;
if (locked) continue;
if (Game.getMaxAffordable(buildingId) > 0) {
affordableAny = true;
break;
}
}
btn.style.display = affordableAny ? '' : 'none';
btn.disabled = !affordableAny;
},
/**
* 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;