/* ========================================================= audio.js — звук. Ретро-эффекты синтезируются на лету через Web Audio API (осцилляторы + огибающая), музыка подключается отдельным файлом assets/music.mp3 (его можно заменить своим). ========================================================= */ 'use strict'; class AudioManager { constructor() { this.ctx = null; // AudioContext (создаётся по первому жесту) this.masterGain = null; this.sfxEnabled = true; this.musicEnabled = true; this.muted = false; this.gamePaused = false; // на паузе музыка не звучит, даже если её включить this.volume = 0.7; this.musicEl = document.getElementById('bg-music'); if (this.musicEl) this.musicEl.volume = CONFIG.AUDIO.MUSIC_VOLUME * this.volume; } /** Ленивая инициализация — вызывается после первого действия пользователя. */ _ensureContext() { if (this.ctx) return; const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return; this.ctx = new AC(); this.masterGain = this.ctx.createGain(); this.masterGain.gain.value = CONFIG.AUDIO.SFX_VOLUME * this.volume; this.masterGain.connect(this.ctx.destination); } resume() { this._ensureContext(); if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume(); } setVolume(v) { this.volume = Utils.clamp(v, 0, 1); if (this.masterGain) this.masterGain.gain.value = this.muted ? 0 : CONFIG.AUDIO.SFX_VOLUME * this.volume; if (this.musicEl) this.musicEl.volume = this.muted ? 0 : CONFIG.AUDIO.MUSIC_VOLUME * this.volume; } toggleSfx() { this.sfxEnabled = !this.sfxEnabled; return this.sfxEnabled; } toggleMusic() { this.musicEnabled = !this.musicEnabled; if (this.musicEnabled) this.playMusic(); else this.stopMusic(); return this.musicEnabled; } /** Общий выключатель музыки и эффектов без потери их настроек. */ toggleMute() { this.muted = !this.muted; if (this.masterGain) { this.masterGain.gain.value = this.muted ? 0 : CONFIG.AUDIO.SFX_VOLUME * this.volume; } if (this.musicEl) { this.musicEl.volume = this.muted ? 0 : CONFIG.AUDIO.MUSIC_VOLUME * this.volume; if (this.muted) this.pauseMusic(); else if (this.musicEnabled) this.playMusic(); } return !this.muted; } /** Игра на паузе — музыка молчит, но настройка «музыка вкл» сохраняется. */ setGamePaused(paused) { this.gamePaused = !!paused; if (this.gamePaused) this.pauseMusic(); } /** Продолжить воспроизведение с текущей позиции. */ playMusic() { if (this.muted || this.gamePaused || !this.musicEnabled || !this.musicEl) return; this.musicEl.volume = CONFIG.AUDIO.MUSIC_VOLUME * this.volume; const promise = this.musicEl.play(); if (promise && promise.catch) promise.catch(() => { /* автоплей заблокирован — ждём жеста */ }); } /** Начать песню с самого начала (новый забег). */ restartMusic() { if (!this.musicEl) return; try { this.musicEl.currentTime = 0; } catch (e) { /* ещё не готово */ } this.playMusic(); } /** Пауза с сохранением позиции (продолжится с того же места). */ pauseMusic() { if (this.musicEl) this.musicEl.pause(); } /** Полная остановка (возврат в меню): пауза + сброс в начало. */ stopMusic() { if (!this.musicEl) return; this.musicEl.pause(); try { this.musicEl.currentTime = 0; } catch (e) { /* ignore */ } } /** * Базовый синтез тона с огибающей ADSR-lite. * @param {Object} o {type, freq, freqEnd, dur, vol, attack, decay} */ _tone(o) { if (!this.sfxEnabled) return; this._ensureContext(); if (!this.ctx) return; const now = this.ctx.currentTime; const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = o.type || 'square'; osc.frequency.setValueAtTime(o.freq, now); if (o.freqEnd) osc.frequency.exponentialRampToValueAtTime(o.freqEnd, now + o.dur); const vol = (o.vol != null ? o.vol : 0.3); const atk = o.attack != null ? o.attack : 0.005; gain.gain.setValueAtTime(0.0001, now); gain.gain.exponentialRampToValueAtTime(vol, now + atk); gain.gain.exponentialRampToValueAtTime(0.0001, now + o.dur); osc.connect(gain); gain.connect(this.masterGain); osc.start(now); osc.stop(now + o.dur + 0.02); } /** Короткий шум (для всплеска/столкновения). */ _noise(dur, vol, filterFreq) { if (!this.sfxEnabled) return; this._ensureContext(); if (!this.ctx) return; const now = this.ctx.currentTime; const bufferSize = Math.floor(this.ctx.sampleRate * dur); const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1; const src = this.ctx.createBufferSource(); src.buffer = buffer; const filter = this.ctx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.value = filterFreq || 1200; const gain = this.ctx.createGain(); gain.gain.setValueAtTime(vol || 0.3, now); gain.gain.exponentialRampToValueAtTime(0.0001, now + dur); src.connect(filter); filter.connect(gain); gain.connect(this.masterGain); src.start(now); src.stop(now + dur); } /* -------- Конкретные игровые звуки -------- */ jump() { this._tone({ type: 'square', freq: 420, freqEnd: 720, dur: 0.16, vol: 0.28 }); } doubleJump() { this._tone({ type: 'square', freq: 560, freqEnd: 980, dur: 0.16, vol: 0.28 }); setTimeout(() => this._tone({ type: 'triangle', freq: 880, freqEnd: 1200, dur: 0.14, vol: 0.22 }), 60); } coin() { this._tone({ type: 'square', freq: 880, dur: 0.07, vol: 0.24 }); setTimeout(() => this._tone({ type: 'square', freq: 1320, dur: 0.12, vol: 0.24 }), 70); } slide() { this._noise(0.22, 0.18, 900); } hit() { this._tone({ type: 'sawtooth', freq: 240, freqEnd: 60, dur: 0.4, vol: 0.35 }); this._noise(0.35, 0.3, 700); } button() { this._tone({ type: 'square', freq: 700, freqEnd: 940, dur: 0.08, vol: 0.22 }); } hover() { this._tone({ type: 'triangle', freq: 1180, dur: 0.04, vol: 0.09 }); } district() { // Приятный аккорд-арпеджио при получении района [523, 659, 784, 1046].forEach((f, i) => setTimeout(() => this._tone({ type: 'triangle', freq: f, dur: 0.18, vol: 0.22 }), i * 70)); } win() { const notes = [523, 659, 784, 1046, 784, 1046, 1318]; notes.forEach((f, i) => setTimeout(() => this._tone({ type: 'square', freq: f, dur: 0.22, vol: 0.26 }), i * 140)); } }