From d609934b73934dc045579c90886f78d77a7a0a2d Mon Sep 17 00:00:00 2001 From: josh Date: Tue, 28 Apr 2026 20:11:50 -0400 Subject: [PATCH] Add working sound effects and background music via Web Audio API 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 --- apps/web/src/audio/AudioManager.ts | 126 +++++++++ apps/web/src/audio/index.ts | 69 +++++ apps/web/src/audio/musicEngine.ts | 145 ++++++++++ apps/web/src/audio/sounds.ts | 54 ++++ apps/web/src/audio/synthesizer.ts | 267 ++++++++++++++++++ apps/web/src/components/layout/MainLayout.tsx | 3 + apps/web/src/pages/SettingsPage.tsx | 26 +- apps/web/src/store/index.ts | 16 +- packages/shared/src/types/gameState.ts | 2 + 9 files changed, 701 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/audio/AudioManager.ts create mode 100644 apps/web/src/audio/index.ts create mode 100644 apps/web/src/audio/musicEngine.ts create mode 100644 apps/web/src/audio/sounds.ts create mode 100644 apps/web/src/audio/synthesizer.ts 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;