diff --git a/apps/web/src/audio/AudioManager.ts b/apps/web/src/audio/AudioManager.ts new file mode 100644 index 0000000..61227a2 --- /dev/null +++ b/apps/web/src/audio/AudioManager.ts @@ -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(); + + 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; + } +} diff --git a/apps/web/src/audio/index.ts b/apps/web/src/audio/index.ts new file mode 100644 index 0000000..7fb6339 --- /dev/null +++ b/apps/web/src/audio/index.ts @@ -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(); + }; +} diff --git a/apps/web/src/audio/musicEngine.ts b/apps/web/src/audio/musicEngine.ts new file mode 100644 index 0000000..508f376 --- /dev/null +++ b/apps/web/src/audio/musicEngine.ts @@ -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 | 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; + } +} diff --git a/apps/web/src/audio/sounds.ts b/apps/web/src/audio/sounds.ts new file mode 100644 index 0000000..679654b --- /dev/null +++ b/apps/web/src/audio/sounds.ts @@ -0,0 +1,54 @@ +import { AudioManager } from './AudioManager'; +import type { SoundId } from './synthesizer'; + +export type { SoundId } from './synthesizer'; + +const NOTIFICATION_SOUND_MAP: Record = { + '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 = { + 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); +} diff --git a/apps/web/src/audio/synthesizer.ts b/apps/web/src/audio/synthesizer.ts new file mode 100644 index 0000000..89477ef --- /dev/null +++ b/apps/web/src/audio/synthesizer.ts @@ -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 = { + 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); +} diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index 570511c..a68cf8a 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -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 ( diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 1dbe067..f9fb204 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -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() { -
+
+
+
SFX Volume
+
Sound effects level
+
+
+ setSfxVolume(Number(e.target.value) / 100)} + disabled={!settings.soundEnabled} + className="w-32 accent-accent" + /> + {Math.round((settings.sfxVolume ?? 0.5) * 100)}% +
+
+ +
Music Volume
Background music level
@@ -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" /> {Math.round(settings.musicVolume * 100)}% diff --git a/apps/web/src/store/index.ts b/apps/web/src/store/index.ts index f220ac1..1600277 100644 --- a/apps/web/src/store/index.ts +++ b/apps/web/src/store/index.ts @@ -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()( 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 => diff --git a/packages/shared/src/types/gameState.ts b/packages/shared/src/types/gameState.ts index 05f1502..c3cea67 100644 --- a/packages/shared/src/types/gameState.ts +++ b/packages/shared/src/types/gameState.ts @@ -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;