Add working sound effects and background music via Web Audio API
CI / build-and-push (push) Successful in 1m17s
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
Reference in New Issue
Block a user