992 lines
34 KiB
JavaScript
992 lines
34 KiB
JavaScript
/**
|
|
* Cosmic Garden - Main Game Engine
|
|
* Handles game state, tick loop, and core logic
|
|
*/
|
|
|
|
const Game = {
|
|
// Core game state
|
|
state: {
|
|
stardust: 0,
|
|
totalStardust: 0,
|
|
totalClicks: 0,
|
|
clickPower: 1,
|
|
startTime: Date.now(),
|
|
lastSaveTime: Date.now(),
|
|
buildings: {},
|
|
upgrades: [],
|
|
achievements: [],
|
|
stellarEssence: 0,
|
|
prestigeCount: 0,
|
|
totalPrestigeEssence: 0,
|
|
prestigeUpgrades: [],
|
|
|
|
// Save Slot System
|
|
activeSlot: 0, // index of the currently selected save slot (0..SLOTS-1)
|
|
autoSave: true, // whether the periodic autosave loop is running
|
|
|
|
// Golden Starbloom random event state
|
|
goldenBlooms: {
|
|
active: false, // is a bloom currently visible on screen?
|
|
timer: 0, // seconds until the bloom vanishes
|
|
lastSpawn: Date.now(), // ms when we last (re)spawned one
|
|
totalClaimed: 0 // lifetime count of blooms the player has clicked
|
|
}
|
|
},
|
|
|
|
// Configuration
|
|
config: {
|
|
tickRate: 100, // ms per tick (10 ticks/sec)
|
|
autoSaveInterval: 30000, // 30 seconds
|
|
costScaling: 1.15, // Cost multiplier per building level
|
|
|
|
// Save Slot System - number of independent save slots available
|
|
SLOTS: 3,
|
|
|
|
goldenBloomMinMs: 45000, // min ms between bloom spawns (45s)
|
|
goldenBloomMaxMs: 90000, // max ms between bloom spawns (90s)
|
|
goldenBloomDuration: 12, // seconds a visible bloom stays before vanishing
|
|
goldenBloomBonusMult: 15 // production-per-second worth of stardust awarded
|
|
},
|
|
|
|
/**
|
|
* Initialize the game
|
|
*/
|
|
init() {
|
|
this.loadState();
|
|
this.setupEventListeners();
|
|
this.startGameLoop();
|
|
this.startAutoSave();
|
|
Audio.init();
|
|
UI.init();
|
|
Stats.init();
|
|
PrestigeUI.init();
|
|
console.log('🌌 Cosmic Garden initialized!');
|
|
},
|
|
|
|
/**
|
|
* Setup event listeners
|
|
*/
|
|
setupEventListeners() {
|
|
// Track which slot the user has selected for importing.
|
|
this.importSlot = 0;
|
|
|
|
// Harvest button - support both click and touch
|
|
const harvestBtn = document.getElementById('harvest-btn');
|
|
|
|
harvestBtn.addEventListener('click', (e) => {
|
|
this.harvest(e);
|
|
this.createRipple(harvestBtn, e);
|
|
this.animateClickButton(harvestBtn);
|
|
});
|
|
|
|
// Touch support for better mobile experience
|
|
harvestBtn.addEventListener('touchstart', (e) => {
|
|
e.preventDefault(); // Prevent double-fire with click
|
|
const touch = e.touches[0];
|
|
const clickEvent = new MouseEvent('click', {
|
|
clientX: touch.clientX,
|
|
clientY: touch.clientY,
|
|
bubbles: true
|
|
});
|
|
|
|
// Create ripple effect for touch
|
|
this.createRipple(harvestBtn, { clientX: touch.clientX, clientY: touch.clientY });
|
|
this.animateClickButton(harvestBtn);
|
|
}, { passive: false });
|
|
|
|
// Save buttons
|
|
document.getElementById('save-btn').addEventListener('click', () => {
|
|
this.save();
|
|
UI.showNotification('Game saved!', 'success');
|
|
});
|
|
|
|
document.getElementById('export-btn').addEventListener('click', () => {
|
|
SaveSystem.exportSave();
|
|
});
|
|
|
|
document.getElementById('import-btn').addEventListener('click', () => {
|
|
UI.showImportModal();
|
|
});
|
|
|
|
document.getElementById('confirm-import').addEventListener('click', () => {
|
|
const data = document.getElementById('import-data').value;
|
|
if (data) {
|
|
const ok = this.importSlotFromData(data, this.importSlot);
|
|
UI.hideImportModal();
|
|
if (ok) {
|
|
// If the import landed in the active slot, switchSlot already re-rendered.
|
|
UI.update();
|
|
}
|
|
}
|
|
});
|
|
|
|
document.getElementById('cancel-import').addEventListener('click', () => {
|
|
UI.hideImportModal();
|
|
});
|
|
|
|
// Buy All upgrades button.
|
|
const buyAllBtn = document.getElementById('buy-all-upgrades');
|
|
if (buyAllBtn) {
|
|
buyAllBtn.addEventListener('click', () => {
|
|
Game.buyAllUpgrades();
|
|
});
|
|
}
|
|
|
|
document.getElementById('reset-btn').addEventListener('click', () => {
|
|
if (confirm('Are you sure you want to reset all progress? This cannot be undone!')) {
|
|
this.reset();
|
|
}
|
|
});
|
|
|
|
// Save Slot System: slot selection buttons.
|
|
document.querySelectorAll('.slot-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
if (this.state.activeSlot === parseInt(btn.dataset.slot, 10)) {
|
|
UI.showNotification(`Already on Slot ${parseInt(btn.dataset.slot, 10) + 1}`, 'info');
|
|
return;
|
|
}
|
|
if (confirm(`Switch to Slot ${parseInt(btn.dataset.slot, 10) + 1}?\nUnsaved changes will be saved first.`)) {
|
|
this.save(); // Persist current slot before switching.
|
|
this.switchSlot(parseInt(btn.dataset.slot, 10));
|
|
}
|
|
});
|
|
});
|
|
|
|
// Autosave toggle button.
|
|
document.getElementById('autosave-btn').addEventListener('click', () => {
|
|
this.state.autoSave = !this.state.autoSave;
|
|
const btn = document.getElementById('autosave-btn');
|
|
if (this.state.autoSave) {
|
|
btn.classList.add('on');
|
|
btn.textContent = 'ON';
|
|
UI.showNotification('Autosave enabled', 'success');
|
|
} else {
|
|
btn.classList.remove('on');
|
|
btn.textContent = 'OFF';
|
|
UI.showNotification('Autosave disabled', 'info');
|
|
}
|
|
// Persist the toggle preference immediately.
|
|
localStorage.setItem('cosmicGardenAutoSave', String(this.state.autoSave));
|
|
});
|
|
|
|
// Tab switching
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const tab = e.target.dataset.tab;
|
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
e.target.classList.add('active');
|
|
document.getElementById(`${tab}-tab`).classList.add('active');
|
|
});
|
|
});
|
|
|
|
// Import slot selector buttons.
|
|
document.querySelectorAll('[data-import-slot]').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
this.importSlot = parseInt(btn.dataset.importSlot, 10);
|
|
UI.showNotification(`Importing into Slot ${this.importSlot + 1}`, 'info');
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Harvest stardust by clicking
|
|
*/
|
|
harvest(event) {
|
|
const amount = this.getClickPower();
|
|
this.addStardust(amount);
|
|
this.state.totalClicks++;
|
|
|
|
// Audio feedback
|
|
Audio.playClick();
|
|
|
|
// Visual feedback
|
|
UI.showFloatingText(event.clientX, event.clientY, `+${this.formatNumber(amount)}`);
|
|
Particles.createClickParticles(event.clientX, event.clientY, 8);
|
|
UI.update();
|
|
},
|
|
|
|
/**
|
|
* Add stardust to the game
|
|
*/
|
|
addStardust(amount) {
|
|
this.state.stardust += amount;
|
|
this.state.totalStardust += amount;
|
|
},
|
|
|
|
/**
|
|
* Get current click power
|
|
*/
|
|
getClickPower() {
|
|
let power = 1;
|
|
|
|
// Apply upgrade bonuses
|
|
for (const upgradeId of this.state.upgrades) {
|
|
const upgrade = UPGRADES[upgradeId];
|
|
if (upgrade && upgrade.clickPowerBonus) {
|
|
power *= upgrade.clickPowerBonus;
|
|
}
|
|
}
|
|
|
|
// Apply prestige multipliers
|
|
const prestigeMultiplier = PRESTIGE.getMultiplier();
|
|
power *= prestigeMultiplier;
|
|
|
|
// Apply prestige upgrades
|
|
for (const upgradeId of (this.state.prestigeUpgrades || [])) {
|
|
const upgrade = PRESTIGE_UPGRADES[upgradeId];
|
|
if (upgrade && upgrade.effect === 'clickMultiplier') {
|
|
power *= upgrade.value;
|
|
}
|
|
}
|
|
|
|
return Math.floor(power);
|
|
},
|
|
|
|
/**
|
|
* Calculate production per second
|
|
*/
|
|
getProductionPerSecond() {
|
|
let total = 0;
|
|
|
|
for (const [buildingId, count] of Object.entries(this.state.buildings)) {
|
|
if (count === 0) continue;
|
|
|
|
const building = BUILDINGS[buildingId];
|
|
if (!building) continue;
|
|
|
|
let production = building.baseProduction * count;
|
|
|
|
// Apply prestige multiplier
|
|
const prestigeMultiplier = PRESTIGE.getMultiplier();
|
|
production *= prestigeMultiplier;
|
|
|
|
// Apply Golden Starbloom temporary production boost (+50%)
|
|
if (this.state.productionBoostUntil && this.state.productionBoostUntil > Date.now()) {
|
|
production *= 1.5;
|
|
}
|
|
// Apply prestige upgrades
|
|
for (const upgradeId of (this.state.prestigeUpgrades || [])) {
|
|
const upgrade = PRESTIGE_UPGRADES[upgradeId];
|
|
if (upgrade && upgrade.effect === 'productionMultiplier') {
|
|
production *= upgrade.value;
|
|
}
|
|
}
|
|
|
|
// Apply upgrade bonuses
|
|
for (const upgradeId of this.state.upgrades) {
|
|
const upgrade = UPGRADES[upgradeId];
|
|
if (upgrade && upgrade.buildingBonus && upgrade.buildingBonus[buildingId]) {
|
|
production *= upgrade.buildingBonus[buildingId];
|
|
}
|
|
if (upgrade && upgrade.globalBonus) {
|
|
production *= upgrade.globalBonus;
|
|
}
|
|
}
|
|
|
|
total += production;
|
|
}
|
|
|
|
return total;
|
|
},
|
|
|
|
/**
|
|
* Get building cost
|
|
*/
|
|
getBuildingCost(buildingId) {
|
|
const building = BUILDINGS[buildingId];
|
|
const count = this.state.buildings[buildingId] || 0;
|
|
let cost = Math.floor(building.baseCost * Math.pow(this.config.costScaling, count));
|
|
|
|
// Apply prestige cost reduction
|
|
for (const upgradeId of (this.state.prestigeUpgrades || [])) {
|
|
const upgrade = PRESTIGE_UPGRADES[upgradeId];
|
|
if (upgrade && upgrade.effect === 'costReduction') {
|
|
cost *= upgrade.value;
|
|
}
|
|
}
|
|
|
|
return Math.floor(cost);
|
|
},
|
|
|
|
/**
|
|
* Buy a single building
|
|
*/
|
|
buyBuilding(buildingId) {
|
|
const cost = this.getBuildingCost(buildingId);
|
|
|
|
if (this.state.stardust >= cost) {
|
|
this.state.stardust -= cost;
|
|
this.state.buildings[buildingId] = (this.state.buildings[buildingId] || 0) + 1;
|
|
|
|
UI.showNotification(`Bought ${BUILDINGS[buildingId].name}!`, 'success');
|
|
|
|
// Audio feedback
|
|
Audio.playPurchase();
|
|
|
|
// Particle effect on building purchase
|
|
const card = document.getElementById(`building-${buildingId}`);
|
|
if (card) {
|
|
const rect = card.getBoundingClientRect();
|
|
Particles.createBuildingEffect(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
|
}
|
|
|
|
UI.update();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
/**
|
|
* Calculate how many of a building the player can currently afford.
|
|
* Does not modify game state.
|
|
*/
|
|
getMaxAffordable(buildingId) {
|
|
const building = BUILDINGS[buildingId];
|
|
if (!building) return 0;
|
|
|
|
let stardust = this.state.stardust;
|
|
let count = 0;
|
|
let owned = this.state.buildings[buildingId] || 0;
|
|
|
|
while (true) {
|
|
const cost = Math.floor(building.baseCost * Math.pow(this.config.costScaling, owned + count));
|
|
if (stardust < cost) break;
|
|
stardust -= cost;
|
|
count++;
|
|
}
|
|
|
|
return count;
|
|
},
|
|
|
|
/**
|
|
* Buy as many of a building as the player can afford at once.
|
|
* Returns the number purchased.
|
|
*/
|
|
buyMaxBuilding(buildingId) {
|
|
const building = BUILDINGS[buildingId];
|
|
if (!building) return 0;
|
|
|
|
let count = 0;
|
|
while (this.state.stardust >= this.getBuildingCost(buildingId)) {
|
|
const cost = this.getBuildingCost(buildingId);
|
|
this.state.stardust -= cost;
|
|
this.state.buildings[buildingId] = (this.state.buildings[buildingId] || 0) + 1;
|
|
count++;
|
|
}
|
|
|
|
if (count > 0) {
|
|
UI.showNotification(`Bought ${count}x ${building.name}!`, 'success');
|
|
|
|
// Audio feedback
|
|
Audio.playPurchase();
|
|
|
|
// Particle effect on building purchase
|
|
const card = document.getElementById(`building-${buildingId}`);
|
|
if (card) {
|
|
const rect = card.getBoundingClientRect();
|
|
Particles.createBuildingEffect(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
|
}
|
|
|
|
UI.update();
|
|
}
|
|
|
|
return count;
|
|
},
|
|
|
|
/**
|
|
* Buy an upgrade
|
|
*/
|
|
buyUpgrade(upgradeId) {
|
|
const upgrade = UPGRADES[upgradeId];
|
|
if (!upgrade || this.state.upgrades.includes(upgradeId)) {
|
|
return false;
|
|
}
|
|
|
|
if (this.state.stardust >= upgrade.cost) {
|
|
// Check requirements
|
|
if (upgrade.requirements) {
|
|
for (const [reqId, reqCount] of Object.entries(upgrade.requirements)) {
|
|
const currentCount = this.state.buildings[reqId] || 0;
|
|
if (currentCount < reqCount) {
|
|
UI.showNotification('Requirements not met!', 'warning');
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.state.stardust -= upgrade.cost;
|
|
this.state.upgrades.push(upgradeId);
|
|
|
|
UI.showNotification(`Unlocked: ${upgrade.name}!`, 'success');
|
|
UI.update();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
/**
|
|
* Buy every upgrade the player can currently afford at once.
|
|
* Returns an object describing what happened: { bought: count, ids: [...] }.
|
|
* Upgrades are one-time purchases, so we loop until nothing affordable remains.
|
|
*/
|
|
buyAllUpgrades() {
|
|
const bought = [];
|
|
// Loop until a pass buys nothing (each purchase may unlock new affordability).
|
|
let progress = true;
|
|
while (progress) {
|
|
progress = false;
|
|
for (const [id, upgrade] of Object.entries(UPGRADES)) {
|
|
if (this.state.upgrades.includes(id)) continue;
|
|
// Respect requirements before attempting purchase.
|
|
if (upgrade.requirements) {
|
|
let met = true;
|
|
for (const [reqId, reqCount] of Object.entries(upgrade.requirements)) {
|
|
if ((this.state.buildings[reqId] || 0) < reqCount) {
|
|
met = false;
|
|
break;
|
|
}
|
|
}
|
|
if (!met) continue;
|
|
}
|
|
if (this.state.stardust >= upgrade.cost) {
|
|
this.state.stardust -= upgrade.cost;
|
|
this.state.upgrades.push(id);
|
|
bought.push(id);
|
|
progress = true; // another purchase may have unlocked more affordability
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bought.length > 0) {
|
|
UI.showNotification(`Bought ${bought.length} upgrade(s)!`, 'success');
|
|
Audio.playPurchase();
|
|
UI.update();
|
|
}
|
|
|
|
return { bought: bought.length, ids: bought };
|
|
},
|
|
|
|
/**
|
|
* Buy every prestige upgrade the player can currently afford at once,
|
|
* paid from Stellar Essence. Returns an object describing what happened:
|
|
* { bought: count, ids: [...] }. Prestige upgrades are one-time purchases,
|
|
* so we loop until a full pass buys nothing (purchasing one never unlocks
|
|
* another, but the loop is kept for symmetry with buyAllUpgrades and to
|
|
* stay safe should future prestige upgrades gain requirement gates).
|
|
*/
|
|
buyMaxPrestigeUpgrades() {
|
|
const bought = [];
|
|
let progress = true;
|
|
while (progress) {
|
|
progress = false;
|
|
for (const [id, upgrade] of Object.entries(PRESTIGE_UPGRADES)) {
|
|
if ((this.state.prestigeUpgrades || []).includes(id)) continue;
|
|
// Respect requirements before attempting purchase, mirroring
|
|
// PRESTIGE.buyUpgrade semantics.
|
|
if (upgrade.requirements) {
|
|
let met = true;
|
|
for (const [reqId, reqCount] of Object.entries(upgrade.requirements)) {
|
|
if ((this.state.buildings[reqId] || 0) < reqCount) {
|
|
met = false;
|
|
break;
|
|
}
|
|
}
|
|
if (!met) continue;
|
|
}
|
|
if ((this.state.stellarEssence || 0) >= upgrade.cost) {
|
|
this.state.prestigeUpgrades = this.state.prestigeUpgrades || [];
|
|
this.state.prestigeUpgrades.push(id);
|
|
// Spend Stellar Essence, mirroring PRESTIGE.buyUpgrade.
|
|
this.state.stellarEssence -= upgrade.cost;
|
|
bought.push(id);
|
|
progress = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bought.length > 0) {
|
|
UI.showNotification(`Bought ${bought.length} prestige upgrade(s)!`, 'achievement');
|
|
Audio.playPurchase();
|
|
UI.update();
|
|
}
|
|
|
|
return { bought: bought.length, ids: bought };
|
|
},
|
|
|
|
/**
|
|
* Golden Starbloom - random event system.
|
|
* A rare, time-limited bloom occasionally appears on screen. Clicking it
|
|
* awards a stardust bonus and grants a temporary production boost.
|
|
*/
|
|
|
|
/**
|
|
* Seconds remaining until the current bloom vanishes (0 if none active).
|
|
*/
|
|
getGoldenBloomRemaining() {
|
|
return this.state.goldenBlooms.active ? Math.ceil(this.state.goldenBlooms.timer) : 0;
|
|
},
|
|
|
|
/**
|
|
* Award a Golden Starbloom bonus and apply the production multiplier.
|
|
* Called when the player clicks a visible bloom. Returns the amount earned.
|
|
*/
|
|
claimGoldenBloom(x, y) {
|
|
const state = this.state.goldenBlooms;
|
|
|
|
// No active bloom - nothing to claim.
|
|
if (!state.active) return 0;
|
|
|
|
state.active = false;
|
|
state.timer = 0;
|
|
state.totalClaimed++;
|
|
|
|
// Bonus: goldenBloomBonusMult x current production per second (min 1).
|
|
const bonus = Math.max(1, Math.floor(this.getProductionPerSecond() * this.config.goldenBloomBonusMult));
|
|
this.addStardust(bonus);
|
|
|
|
// Temporary production boost: +50% for 30 seconds.
|
|
this.state.productionBoostUntil = Date.now() + 30000;
|
|
|
|
UI.showNotification('⭐ Golden Starbloom! +' + this.formatNumber(bonus) + ' ✨', 'achievement');
|
|
Audio.playGoldenBloom();
|
|
Particles.createGoldenEffect(x, y);
|
|
UI.update();
|
|
return bonus;
|
|
},
|
|
|
|
/**
|
|
* Update golden bloom scheduling on each tick.
|
|
* Spawns a new bloom if none is active and enough time has elapsed;
|
|
* decrements the countdown of an active bloom until it vanishes.
|
|
*/
|
|
updateGoldenBloom(dtSeconds) {
|
|
const state = this.state.goldenBlooms;
|
|
|
|
// If a bloom is still on screen, count down its lifetime.
|
|
if (state.active) {
|
|
state.timer -= dtSeconds;
|
|
if (state.timer <= 0) {
|
|
state.active = false;
|
|
state.timer = 0;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Otherwise, check whether it is time to spawn the next bloom.
|
|
const elapsed = Date.now() - state.lastSpawn;
|
|
const minMs = this.config.goldenBloomMinMs;
|
|
const maxMs = this.config.goldenBloomMaxMs;
|
|
if (elapsed >= minMs) {
|
|
// Wait until somewhere in [min, max] before spawning.
|
|
if (elapsed >= maxMs || Math.random() < 0.05) {
|
|
state.active = true;
|
|
state.timer = this.config.goldenBloomDuration;
|
|
state.lastSpawn = Date.now();
|
|
UI.spawnGoldenBloom();
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Whether the temporary production boost is currently active, and how
|
|
* many seconds remain.
|
|
*/
|
|
getProductionBoostInfo() {
|
|
const until = this.state.productionBoostUntil || 0;
|
|
if (until <= Date.now()) return { active: false, remaining: 0 };
|
|
return { active: true, remaining: Math.ceil((until - Date.now()) / 1000) };
|
|
},
|
|
|
|
/**
|
|
* Game tick - called every config.tickRate ms
|
|
*/
|
|
tick() {
|
|
const production = this.getProductionPerSecond();
|
|
const tickSeconds = this.config.tickRate / 1000;
|
|
|
|
if (production > 0) {
|
|
this.addStardust(production * tickSeconds);
|
|
}
|
|
|
|
// Advance the Golden Starbloom random event clock.
|
|
this.updateGoldenBloom(tickSeconds);
|
|
|
|
UI.update();
|
|
Achievements.check();
|
|
},
|
|
|
|
/**
|
|
* Start the game loop
|
|
*/
|
|
startGameLoop() {
|
|
setInterval(() => this.tick(), this.config.tickRate);
|
|
},
|
|
|
|
/**
|
|
* Start auto-save
|
|
*/
|
|
startAutoSave() {
|
|
setInterval(() => {
|
|
// Only autosave when the player has enabled it (default on).
|
|
if (!this.state.autoSave) return;
|
|
this.save();
|
|
document.getElementById('last-saved').textContent =
|
|
`Last saved: ${new Date().toLocaleTimeString()}`;
|
|
}, this.config.autoSaveInterval);
|
|
},
|
|
|
|
/**
|
|
* localStorage key for a given slot's save data.
|
|
*/
|
|
getSaveKey(slot) {
|
|
return `cosmicGardenSave-${slot}`;
|
|
},
|
|
|
|
/**
|
|
* The key holding the selected slot index across page loads.
|
|
*/
|
|
getActiveSlotKey() {
|
|
return 'cosmicGardenActiveSlot';
|
|
},
|
|
|
|
/**
|
|
* Save game state to the currently active slot.
|
|
*/
|
|
save() {
|
|
this.state.lastSaveTime = Date.now();
|
|
localStorage.setItem(this.getSaveKey(this.state.activeSlot), JSON.stringify(this.state));
|
|
},
|
|
|
|
/**
|
|
* Load game state
|
|
*/
|
|
loadState() {
|
|
// Restore the persisted selected-slot index before reading its data.
|
|
const persistedSlot = localStorage.getItem(this.getActiveSlotKey());
|
|
if (persistedSlot !== null) {
|
|
this.state.activeSlot = parseInt(persistedSlot, 10) || 0;
|
|
}
|
|
|
|
const saved = localStorage.getItem(this.getSaveKey(this.state.activeSlot));
|
|
if (saved) {
|
|
try {
|
|
const parsed = JSON.parse(saved);
|
|
this.state = { ...this.state, ...parsed };
|
|
// The explicitly-persisted slot selection always wins.
|
|
if (persistedSlot !== null) {
|
|
this.state.activeSlot = parseInt(persistedSlot, 10) || 0;
|
|
}
|
|
|
|
// Calculate offline progress
|
|
const offlineTime = (Date.now() - this.state.lastSaveTime) / 1000;
|
|
if (offlineTime > 60) {
|
|
const production = this.getProductionPerSecond();
|
|
let offlineRate = 0.5; // 50% offline efficiency
|
|
for (const upgradeId of (this.state.prestigeUpgrades || [])) {
|
|
const upgrade = PRESTIGE_UPGRADES[upgradeId];
|
|
if (upgrade && upgrade.effect === 'offlineBonus') {
|
|
offlineRate = upgrade.value;
|
|
}
|
|
}
|
|
const offlineEarnings = production * offlineTime * offlineRate;
|
|
if (offlineEarnings > 0) {
|
|
this.addStardust(offlineEarnings);
|
|
UI.showNotification(
|
|
`Welcome back! Earned ${this.formatNumber(offlineEarnings)} ✨ while away`,
|
|
'success'
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load save:', e);
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Load from imported data
|
|
*/
|
|
loadFromData(data) {
|
|
try {
|
|
const parsed = JSON.parse(atob(data));
|
|
this.state = { ...this.state, ...parsed };
|
|
UI.update();
|
|
} catch (e) {
|
|
UI.showNotification('Invalid save data!', 'warning');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Load the data stored in a specific slot index (without changing it).
|
|
* Returns the parsed state, or null if that slot is empty.
|
|
*/
|
|
loadSlot(slot) {
|
|
const raw = localStorage.getItem(this.getSaveKey(slot));
|
|
if (!raw) return null;
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch (e) {
|
|
console.error(`Failed to read slot ${slot}:`, e);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Build a lightweight preview of a given save slot WITHOUT mutating the
|
|
* live game state. Returns an object describing what that slot holds:
|
|
* - present: whether the slot has any saved data
|
|
* - stardust: current (live) stardust production/sec for the slot
|
|
* - totalStardust: lifetime stardust earned in the slot
|
|
* - buildings: total number of buildings owned in the slot
|
|
* - achievements: count/total of achievements unlocked in the slot
|
|
* - prestigeCount: how many times the slot has been prestiged
|
|
* - lastSaved: human-readable "time ago" string for the slot's save time
|
|
* An empty/unreadable slot returns present:false with zeroed fields.
|
|
*/
|
|
getSlotPreview(slot) {
|
|
const data = this.loadSlot(slot);
|
|
if (!data) {
|
|
return {
|
|
present: false,
|
|
stardust: 0,
|
|
totalStardust: 0,
|
|
buildings: 0,
|
|
achievements: [0, 33],
|
|
prestigeCount: 0,
|
|
lastSaved: 'never'
|
|
};
|
|
}
|
|
|
|
const buildingCount = Object.values(data.buildings || {})
|
|
.reduce((sum, c) => sum + (Number(c) || 0), 0);
|
|
|
|
let timeAgo = 'never';
|
|
if (typeof data.lastSaveTime === 'number') {
|
|
const secs = Math.max(0, Math.floor((Date.now() - data.lastSaveTime) / 1000));
|
|
timeAgo = this.formatTimeAgo(secs);
|
|
}
|
|
|
|
return {
|
|
present: true,
|
|
stardust: Number(data.stardust) || 0,
|
|
totalStardust: Number(data.totalStardust) || 0,
|
|
buildings: buildingCount,
|
|
achievements: [
|
|
Array.isArray(data.achievements) ? data.achievements.length : 0,
|
|
33
|
|
],
|
|
prestigeCount: Number(data.prestigeCount) || 0,
|
|
lastSaved: timeAgo
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Format a number of seconds as a compact "time ago" string
|
|
* (e.g. "just now", "1m ago", "3h ago", "2d ago"). Used by slot previews.
|
|
*/
|
|
formatTimeAgo(secs) {
|
|
if (secs < 5) return 'just now';
|
|
|
|
// Largest units first; each entry is [thresholdSeconds, label].
|
|
const buckets = [
|
|
[365 * 86400, 'y'],
|
|
[30 * 86400, 'mo'],
|
|
[86400, 'd'],
|
|
[3600, 'h'],
|
|
[60, 'm']
|
|
];
|
|
|
|
for (const [threshold, label] of buckets) {
|
|
if (secs >= threshold) {
|
|
const value = Math.floor(secs / threshold);
|
|
return `${value}${label} ago`;
|
|
}
|
|
}
|
|
|
|
// Below one minute.
|
|
return `${Math.max(1, secs)}s ago`;
|
|
},
|
|
|
|
/**
|
|
* Switch the active save slot. Persists the selection and reloads that
|
|
* slot's data from localStorage into the live game state, preserving
|
|
* prestige-related counters that should carry across slots only when
|
|
* present in the target slot.
|
|
*/
|
|
switchSlot(slot) {
|
|
slot = ((parseInt(slot, 10) || 0) + this.config.SLOTS) % this.config.SLOTS;
|
|
|
|
// Persist the selection first so loadState() reads it back.
|
|
localStorage.setItem(this.getActiveSlotKey(), String(slot));
|
|
this.state.activeSlot = slot;
|
|
|
|
const data = this.loadSlot(slot);
|
|
if (data) {
|
|
try {
|
|
const parsed = JSON.parse(JSON.stringify(data));
|
|
this.state = { ...this.state, ...parsed };
|
|
// Ensure the slot index stays consistent.
|
|
this.state.activeSlot = slot;
|
|
|
|
UI.showNotification(
|
|
`Saved to Slot ${slot + 1}!`, 'success'
|
|
);
|
|
} catch (e) {
|
|
console.error('Failed to load selected slot:', e);
|
|
UI.showNotification('Could not load that slot!', 'warning');
|
|
}
|
|
} else {
|
|
// Target slot is empty: reset to a fresh start but keep essence.
|
|
this.state = {
|
|
stardust: 0,
|
|
totalStardust: 0,
|
|
totalClicks: 0,
|
|
clickPower: 1,
|
|
startTime: Date.now(),
|
|
lastSaveTime: Date.now(),
|
|
buildings: {},
|
|
upgrades: [],
|
|
achievements: [],
|
|
stellarEssence: this.state.stellarEssence || 0,
|
|
prestigeCount: this.state.prestigeCount || 0,
|
|
totalPrestigeEssence: this.state.totalPrestigeEssence || 0,
|
|
prestigeUpgrades: this.state.prestigeUpgrades || [],
|
|
goldenBlooms: { active: false, timer: 0, lastSpawn: Date.now(), totalClaimed: 0 },
|
|
// Reset the temporary golden-bloom production boost so a boost
|
|
// from a previous slot cannot leak into a fresh slot.
|
|
productionBoostUntil: 0,
|
|
activeSlot: slot,
|
|
autoSave: this.state.autoSave
|
|
};
|
|
UI.showNotification(
|
|
`Empty Slot ${slot + 1} - new game started!`, 'success'
|
|
);
|
|
}
|
|
|
|
// Re-render dynamic UI and refresh the golden-bloom badge.
|
|
UI.init();
|
|
UI.update();
|
|
},
|
|
|
|
/**
|
|
* Export a single slot's save as base64 (for backup or transfer).
|
|
*/
|
|
exportSlotToBase64(slot) {
|
|
const data = this.loadSlot(slot);
|
|
if (!data) {
|
|
UI.showNotification(`Slot ${slot + 1} is empty!`, 'warning');
|
|
return null;
|
|
}
|
|
const encoded = btoa(JSON.stringify(data));
|
|
navigator.clipboard.writeText(encoded).then(() => {
|
|
UI.showNotification(`Slot ${slot + 1} copied to clipboard!`, 'success');
|
|
});
|
|
return encoded;
|
|
},
|
|
|
|
/**
|
|
* Import base64 save data into a chosen slot.
|
|
*/
|
|
importSlotFromData(data, slot) {
|
|
try {
|
|
const parsed = JSON.parse(atob(data));
|
|
localStorage.setItem(this.getSaveKey(slot), JSON.stringify(parsed));
|
|
UI.showNotification(`Imported into Slot ${slot + 1}!`, 'success');
|
|
|
|
// If we imported into the active slot, reload it.
|
|
if (parseInt(slot, 10) === this.state.activeSlot) {
|
|
this.switchSlot(slot);
|
|
} else {
|
|
UI.update();
|
|
}
|
|
return true;
|
|
} catch (e) {
|
|
UI.showNotification('Invalid save data!', 'warning');
|
|
return false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Export save as base64
|
|
*/
|
|
exportSave() {
|
|
this.save();
|
|
const data = btoa(JSON.stringify(this.state));
|
|
navigator.clipboard.writeText(data).then(() => {
|
|
UI.showNotification('Save copied to clipboard!', 'success');
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Reset game
|
|
*/
|
|
reset() {
|
|
localStorage.removeItem('cosmicGardenSave');
|
|
this.state = {
|
|
stardust: 0,
|
|
totalStardust: 0,
|
|
totalClicks: 0,
|
|
clickPower: 1,
|
|
startTime: Date.now(),
|
|
lastSaveTime: Date.now(),
|
|
buildings: {},
|
|
upgrades: [],
|
|
achievements: [],
|
|
stellarEssence: 0,
|
|
prestigeCount: 0,
|
|
totalPrestigeEssence: 0,
|
|
prestigeUpgrades: [],
|
|
goldenBlooms: { active: false, timer: 0, lastSpawn: Date.now(), totalClaimed: 0 },
|
|
activeSlot: this.state.activeSlot,
|
|
autoSave: this.state.autoSave
|
|
};
|
|
UI.update();
|
|
UI.showNotification('Game reset!', 'warning');
|
|
},
|
|
|
|
/**
|
|
* Format numbers with commas
|
|
*/
|
|
formatNumber(num) {
|
|
if (num >= 1e12) return (num / 1e12).toFixed(2) + 'T';
|
|
if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B';
|
|
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
|
|
if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K';
|
|
return Math.floor(num).toLocaleString();
|
|
},
|
|
|
|
/**
|
|
* Create ripple effect on button click
|
|
*/
|
|
createRipple(button, event) {
|
|
const ripple = document.createElement('span');
|
|
ripple.className = 'ripple';
|
|
|
|
const rect = button.getBoundingClientRect();
|
|
const size = Math.max(rect.width, rect.height);
|
|
const x = event.clientX - rect.left - size / 2;
|
|
const y = event.clientY - rect.top - size / 2;
|
|
|
|
ripple.style.width = ripple.style.height = `${size}px`;
|
|
ripple.style.left = `${x}px`;
|
|
ripple.style.top = `${y}px`;
|
|
|
|
button.appendChild(ripple);
|
|
setTimeout(() => ripple.remove(), 600);
|
|
},
|
|
|
|
/**
|
|
* Animate click button with glow effect
|
|
*/
|
|
animateClickButton(button) {
|
|
button.classList.add('clicked');
|
|
setTimeout(() => button.classList.remove('clicked'), 200);
|
|
}
|
|
};
|
|
|
|
// Make Game globally accessible
|
|
window.Game = Game;
|