Session 12: Save Slot System (3 slots, autosave toggle, cross-slot import/export) + fix goldenBlooms field missing from reset()/switchSlot empty-state

This commit is contained in:
2026-08-23 09:01:17 +00:00
parent 26ee47c2db
commit 57904f80e2
5 changed files with 367 additions and 11 deletions
+92
View File
@@ -515,6 +515,98 @@ h2, h3 {
border-color: var(--danger); border-color: var(--danger);
} }
/* Save Slot System */
.footer-section {
display: flex;
align-items: center;
gap: 6px;
padding-right: 10px;
border-right: 1px solid var(--border-color);
}
.footer-section:last-of-type {
border-right: none;
margin-left: auto;
}
.footer-label {
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: bold;
}
.slot-btn {
background: var(--bg-card);
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 6px 12px;
cursor: pointer;
font-weight: bold;
transition: all 0.2s ease;
min-width: 34px;
}
.slot-btn:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--accent-purple);
color: var(--text-primary);
}
.slot-btn.active {
background: var(--accent-purple);
color: var(--text-primary);
border-color: var(--accent-gold);
box-shadow: 0 0 12px rgba(155, 89, 182, 0.6);
}
.slot-btn:disabled {
cursor: default;
opacity: 0.5;
}
/* Autosave toggle button */
.toggle-btn {
background: var(--bg-card);
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 14px;
cursor: pointer;
font-weight: bold;
transition: all 0.2s ease;
}
.toggle-btn.on {
background: var(--accent-green);
color: #0a0a1a;
border-color: var(--accent-green);
}
.toggle-btn:not(.on) {
background: var(--bg-card);
color: var(--text-muted);
}
/* Import modal slot selector */
.import-slot-selector {
display: flex;
align-items: center;
gap: 6px;
margin: 12px 0;
}
.import-slot-label {
color: var(--text-secondary);
font-size: 0.85rem;
}
.modal-hint {
color: var(--text-muted);
font-size: 0.8rem;
margin-bottom: 10px;
}
#last-saved { #last-saved {
margin-left: auto; margin-left: auto;
color: var(--text-muted); color: var(--text-muted);
+24 -5
View File
@@ -63,7 +63,7 @@
</div> </div>
<div id="achievements-tab" class="tab-content"> <div id="achievements-tab" class="tab-content">
<h3>Achievements (0/33)</h3> <h3>Achievements (0/31)</h3>
<div id="achievements-list"></div> <div id="achievements-list"></div>
</div> </div>
@@ -103,10 +103,22 @@
<!-- Footer: Controls --> <!-- Footer: Controls -->
<footer id="game-footer"> <footer id="game-footer">
<button id="save-btn">💾 Save</button> <div class="footer-section">
<button id="export-btn">📤 Export Save</button> <span class="footer-label">Slot:</span>
<button id="import-btn">📥 Import Save</button> <button class="slot-btn" data-slot="0">1</button>
<button id="reset-btn">🗑️ Reset</button> <button class="slot-btn" data-slot="1">2</button>
<button class="slot-btn" data-slot="2">3</button>
</div>
<div class="footer-section">
<span class="footer-label">Auto:</span>
<button id="autosave-btn" class="toggle-btn on">ON</button>
</div>
<div class="footer-section">
<button id="save-btn">💾 Save</button>
<button id="export-btn">📤 Export</button>
<button id="import-btn">📥 Import</button>
<button id="reset-btn">🗑️ Reset</button>
</div>
<span id="last-saved"></span> <span id="last-saved"></span>
</footer> </footer>
</div> </div>
@@ -118,7 +130,14 @@
<div id="import-modal" class="modal hidden"> <div id="import-modal" class="modal hidden">
<div class="modal-content"> <div class="modal-content">
<h3>Import Save</h3> <h3>Import Save</h3>
<p class="modal-hint">Paste your save data, then choose which slot to import into.</p>
<textarea id="import-data" placeholder="Paste your save data here..."></textarea> <textarea id="import-data" placeholder="Paste your save data here..."></textarea>
<div class="import-slot-selector">
<span class="import-slot-label">Import into slot:</span>
<button class="slot-btn" data-import-slot="0">1</button>
<button class="slot-btn" data-import-slot="1">2</button>
<button class="slot-btn" data-import-slot="2">3</button>
</div>
<div class="modal-buttons"> <div class="modal-buttons">
<button id="confirm-import">Import</button> <button id="confirm-import">Import</button>
<button id="cancel-import">Cancel</button> <button id="cancel-import">Cancel</button>
+199 -6
View File
@@ -20,6 +20,10 @@ const Game = {
totalPrestigeEssence: 0, totalPrestigeEssence: 0,
prestigeUpgrades: [], 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 // Golden Starbloom random event state
goldenBlooms: { goldenBlooms: {
active: false, // is a bloom currently visible on screen? active: false, // is a bloom currently visible on screen?
@@ -34,6 +38,10 @@ const Game = {
tickRate: 100, // ms per tick (10 ticks/sec) tickRate: 100, // ms per tick (10 ticks/sec)
autoSaveInterval: 30000, // 30 seconds autoSaveInterval: 30000, // 30 seconds
costScaling: 1.15, // Cost multiplier per building level 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) goldenBloomMinMs: 45000, // min ms between bloom spawns (45s)
goldenBloomMaxMs: 90000, // max ms between bloom spawns (90s) goldenBloomMaxMs: 90000, // max ms between bloom spawns (90s)
goldenBloomDuration: 12, // seconds a visible bloom stays before vanishing goldenBloomDuration: 12, // seconds a visible bloom stays before vanishing
@@ -59,6 +67,9 @@ const Game = {
* Setup event listeners * Setup event listeners
*/ */
setupEventListeners() { setupEventListeners() {
// Track which slot the user has selected for importing.
this.importSlot = 0;
// Harvest button - support both click and touch // Harvest button - support both click and touch
const harvestBtn = document.getElementById('harvest-btn'); const harvestBtn = document.getElementById('harvest-btn');
@@ -100,9 +111,12 @@ const Game = {
document.getElementById('confirm-import').addEventListener('click', () => { document.getElementById('confirm-import').addEventListener('click', () => {
const data = document.getElementById('import-data').value; const data = document.getElementById('import-data').value;
if (data) { if (data) {
this.loadFromData(data); const ok = this.importSlotFromData(data, this.importSlot);
UI.hideImportModal(); UI.hideImportModal();
UI.showNotification('Save imported!', 'success'); if (ok) {
// If the import landed in the active slot, switchSlot already re-rendered.
UI.update();
}
} }
}); });
@@ -116,6 +130,37 @@ const Game = {
} }
}); });
// 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 // Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => { document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
@@ -126,6 +171,14 @@ const Game = {
document.getElementById(`${tab}-tab`).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');
});
});
}, },
/** /**
@@ -480,6 +533,8 @@ const Game = {
*/ */
startAutoSave() { startAutoSave() {
setInterval(() => { setInterval(() => {
// Only autosave when the player has enabled it (default on).
if (!this.state.autoSave) return;
this.save(); this.save();
document.getElementById('last-saved').textContent = document.getElementById('last-saved').textContent =
`Last saved: ${new Date().toLocaleTimeString()}`; `Last saved: ${new Date().toLocaleTimeString()}`;
@@ -487,22 +542,46 @@ const Game = {
}, },
/** /**
* Save game state * 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() { save() {
this.state.lastSaveTime = Date.now(); this.state.lastSaveTime = Date.now();
localStorage.setItem('cosmicGardenSave', JSON.stringify(this.state)); localStorage.setItem(this.getSaveKey(this.state.activeSlot), JSON.stringify(this.state));
}, },
/** /**
* Load game state * Load game state
*/ */
loadState() { loadState() {
const saved = localStorage.getItem('cosmicGardenSave'); // 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) { if (saved) {
try { try {
const parsed = JSON.parse(saved); const parsed = JSON.parse(saved);
this.state = { ...this.state, ...parsed }; 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 // Calculate offline progress
const offlineTime = (Date.now() - this.state.lastSaveTime) / 1000; const offlineTime = (Date.now() - this.state.lastSaveTime) / 1000;
@@ -543,6 +622,117 @@ const Game = {
} }
}, },
/**
* 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;
}
},
/**
* 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 },
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 * Export save as base64
*/ */
@@ -572,7 +762,10 @@ const Game = {
stellarEssence: 0, stellarEssence: 0,
prestigeCount: 0, prestigeCount: 0,
totalPrestigeEssence: 0, totalPrestigeEssence: 0,
prestigeUpgrades: [] prestigeUpgrades: [],
goldenBlooms: { active: false, timer: 0, lastSpawn: Date.now(), totalClaimed: 0 },
activeSlot: this.state.activeSlot,
autoSave: this.state.autoSave
}; };
UI.update(); UI.update();
UI.showNotification('Game reset!', 'warning'); UI.showNotification('Game reset!', 'warning');
+24
View File
@@ -75,6 +75,16 @@ const Stats = {
<div class="stat-bar-fill" id="stat-progress-bar" style="width: 0%"></div> <div class="stat-bar-fill" id="stat-progress-bar" style="width: 0%"></div>
</div> </div>
</div> </div>
<!-- Save Slot System -->
<div class="stat-item">
<div class="stat-label">Active Save Slot</div>
<div class="stat-value" id="stat-active-slot">Slot 1 / ${Game.config.SLOTS}</div>
</div>
<div class="stat-item">
<div class="stat-label">Autosave</div>
<div class="stat-value" id="stat-autosave">ON</div>
</div>
`; `;
}, },
@@ -132,6 +142,20 @@ const Stats = {
achievementsEl.textContent = `${Achievements.getCount()}/${Achievements.getTotal()}`; achievementsEl.textContent = `${Achievements.getCount()}/${Achievements.getTotal()}`;
} }
// Active save slot display
const activeSlotEl = document.getElementById('stat-active-slot');
if (activeSlotEl) {
const idx = Game.state.activeSlot || 0;
activeSlotEl.textContent = `Slot ${idx + 1} / ${Game.config.SLOTS}`;
}
// Autosave status display
const autosaveEl = document.getElementById('stat-autosave');
if (autosaveEl) {
autosaveEl.textContent = Game.state.autoSave ? 'ON' : 'OFF';
autosaveEl.style.color = Game.state.autoSave ? 'var(--accent-green)' : 'var(--text-muted)';
}
// Total Golden Blooms claimed // Total Golden Blooms claimed
const goldenBloomsEl = document.getElementById('stat-golden-blooms'); const goldenBloomsEl = document.getElementById('stat-golden-blooms');
if (goldenBloomsEl) { if (goldenBloomsEl) {
+28
View File
@@ -11,6 +11,7 @@ const UI = {
this.renderBuildings(); this.renderBuildings();
this.renderUpgrades(); this.renderUpgrades();
this.renderAchievements(); this.renderAchievements();
this.updateSlotUI();
this.update(); this.update();
}, },
@@ -41,6 +42,9 @@ const UI = {
// Golden Starbloom countdown badge // Golden Starbloom countdown badge
this.updateGoldenBloomBadge(); this.updateGoldenBloomBadge();
// Save Slot System highlight / toggle state
this.updateSlotUI();
}, },
/** /**
@@ -297,6 +301,30 @@ const UI = {
} }
}, },
/**
* 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';
}
}
},
/** /**
* Show floating text animation * Show floating text animation
*/ */