Session 17: Sound Toggle (mute/unmute with persistence) Added
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user