Session 17: Sound Toggle (mute/unmute with persistence) Added

This commit is contained in:
2026-08-25 02:17:50 +00:00
parent cc1fa6bc4d
commit b9068e2029
5 changed files with 133 additions and 18 deletions
+28
View File
@@ -468,3 +468,31 @@ Wrote a headless Node.js test (`/opt/data/idle-game-test/buy-all-buildings-test.
- Potential new building tiers or prestige milestones / new prestige upgrades. - Potential new building tiers or prestige milestones / new prestige upgrades.
- Sound variations or background music. - Sound variations or background music.
- Consider a "Buy Max" toggle (buy one-at-a-time vs bulk) or per-building Buy Max count display. - Consider a "Buy Max" toggle (buy one-at-a-time vs bulk) or per-building Buy Max count display.
---
### Session 17 - 2026-08-25 (Morning)
**Status:** ✅ Sound Toggle (mute/unmute with persistence) Added
#### What was done:
- Followed up on Sessions 14-16's recurring "sound variations / background music" next-goal by adding a **footer Sound toggle button**. The game already had a full Web Audio sound-effect system (click arpeggio, purchase chime, achievement fanfare, prestige whoosh, golden-bloom shimmer in `js/audio.js`), but there was no way to turn it off — once the audio context started on first click, it could never be silenced. This is a real usability gap for, e.g., playing at night or in a shared space.
- Added `soundEnabled: true` to `Game.state` and made **every** sound effect flow through a single new method `Game.playSound(effect)` (effects: 'click', 'purchase', 'achievement', 'prestige', 'goldenBloom'). The method returns early when `soundEnabled` is false, so muting reliably silences all effects. Replaced every direct `Audio.play*()` call site in `game.js`, `prestige.js`, and `achievements.js` with `Game.playSound(...)`.
- Guarded the initial `Audio.init()` on `if (this.state.soundEnabled)` so a muted player's browser never creates an audio context at all.
- Added a footer **Sound** toggle button (`#sound-btn`) next to the existing Autosave toggle, reusing the `.toggle-btn` styling. It shows 🔊/🔇 icons (instead of ON/OFF text), toggles `state.soundEnabled`, persists the preference to `localStorage('cosmicGardenSound')`, shows a notification, and — when unmute-ing — re-initializes/resumes the audio context (a click gesture satisfies the browser's autoplay policy).
- Added `Game.syncSoundButton()` which reflects the current preference on the footer button's visual state; called it at the end of `init()` so a muted player sees a muted button on reload even though the HTML defaults to enabled.
- Restored the persisted sound preference in `loadState()` **outside** the `if (saved)` block so it applies whether or not a per-slot save exists, and preserved `soundEnabled` across `reset()` and the empty-slot branch of `switchSlot()` (mirroring the existing `autoSave` handling).
#### Files modified:
- `js/game.js` - added `soundEnabled` state; `playSound()` + `syncSoundButton()` methods; guarded `Audio.init()`; sound-toggle event listener; routed all 6 audio call sites through `Game.playSound`; restored pref in `loadState()`; preserved across `reset()`/`switchSlot()`
- `js/prestige.js` - routed prestige + prestige-upgrade sounds through `Game.playSound()`
- `js/achievements.js` - routed achievement sound through `Game.playSound()`
- `index.html` - added Sound toggle button to the footer
- `dev-journal.md` - appended Session 17 entry
#### Verification:
Wrote a headless Node.js test (`/opt/data/idle-game-test/sound-toggle-test.mjs`) that loads the real `game.js` in-process with DOM/audio/localStorage stubs. **29/29 checks pass**, covering: default-on state, correct effect routing when enabled, total silence for all five effects when muted, unknown-effect safety, toggle button flipping preference + persisting to localStorage + showing notifications + updating 🔊/🔇 icon and `.on` class, restoring the muted/enabled preference from `loadState()` both with and without a save on disk, `reset()` preserving the preference across both states, and `syncSoundButton()` visual state. All existing suites re-ran clean with no regressions: buy-max 6/6, buy-all-upgrades 24/24, golden-bloom 22/22, prestige-buy-max 22/22, slot-preview 22/22, buy-all-buildings 31/31. All modified JS files pass `node --check`.
#### Next goals:
- Consider adding a mute-state persistence across slots explicitly tested via the UI (currently preserved in state; could add a dedicated test that switches slots and checks the button).
- Potential new building tiers or prestige milestones / new prestige upgrades.
- Background music track or per-effect volume sliders.
+5
View File
@@ -125,6 +125,11 @@
<span class="footer-label">Auto:</span> <span class="footer-label">Auto:</span>
<button id="autosave-btn" class="toggle-btn on">ON</button> <button id="autosave-btn" class="toggle-btn on">ON</button>
</div> </div>
<div class="footer-section">
<span class="footer-label">Sound:</span>
<!-- Speaker icon flips to a crossed-out speaker when muted (OFF) -->
<button id="sound-btn" class="toggle-btn on" title="Toggle game sound effects">🔊</button>
</div>
<div class="footer-section"> <div class="footer-section">
<button id="save-btn">💾 Save</button> <button id="save-btn">💾 Save</button>
<button id="export-btn">📤 Export</button> <button id="export-btn">📤 Export</button>
+2 -2
View File
@@ -274,8 +274,8 @@ const Achievements = {
Game.state.achievements.push(id); Game.state.achievements.push(id);
UI.showNotification(`Achievement: ${achievement.name}!`, 'achievement'); UI.showNotification(`Achievement: ${achievement.name}!`, 'achievement');
// Audio feedback // Audio feedback (gated by the sound preference)
Audio.playAchievement(); Game.playSound('achievement');
// Particle effect for achievement unlock // Particle effect for achievement unlock
const countEl = document.querySelector('#achievements-tab h3'); const countEl = document.querySelector('#achievements-tab h3');
+95 -13
View File
@@ -23,6 +23,7 @@ const Game = {
// Save Slot System // Save Slot System
activeSlot: 0, // index of the currently selected save slot (0..SLOTS-1) activeSlot: 0, // index of the currently selected save slot (0..SLOTS-1)
autoSave: true, // whether the periodic autosave loop is running autoSave: true, // whether the periodic autosave loop is running
soundEnabled: true, // whether game sound effects are enabled (persisted preference)
// Golden Starbloom random event state // Golden Starbloom random event state
goldenBlooms: { goldenBlooms: {
@@ -56,10 +57,19 @@ const Game = {
this.setupEventListeners(); this.setupEventListeners();
this.startGameLoop(); this.startGameLoop();
this.startAutoSave(); this.startAutoSave();
Audio.init(); // Only initialize the Web Audio context if sound is enabled. This keeps
// a muted player's browser from creating an audio context at all.
if (this.state.soundEnabled) {
Audio.init();
}
UI.init(); UI.init();
Stats.init(); Stats.init();
PrestigeUI.init(); PrestigeUI.init();
// Reflect the persisted sound preference on the footer button. If the
// player muted sound in a previous session, the button must show muted
// (green 'on' class is dropped and the icon flips to 🔇) even though the
// HTML defaults to enabled.
this.syncSoundButton();
console.log('🌌 Cosmic Garden initialized!'); console.log('🌌 Cosmic Garden initialized!');
}, },
@@ -177,6 +187,33 @@ const Game = {
localStorage.setItem('cosmicGardenAutoSave', String(this.state.autoSave)); localStorage.setItem('cosmicGardenAutoSave', String(this.state.autoSave));
}); });
// Sound toggle button - mirrors the autosave toggle pattern above, but
// uses a speaker icon instead of ON/OFF text since the button content
// is an emoji. The 'on' class keeps the green accent styling.
document.getElementById('sound-btn').addEventListener('click', () => {
this.state.soundEnabled = !this.state.soundEnabled;
const btn = document.getElementById('sound-btn');
if (this.state.soundEnabled) {
btn.classList.add('on');
btn.textContent = '🔊';
// Re-initialize the audio context now that sound is on. Some
// browsers require a user gesture to start it, which this click
// provides, so Audio.init() will succeed here.
if (!Audio.ctx) {
Audio.init();
} else {
Audio.resume();
}
UI.showNotification('Sound enabled', 'success');
} else {
btn.classList.remove('on');
btn.textContent = '🔇';
UI.showNotification('Sound muted', 'info');
}
// Persist the toggle preference immediately.
localStorage.setItem('cosmicGardenSound', String(this.state.soundEnabled));
});
// Tab switching // Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => { document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
@@ -205,8 +242,8 @@ const Game = {
this.addStardust(amount); this.addStardust(amount);
this.state.totalClicks++; this.state.totalClicks++;
// Audio feedback // Audio feedback (gated by the sound preference)
Audio.playClick(); this.playSound('click');
// Visual feedback // Visual feedback
UI.showFloatingText(event.clientX, event.clientY, `+${this.formatNumber(amount)}`); UI.showFloatingText(event.clientX, event.clientY, `+${this.formatNumber(amount)}`);
@@ -329,8 +366,8 @@ const Game = {
UI.showNotification(`Bought ${BUILDINGS[buildingId].name}!`, 'success'); UI.showNotification(`Bought ${BUILDINGS[buildingId].name}!`, 'success');
// Audio feedback // Audio feedback (gated by the sound preference)
Audio.playPurchase(); this.playSound('purchase');
// Particle effect on building purchase // Particle effect on building purchase
const card = document.getElementById(`building-${buildingId}`); const card = document.getElementById(`building-${buildingId}`);
@@ -387,8 +424,8 @@ const Game = {
if (count > 0) { if (count > 0) {
UI.showNotification(`Bought ${count}x ${building.name}!`, 'success'); UI.showNotification(`Bought ${count}x ${building.name}!`, 'success');
// Audio feedback // Audio feedback (gated by the sound preference)
Audio.playPurchase(); this.playSound('purchase');
// Particle effect on building purchase // Particle effect on building purchase
const card = document.getElementById(`building-${buildingId}`); const card = document.getElementById(`building-${buildingId}`);
@@ -499,7 +536,7 @@ const Game = {
if (bought.length > 0) { if (bought.length > 0) {
UI.showNotification(`Bought ${bought.length} upgrade(s)!`, 'success'); UI.showNotification(`Bought ${bought.length} upgrade(s)!`, 'success');
Audio.playPurchase(); this.playSound('purchase');
UI.update(); UI.update();
} }
@@ -546,7 +583,7 @@ const Game = {
if (bought.length > 0) { if (bought.length > 0) {
UI.showNotification(`Bought ${bought.length} prestige upgrade(s)!`, 'achievement'); UI.showNotification(`Bought ${bought.length} prestige upgrade(s)!`, 'achievement');
Audio.playPurchase(); this.playSound('purchase');
UI.update(); UI.update();
} }
@@ -566,6 +603,40 @@ const Game = {
return this.state.goldenBlooms.active ? Math.ceil(this.state.goldenBlooms.timer) : 0; return this.state.goldenBlooms.active ? Math.ceil(this.state.goldenBlooms.timer) : 0;
}, },
/**
* Play a game sound effect, gated by the player's sound preference.
* This is the single entry point for all audio so that muting sound
* (via the footer toggle) reliably silences every effect in the game —
* clicks, purchases, achievements, prestige and golden blooms alike.
*/
playSound(effect) {
if (!this.state.soundEnabled) return;
switch (effect) {
case 'click': Audio.playClick(); break;
case 'purchase': Audio.playPurchase(); break;
case 'achievement': Audio.playAchievement(); break;
case 'prestige': Audio.playPrestige(); break;
case 'goldenBloom': Audio.playGoldenBloom(); break;
}
},
/**
* Reflect the current sound preference on the footer toggle button's visual
* state (green accent class + speaker icon). Call this after loading a save
* so a muted player sees a muted button even though the HTML defaults to ON.
*/
syncSoundButton() {
const btn = document.getElementById('sound-btn');
if (!btn) return;
if (this.state.soundEnabled) {
btn.classList.add('on');
btn.textContent = '🔊';
} else {
btn.classList.remove('on');
btn.textContent = '🔇';
}
},
/** /**
* Award a Golden Starbloom bonus and apply the production multiplier. * Award a Golden Starbloom bonus and apply the production multiplier.
* Called when the player clicks a visible bloom. Returns the amount earned. * Called when the player clicks a visible bloom. Returns the amount earned.
@@ -588,7 +659,7 @@ const Game = {
this.state.productionBoostUntil = Date.now() + 30000; this.state.productionBoostUntil = Date.now() + 30000;
UI.showNotification('⭐ Golden Starbloom! +' + this.formatNumber(bonus) + ' ✨', 'achievement'); UI.showNotification('⭐ Golden Starbloom! +' + this.formatNumber(bonus) + ' ✨', 'achievement');
Audio.playGoldenBloom(); this.playSound('goldenBloom');
Particles.createGoldenEffect(x, y); Particles.createGoldenEffect(x, y);
UI.update(); UI.update();
return bonus; return bonus;
@@ -716,7 +787,7 @@ const Game = {
if (persistedSlot !== null) { if (persistedSlot !== null) {
this.state.activeSlot = parseInt(persistedSlot, 10) || 0; 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;
if (offlineTime > 60) { if (offlineTime > 60) {
@@ -741,6 +812,15 @@ const Game = {
console.error('Failed to load save:', e); console.error('Failed to load save:', e);
} }
} }
// Restore the persisted sound preference over whatever the slot save
// contained. This is stored separately from the per-slot save so a muted
// player's choice survives reloading even with no save on disk, and
// switching slots never re-enables sound unexpectedly.
const persistedSound = localStorage.getItem('cosmicGardenSound');
if (persistedSound !== null) {
this.state.soundEnabled = persistedSound === 'true';
}
}, },
/** /**
@@ -896,7 +976,8 @@ const Game = {
// from a previous slot cannot leak into a fresh slot. // from a previous slot cannot leak into a fresh slot.
productionBoostUntil: 0, productionBoostUntil: 0,
activeSlot: slot, activeSlot: slot,
autoSave: this.state.autoSave autoSave: this.state.autoSave,
soundEnabled: this.state.soundEnabled
}; };
UI.showNotification( UI.showNotification(
`Empty Slot ${slot + 1} - new game started!`, 'success' `Empty Slot ${slot + 1} - new game started!`, 'success'
@@ -978,7 +1059,8 @@ const Game = {
prestigeUpgrades: [], prestigeUpgrades: [],
goldenBlooms: { active: false, timer: 0, lastSpawn: Date.now(), totalClaimed: 0 }, goldenBlooms: { active: false, timer: 0, lastSpawn: Date.now(), totalClaimed: 0 },
activeSlot: this.state.activeSlot, activeSlot: this.state.activeSlot,
autoSave: this.state.autoSave autoSave: this.state.autoSave,
soundEnabled: this.state.soundEnabled
}; };
UI.update(); UI.update();
UI.showNotification('Game reset!', 'warning'); UI.showNotification('Game reset!', 'warning');
+3 -3
View File
@@ -54,8 +54,8 @@ const PRESTIGE = {
prestigeUpgrades: Game.state.prestigeUpgrades || [] prestigeUpgrades: Game.state.prestigeUpgrades || []
}; };
// Audio feedback // Audio feedback (gated by the sound preference)
Audio.playPrestige(); Game.playSound('prestige');
// Particle effect for prestige // Particle effect for prestige
const btn = document.getElementById('prestige-btn'); const btn = document.getElementById('prestige-btn');
@@ -88,7 +88,7 @@ const PRESTIGE = {
Game.state.prestigeUpgrades.push(upgradeId); Game.state.prestigeUpgrades.push(upgradeId);
UI.showNotification(`Unlocked: ${upgrade.name}!`, 'achievement'); UI.showNotification(`Unlocked: ${upgrade.name}!`, 'achievement');
Audio.playPurchase(); Game.playSound('purchase');
UI.update(); UI.update();
return true; return true;
} }