Add working sound effects and background music via Web Audio API
CI / build-and-push (push) Successful in 1m17s

Synthesized audio system with 9 distinct SFX (click, success, warning,
danger, purchase, achievement, era transition, info) mapped to all game
notifications, plus generative ambient background music with chord
progressions. Adds SFX volume slider to settings alongside existing
music volume control. No audio files or npm dependencies needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 20:11:50 -04:00
parent 1e3d50719e
commit d609934b73
9 changed files with 701 additions and 7 deletions
+126
View File
@@ -0,0 +1,126 @@
import type { SoundId } from './synthesizer';
import { playSound } from './synthesizer';
import { MusicEngine } from './musicEngine';
export class AudioManager {
private static instance: AudioManager | null = null;
private ctx: AudioContext | null = null;
private masterGain: GainNode | null = null;
private sfxGain: GainNode | null = null;
private musicGain: GainNode | null = null;
private music: MusicEngine | null = null;
private musicPlaying = false;
private soundEnabled = true;
private sfxVol = 0.5;
private musicVol = 0.5;
private lastPlayed = new Map<SoundId, number>();
static getInstance(): AudioManager {
if (!AudioManager.instance) {
AudioManager.instance = new AudioManager();
}
return AudioManager.instance;
}
private ensureContext(): AudioContext {
if (!this.ctx) {
this.ctx = new AudioContext();
this.masterGain = this.ctx.createGain();
this.masterGain.connect(this.ctx.destination);
this.sfxGain = this.ctx.createGain();
this.sfxGain.connect(this.masterGain);
this.sfxGain.gain.value = this.sfxVol;
this.musicGain = this.ctx.createGain();
this.musicGain.connect(this.masterGain);
this.musicGain.gain.value = this.musicVol;
this.masterGain.gain.value = this.soundEnabled ? 1 : 0;
}
if (this.ctx.state === 'suspended') {
this.ctx.resume();
}
return this.ctx;
}
setSoundEnabled(enabled: boolean): void {
this.soundEnabled = enabled;
if (this.masterGain && this.ctx) {
const now = this.ctx.currentTime;
this.masterGain.gain.cancelScheduledValues(now);
this.masterGain.gain.setValueAtTime(this.masterGain.gain.value, now);
this.masterGain.gain.linearRampToValueAtTime(enabled ? 1 : 0, now + 0.05);
}
}
setSfxVolume(v: number): void {
this.sfxVol = v;
if (this.sfxGain && this.ctx) {
const now = this.ctx.currentTime;
this.sfxGain.gain.cancelScheduledValues(now);
this.sfxGain.gain.setValueAtTime(this.sfxGain.gain.value, now);
this.sfxGain.gain.linearRampToValueAtTime(v, now + 0.05);
}
}
setMusicVolume(v: number): void {
this.musicVol = v;
if (this.musicGain && this.ctx) {
const now = this.ctx.currentTime;
this.musicGain.gain.cancelScheduledValues(now);
this.musicGain.gain.setValueAtTime(this.musicGain.gain.value, now);
this.musicGain.gain.linearRampToValueAtTime(v, now + 0.2);
}
}
playSfx(soundId: SoundId): void {
if (!this.soundEnabled || this.sfxVol === 0) return;
const now = performance.now();
const last = this.lastPlayed.get(soundId) ?? 0;
if (now - last < 100) return;
this.lastPlayed.set(soundId, now);
const ctx = this.ensureContext();
playSound(ctx, this.sfxGain!, soundId);
}
startMusic(): void {
if (this.musicPlaying) return;
const ctx = this.ensureContext();
this.music = new MusicEngine(ctx, this.musicGain!);
this.music.start();
this.musicPlaying = true;
}
stopMusic(): void {
if (!this.musicPlaying || !this.music) return;
this.music.stop();
this.music = null;
this.musicPlaying = false;
}
get isMusicPlaying(): boolean {
return this.musicPlaying;
}
get isContextActive(): boolean {
return this.ctx !== null && this.ctx.state === 'running';
}
dispose(): void {
this.stopMusic();
if (this.ctx) {
this.ctx.close();
this.ctx = null;
this.masterGain = null;
this.sfxGain = null;
this.musicGain = null;
}
this.lastPlayed.clear();
AudioManager.instance = null;
}
}
+69
View File
@@ -0,0 +1,69 @@
import { AudioManager } from './AudioManager';
import type { GameSettings } from '@token-empire/shared';
import { useGameStore } from '@/store';
export { AudioManager } from './AudioManager';
export { triggerNotificationSound, playUISound } from './sounds';
export type { SoundId } from './synthesizer';
function applyVolumes(audio: AudioManager, settings: GameSettings): void {
audio.setSoundEnabled(settings.soundEnabled);
audio.setMusicVolume(settings.musicVolume);
audio.setSfxVolume(settings.sfxVolume ?? 0.5);
}
function shouldPlayMusic(settings: GameSettings): boolean {
return settings.soundEnabled && settings.musicVolume > 0;
}
export function initAudioSystem(): () => void {
const audio = AudioManager.getInstance();
let gestureListenerActive = false;
const settings = useGameStore.getState().meta.settings;
applyVolumes(audio, settings);
// Music requires a user gesture to start (browser autoplay policy).
// We register a one-shot listener that starts music on first click/key.
const startOnGesture = () => {
const s = useGameStore.getState().meta.settings;
if (shouldPlayMusic(s)) {
audio.startMusic();
}
document.removeEventListener('click', startOnGesture);
document.removeEventListener('keydown', startOnGesture);
gestureListenerActive = false;
};
if (shouldPlayMusic(settings)) {
document.addEventListener('click', startOnGesture);
document.addEventListener('keydown', startOnGesture);
gestureListenerActive = true;
}
const unsub = useGameStore.subscribe((state) => {
const next = state.meta.settings;
applyVolumes(audio, next);
if (shouldPlayMusic(next)) {
if (!audio.isMusicPlaying) {
if (audio.isContextActive) {
audio.startMusic();
} else if (!gestureListenerActive) {
document.addEventListener('click', startOnGesture);
document.addEventListener('keydown', startOnGesture);
gestureListenerActive = true;
}
}
} else {
audio.stopMusic();
}
});
return () => {
unsub();
document.removeEventListener('click', startOnGesture);
document.removeEventListener('keydown', startOnGesture);
audio.dispose();
};
}
+145
View File
@@ -0,0 +1,145 @@
const CHORDS: number[][] = [
[130.81, 164.81, 196.00, 246.94], // Cmaj7: C3, E3, G3, B3
[110.00, 130.81, 164.81, 207.65], // Am7: A2, C3, E3, Ab3
[87.31, 110.00, 130.81, 164.81], // Fmaj7: F2, A2, C3, E3
[98.00, 123.47, 146.83, 174.61], // G7: G2, B2, D3, F3
];
const CHORD_DURATION = 32;
export class MusicEngine {
private ctx: AudioContext;
private dest: AudioNode;
private oscs: OscillatorNode[] = [];
private gains: GainNode[] = [];
private shimmerOsc: OscillatorNode | null = null;
private shimmerGain: GainNode | null = null;
private shimmerLfo: OscillatorNode | null = null;
private filter: BiquadFilterNode | null = null;
private filterLfo: OscillatorNode | null = null;
private filterLfoGain: GainNode | null = null;
private chordIndex = 0;
private timer: ReturnType<typeof setInterval> | null = null;
private running = false;
constructor(ctx: AudioContext, dest: AudioNode) {
this.ctx = ctx;
this.dest = dest;
}
start(): void {
if (this.running) return;
this.running = true;
this.filter = this.ctx.createBiquadFilter();
this.filter.type = 'lowpass';
this.filter.frequency.value = 1400;
this.filter.Q.value = 0.5;
this.filter.connect(this.dest);
// Filter LFO
this.filterLfo = this.ctx.createOscillator();
this.filterLfoGain = this.ctx.createGain();
this.filterLfo.type = 'sine';
this.filterLfo.frequency.value = 0.01;
this.filterLfoGain.gain.value = 600;
this.filterLfo.connect(this.filterLfoGain).connect(this.filter.frequency);
this.filterLfo.start();
// Base chord oscillators with breathing LFOs
const chord = CHORDS[0];
for (let i = 0; i < chord.length; i++) {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const lfo = this.ctx.createOscillator();
const lfoGain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.value = chord[i];
gain.gain.value = 0.04;
lfo.type = 'sine';
lfo.frequency.value = 0.05 + i * 0.03;
lfoGain.gain.value = 0.03;
lfo.connect(lfoGain).connect(gain.gain);
osc.connect(gain).connect(this.filter!);
osc.start();
lfo.start();
this.oscs.push(osc, lfo);
this.gains.push(gain);
}
// Shimmer layer
this.shimmerOsc = this.ctx.createOscillator();
this.shimmerGain = this.ctx.createGain();
this.shimmerLfo = this.ctx.createOscillator();
const shimmerLfoGain = this.ctx.createGain();
this.shimmerOsc.type = 'triangle';
this.shimmerOsc.frequency.value = 523.25; // C5
this.shimmerGain.gain.value = 0.02;
this.shimmerLfo.type = 'sine';
this.shimmerLfo.frequency.value = 0.02;
shimmerLfoGain.gain.value = 0.018;
this.shimmerLfo.connect(shimmerLfoGain).connect(this.shimmerGain.gain);
this.shimmerOsc.connect(this.shimmerGain).connect(this.filter!);
this.shimmerOsc.start();
this.shimmerLfo.start();
this.oscs.push(this.shimmerLfo);
this.chordIndex = 0;
this.timer = setInterval(() => this.nextChord(), CHORD_DURATION * 1000);
}
private nextChord(): void {
this.chordIndex = (this.chordIndex + 1) % CHORDS.length;
const chord = CHORDS[this.chordIndex];
const now = this.ctx.currentTime;
// Only the first 4 oscs are chord tones (every other is an LFO)
for (let i = 0; i < chord.length; i++) {
const osc = this.oscs[i * 2]; // chord oscs are at even indices
osc.frequency.cancelScheduledValues(now);
osc.frequency.setValueAtTime(osc.frequency.value, now);
osc.frequency.linearRampToValueAtTime(chord[i], now + 2);
}
// Shift shimmer to match root at higher octave
if (this.shimmerOsc) {
const shimmerFreq = chord[0] * 4;
this.shimmerOsc.frequency.cancelScheduledValues(now);
this.shimmerOsc.frequency.setValueAtTime(this.shimmerOsc.frequency.value, now);
this.shimmerOsc.frequency.linearRampToValueAtTime(shimmerFreq, now + 2);
}
}
stop(): void {
if (!this.running) return;
this.running = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
for (const osc of this.oscs) {
try { osc.stop(); } catch { /* already stopped */ }
}
if (this.shimmerOsc) {
try { this.shimmerOsc.stop(); } catch { /* already stopped */ }
}
this.oscs = [];
this.gains = [];
this.shimmerOsc = null;
this.shimmerGain = null;
this.shimmerLfo = null;
this.filter = null;
this.filterLfo = null;
this.filterLfoGain = null;
}
}
+54
View File
@@ -0,0 +1,54 @@
import { AudioManager } from './AudioManager';
import type { SoundId } from './synthesizer';
export type { SoundId } from './synthesizer';
const NOTIFICATION_SOUND_MAP: Record<string, SoundId> = {
'Training Complete': 'success-major',
'Research Complete': 'success',
'Model Deployed': 'success-major',
'Variant Deployed': 'success',
'Cluster Online': 'success',
'Campus Ready': 'success',
'Data Center Online': 'success',
'Retrofit Complete': 'success',
'Campus Retrofit Complete': 'success',
'Breakthrough!': 'success-major',
'Variant Created': 'success',
'Model Open Sourced': 'purchase',
'Loss Spike': 'warning',
'Training Instability': 'warning',
'Hardware Failure': 'warning',
'Network Switch Failure': 'warning',
'Safety Incident!': 'danger',
'Core Network Failure': 'danger',
'Data Contamination': 'danger',
'Training Started': 'info',
'Quantization Started': 'info',
'Pre-training Complete': 'info',
'SFT Complete': 'info',
'Achievement Unlocked!': 'achievement',
'Era Transition!': 'era',
};
const TYPE_FALLBACK: Record<string, SoundId> = {
success: 'success',
warning: 'warning',
danger: 'danger',
info: 'info',
};
export function triggerNotificationSound(n: { title: string; type: string }): void {
const soundId = NOTIFICATION_SOUND_MAP[n.title] ?? TYPE_FALLBACK[n.type];
if (soundId) {
AudioManager.getInstance().playSfx(soundId);
}
}
export function playUISound(soundId: SoundId = 'click'): void {
AudioManager.getInstance().playSfx(soundId);
}
+267
View File
@@ -0,0 +1,267 @@
export type SoundId =
| 'click'
| 'success'
| 'success-major'
| 'warning'
| 'danger'
| 'purchase'
| 'achievement'
| 'era'
| 'info';
export function playSound(ctx: AudioContext, dest: AudioNode, id: SoundId): void {
const fn = SOUNDS[id];
if (fn) fn(ctx, dest);
}
type SynthFn = (ctx: AudioContext, dest: AudioNode) => void;
const SOUNDS: Record<SoundId, SynthFn> = {
click: playClick,
success: playSuccess,
'success-major': playSuccessMajor,
warning: playWarning,
danger: playDanger,
purchase: playPurchase,
achievement: playAchievement,
era: playEra,
info: playInfo,
};
function playClick(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = 800;
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
osc.connect(gain).connect(dest);
osc.start(now);
osc.stop(now + 0.06);
}
function playSuccess(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const notes = [523.25, 659.25]; // C5, E5
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
const start = now + i * 0.1;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(0.25, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.15);
osc.connect(gain).connect(dest);
osc.start(start);
osc.stop(start + 0.16);
});
}
function playSuccessMajor(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const notes = [523.25, 659.25, 783.99]; // C5, E5, G5
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 2000;
osc.type = 'sawtooth';
osc.frequency.value = freq;
const start = now + i * 0.09;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(0.2, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.2);
osc.connect(filter).connect(gain).connect(dest);
osc.start(start);
osc.stop(start + 0.22);
});
}
function playWarning(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const osc1 = ctx.createOscillator();
const osc2 = ctx.createOscillator();
const gain = ctx.createGain();
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
osc1.type = 'square';
osc1.frequency.value = 300;
osc2.type = 'square';
osc2.frequency.value = 305;
lfo.type = 'sine';
lfo.frequency.value = 8;
lfoGain.gain.value = 0.15;
lfo.connect(lfoGain).connect(gain.gain);
gain.gain.setValueAtTime(0.15, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
osc1.connect(gain);
osc2.connect(gain);
gain.connect(dest);
osc1.start(now);
osc2.start(now);
lfo.start(now);
osc1.stop(now + 0.26);
osc2.stop(now + 0.26);
lfo.stop(now + 0.26);
}
function playDanger(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
// Sub-bass hit
const sub = ctx.createOscillator();
const subGain = ctx.createGain();
sub.type = 'sine';
sub.frequency.value = 150;
subGain.gain.setValueAtTime(0.3, now);
subGain.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
sub.connect(subGain).connect(dest);
sub.start(now);
sub.stop(now + 0.36);
// Noise burst via oscillator with rapid detuning
const noise = ctx.createOscillator();
const noiseGain = ctx.createGain();
const noiseFilter = ctx.createBiquadFilter();
noise.type = 'sawtooth';
noise.frequency.value = 80;
noise.detune.value = 1200;
noiseFilter.type = 'lowpass';
noiseFilter.frequency.value = 400;
noiseGain.gain.setValueAtTime(0.2, now);
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
noise.connect(noiseFilter).connect(noiseGain).connect(dest);
noise.start(now);
noise.stop(now + 0.16);
}
function playPurchase(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
// Pitch slide
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(1200, now);
osc.frequency.exponentialRampToValueAtTime(800, now + 0.1);
gain.gain.setValueAtTime(0.2, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
osc.connect(gain).connect(dest);
osc.start(now);
osc.stop(now + 0.13);
// Short noise tick
const tick = ctx.createOscillator();
const tickGain = ctx.createGain();
tick.type = 'square';
tick.frequency.value = 3000;
tickGain.gain.setValueAtTime(0.1, now);
tickGain.gain.exponentialRampToValueAtTime(0.001, now + 0.02);
tick.connect(tickGain).connect(dest);
tick.start(now);
tick.stop(now + 0.03);
}
function playAchievement(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const notes = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
const start = now + i * 0.1;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(0.22, start + 0.02);
gain.gain.setValueAtTime(0.22, start + 0.08);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.25);
osc.connect(gain).connect(dest);
osc.start(start);
osc.stop(start + 0.26);
});
// Echo layer
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
const start = now + i * 0.1 + 0.15;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(0.07, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.3);
osc.connect(gain).connect(dest);
osc.start(start);
osc.stop(start + 0.31);
});
}
function playEra(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
// Low sine sweep
const sweep = ctx.createOscillator();
const sweepGain = ctx.createGain();
sweep.type = 'sine';
sweep.frequency.setValueAtTime(100, now);
sweep.frequency.exponentialRampToValueAtTime(400, now + 0.6);
sweepGain.gain.setValueAtTime(0, now);
sweepGain.gain.linearRampToValueAtTime(0.25, now + 0.1);
sweepGain.gain.setValueAtTime(0.25, now + 0.5);
sweepGain.gain.exponentialRampToValueAtTime(0.001, now + 0.8);
sweep.connect(sweepGain).connect(dest);
sweep.start(now);
sweep.stop(now + 0.81);
// High shimmer
const shimmer = ctx.createOscillator();
const shimmerGain = ctx.createGain();
const shimmerFilter = ctx.createBiquadFilter();
shimmer.type = 'sawtooth';
shimmer.frequency.value = 2000;
shimmerFilter.type = 'highpass';
shimmerFilter.frequency.value = 4000;
shimmerGain.gain.setValueAtTime(0, now + 0.2);
shimmerGain.gain.linearRampToValueAtTime(0.08, now + 0.35);
shimmerGain.gain.exponentialRampToValueAtTime(0.001, now + 0.7);
shimmer.connect(shimmerFilter).connect(shimmerGain).connect(dest);
shimmer.start(now + 0.2);
shimmer.stop(now + 0.71);
// Impact
const impact = ctx.createOscillator();
const impactGain = ctx.createGain();
impact.type = 'sine';
impact.frequency.value = 200;
impactGain.gain.setValueAtTime(0.3, now + 0.6);
impactGain.gain.exponentialRampToValueAtTime(0.001, now + 1.0);
impact.connect(impactGain).connect(dest);
impact.start(now + 0.6);
impact.stop(now + 1.01);
}
function playInfo(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 880;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.18, now + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
osc.connect(gain).connect(dest);
osc.start(now);
osc.stop(now + 0.09);
}
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar';
import { ToastContainer } from '@/components/common/ToastContainer';
@@ -5,6 +6,7 @@ import { DevMenu } from '@/components/dev/DevMenu';
import { useGameStore } from '@/store';
import { useHashRouter } from '@/hooks/useHashRouter';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import { initAudioSystem } from '@/audio';
import { DashboardPage } from '@/pages/DashboardPage';
import { InfrastructurePage } from '@/pages/InfrastructurePage';
import { ResearchPage } from '@/pages/ResearchPage';
@@ -23,6 +25,7 @@ import { InvitationsPage } from '@/pages/InvitationsPage';
export function MainLayout() {
const { subPath, setSubPath } = useHashRouter();
useKeyboardShortcuts();
useEffect(() => initAudioSystem(), []);
const activePage = useGameStore((s) => s.activePage);
return (
+25 -1
View File
@@ -75,6 +75,10 @@ export function SettingsPage() {
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, musicVolume: v } } });
};
const setSfxVolume = (v: number) => {
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, sfxVolume: v } } });
};
const handleReset = () => {
localStorage.removeItem('token-empire-save');
window.location.reload();
@@ -232,7 +236,26 @@ export function SettingsPage() {
<ToggleSwitch checked={settings.soundEnabled} onChange={toggleSound} />
</div>
<div className="flex items-center justify-between">
<div className={`flex items-center justify-between ${!settings.soundEnabled ? 'opacity-40' : ''}`}>
<div>
<div className="text-sm">SFX Volume</div>
<div className="text-xs text-surface-400">Sound effects level</div>
</div>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={100}
value={(settings.sfxVolume ?? 0.5) * 100}
onChange={(e) => setSfxVolume(Number(e.target.value) / 100)}
disabled={!settings.soundEnabled}
className="w-32 accent-accent"
/>
<span className="text-sm font-mono text-surface-400 w-8 text-right">{Math.round((settings.sfxVolume ?? 0.5) * 100)}%</span>
</div>
</div>
<div className={`flex items-center justify-between ${!settings.soundEnabled ? 'opacity-40' : ''}`}>
<div>
<div className="text-sm">Music Volume</div>
<div className="text-xs text-surface-400">Background music level</div>
@@ -244,6 +267,7 @@ export function SettingsPage() {
max={100}
value={settings.musicVolume * 100}
onChange={(e) => setMusicVolume(Number(e.target.value) / 100)}
disabled={!settings.soundEnabled}
className="w-32 accent-accent"
/>
<span className="text-sm font-mono text-surface-400 w-8 text-right">{Math.round(settings.musicVolume * 100)}%</span>
+10 -6
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { triggerNotificationSound } from '@/audio/sounds';
import type {
GameState, Era, GameSpeed, GameSettings,
EconomyState, InfrastructureState, ComputeState,
@@ -313,12 +314,15 @@ export const useGameStore = create<Store>()(
setModelsTab: (tab) => set({ modelsTab: tab }),
addNotification: (n) => set((s) => ({
notifications: [
{ ...n, id: uuid(), read: false },
...s.notifications.slice(0, 49),
],
})),
addNotification: (n) => {
set((s) => ({
notifications: [
{ ...n, id: uuid(), read: false },
...s.notifications.slice(0, 49),
],
}));
triggerNotificationSound(n);
},
dismissNotification: (id) => set((s) => ({
notifications: s.notifications.map(n =>
+2
View File
@@ -45,11 +45,13 @@ export type GameSpeed = 1 | 2 | 5;
export interface GameSettings {
soundEnabled: boolean;
musicVolume: number;
sfxVolume: number;
}
export const INITIAL_SETTINGS: GameSettings = {
soundEnabled: true,
musicVolume: 0.5,
sfxVolume: 0.5,
};
export const SAVE_VERSION = 10;