// ══════════════════════════════════════════════════════════════ // Audio — процедурные SFX + фоновая музыка (Web Audio API) // ══════════════════════════════════════════════════════════════ const Audio = { ctx: null, _master: null, _musicGain: null, // отдельный узел для музыки — глушится при смене темы _menuBgm: null, // HTML-аудио для mainmenu.mp3 _musicSeqId: 0, currentTheme: null, muted: false, _volume: 0.6, _lastStep: 0, // ── Инициализация ───────────────────────────────────────── init() { // Создать HTML-элемент для MP3 меню (не требует AudioContext) if (!this._menuBgm) { const el = document.createElement('audio'); el.src = 'mainmenu.mp3'; el.loop = true; el.volume = this._volume; el.muted = this.muted; this._menuBgm = el; } if (this.ctx) return; try { this.ctx = new (window.AudioContext || window.webkitAudioContext)(); this._master = this.ctx.createGain(); this._master.gain.value = this._volume; this._master.connect(this.ctx.destination); // Отдельный гейн для музыкальных нот (мгновенно глушится при смене темы) this._musicGain = this.ctx.createGain(); this._musicGain.gain.value = 1; this._musicGain.connect(this._master); } catch(e) { console.warn('Web Audio недоступен:', e); } }, toggleMute() { if (!this.ctx) return; this.muted = !this.muted; this._master.gain.value = this.muted ? 0 : this._volume; if (this._menuBgm) this._menuBgm.muted = this.muted; const btn = document.getElementById('btn-mute'); if (btn) btn.textContent = this.muted ? '🔇' : '🔊'; }, setVolume(v) { this._volume = Math.max(0, Math.min(1, v)); if (this._master && !this.muted) this._master.gain.value = this._volume; if (this._menuBgm) this._menuBgm.volume = this._volume; }, // ── Низкоуровневый синтез ────────────────────────────────── _note(freq, startTime, dur, type, gainVal, dest, filterFreq) { if (!this.ctx || this.muted) return; const osc = this.ctx.createOscillator(); const g = this.ctx.createGain(); osc.type = type || 'sine'; osc.frequency.setValueAtTime(freq, startTime); const atk = 0.01; const rel = Math.min(dur * 0.4, 0.15); g.gain.setValueAtTime(0, startTime); g.gain.linearRampToValueAtTime(gainVal, startTime + atk); g.gain.setValueAtTime(gainVal, startTime + dur - rel); g.gain.exponentialRampToValueAtTime(0.0001, startTime + dur); if (filterFreq) { const f = this.ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = filterFreq; osc.connect(f); f.connect(g); } else { osc.connect(g); } g.connect(dest || this._master); osc.start(startTime); osc.stop(startTime + dur + 0.01); }, _noise(startTime, dur, gainVal, filterFreq) { if (!this.ctx || this.muted) return; const bufLen = Math.ceil(this.ctx.sampleRate * dur); const buf = this.ctx.createBuffer(1, bufLen, this.ctx.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < bufLen; i++) data[i] = Math.random() * 2 - 1; const src = this.ctx.createBufferSource(); src.buffer = buf; const g = this.ctx.createGain(); g.gain.setValueAtTime(gainVal, startTime); g.gain.exponentialRampToValueAtTime(0.0001, startTime + dur); const f = this.ctx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = filterFreq || 800; f.Q.value = 0.5; src.connect(f); f.connect(g); g.connect(this._master); src.start(startTime); src.stop(startTime + dur + 0.01); }, // ── SFX ─────────────────────────────────────────────────── playHit(crit) { if (!this.ctx) return; const t = this.ctx.currentTime; const mul = crit ? 1.6 : 1; // удар — нисходящий шум + низкий удар this._noise(t, 0.08, crit ? 0.3 : 0.18, 1200 * mul); this._note(180 * mul, t, 0.1, 'sawtooth', 0.18, null, 400); this._note(90, t + 0.04, 0.12, 'sine', 0.25, null, 300); if (crit) { // дополнительный хруст для крита this._note(440, t, 0.05, 'square', 0.12); this._note(330, t + 0.05, 0.08, 'square', 0.1); } }, playSpell(type) { if (!this.ctx) return; const t = this.ctx.currentTime; switch (type) { case 'fire': { // восходящий пламенный свист const osc = this.ctx.createOscillator(); const g = this.ctx.createGain(); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(200, t); osc.frequency.exponentialRampToValueAtTime(900, t + 0.35); g.gain.setValueAtTime(0.15, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.35); osc.connect(g); g.connect(this._master); osc.start(t); osc.stop(t + 0.36); this._noise(t, 0.25, 0.1, 1800); break; } case 'ice': { // нисходящий кристальный звон [1046, 784, 523, 392].forEach((f, i) => { this._note(f, t + i * 0.06, 0.18, 'sine', 0.12); }); break; } case 'heal': { // мажорный аккорд [261, 329, 392, 523].forEach((f, i) => { this._note(f, t + i * 0.04, 0.4, 'sine', 0.1); }); break; } case 'magic': default: { // арпеджио вверх-вниз const notes = [261, 329, 392, 523, 392, 329]; notes.forEach((f, i) => { this._note(f, t + i * 0.07, 0.1, 'triangle', 0.12); }); break; } } }, playStep() { if (!this.ctx) return; const now = Date.now(); if (now - this._lastStep < 250) return; // дроссель this._lastStep = now; const t = this.ctx.currentTime; this._noise(t, 0.04, 0.04, 300); this._note(80, t, 0.04, 'sine', 0.06, null, 200); }, playLevelUp() { if (!this.ctx) return; const t = this.ctx.currentTime; // восходящее арпеджио C-E-G-C' [261, 329, 392, 523].forEach((f, i) => { this._note(f, t + i * 0.12, 0.2, 'triangle', 0.15); this._note(f * 2, t + i * 0.12 + 0.06, 0.1, 'sine', 0.07); }); // завершение — аккорд [523, 659, 784].forEach(f => { this._note(f, t + 0.6, 0.5, 'sine', 0.1); }); }, playVictory() { if (!this.ctx) return; const t = this.ctx.currentTime; // небольшая фанфара const mel = [392, 392, 392, 523, 392, 523, 659]; const durs = [0.15, 0.15, 0.15, 0.4, 0.15, 0.15, 0.6]; let pos = 0; mel.forEach((f, i) => { this._note(f, t + pos, durs[i] * 0.9, 'triangle', 0.18); this._note(f / 2, t + pos, durs[i] * 0.9, 'sine', 0.08); pos += durs[i]; }); }, playDeath() { if (!this.ctx) return; const t = this.ctx.currentTime; // нисходящий минорный аккорд [220, 261, 311].forEach((f, i) => { const osc = this.ctx.createOscillator(); const g = this.ctx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(f, t); osc.frequency.exponentialRampToValueAtTime(f * 0.5, t + 1.2); g.gain.setValueAtTime(0.15, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 1.2); osc.connect(g); g.connect(this._master); osc.start(t); osc.stop(t + 1.25); }); this._noise(t, 0.3, 0.08, 200); }, playOpenChest() { if (!this.ctx) return; const t = this.ctx.currentTime; // позвякивание [784, 1046, 1318, 1046, 1318, 1568].forEach((f, i) => { this._note(f, t + i * 0.07, 0.15, 'triangle', 0.1); }); }, // ── Музыкальные темы ────────────────────────────────────── THEMES: { // ── Главное меню: тёмная эпическая баллада, A-минор ── menu: { bpm: 58, notes: [ // Фраза 1 — вступление (A3 → E4) [220, 2, 0.070], // A3 [0, 0.5, 0 ], [196, 0.5, 0.055], // G3 [220, 1, 0.065], // A3 [0, 0.5, 0 ], [261, 1.5, 0.070], // C4 [329, 2, 0.080], // E4 // Фраза 2 — подъём к кульминации (G4 → A4) [0, 0.5, 0 ], [392, 1, 0.075], // G4 [440, 1.5, 0.090], // A4 — кульминация // Фраза 3 — спуск (G4 → C4) [0, 0.5, 0 ], [392, 0.5, 0.065], // G4 [349, 0.5, 0.060], // F4 [329, 1, 0.070], // E4 [293, 0.5, 0.060], // D4 [261, 2, 0.070], // C4 // Фраза 4 — разрешение (B3 → A3) [0, 0.5, 0 ], [246, 0.5, 0.055], // B3 [220, 3, 0.080], // A3 — финал [0, 2, 0 ], ], bass: [ [55, 4, 0.085], // A1 — тоника [65, 4, 0.075], // C2 — параллельный мажор [82, 4, 0.080], // E2 — доминанта [73, 4, 0.075], // D2 — субдоминанта [55, 6, 0.080], // A1 — разрешение ], }, village: { bpm: 80, notes: [ // C мажорная пентатоника: C D E G A C' [261,1,0.055],[294,0.5,0.045],[329,1,0.055],[0,0.5,0], [392,0.5,0.045],[440,1,0.065],[523,1,0.055],[0,0.5,0], [440,0.5,0.04],[392,0.5,0.04],[329,1,0.05],[0,0.5,0], [294,0.5,0.04],[261,1.5,0.055],[0,2,0], ], bass: [ [65,2,0.06],[65,2,0.05],[73,2,0.06],[65,2,0.05], ] }, forest: { bpm: 100, notes: [ // a минор арпеджио [220,0.5,0.06],[261,0.5,0.05],[329,0.5,0.06],[261,0.5,0.05], [247,0.5,0.06],[294,0.5,0.05],[370,0.5,0.065],[294,0.5,0.05], [220,0.5,0.06],[261,0.5,0.055],[329,1,0.06],[0,1,0], [196,0.5,0.055],[220,0.5,0.05],[261,0.5,0.06],[220,0.5,0.05], [196,0.5,0.055],[174,0.5,0.05],[220,1.5,0.065],[0,1,0], ], bass: [ [55,1,0.07],[55,1,0.06],[62,1,0.07],[55,1,0.06], [49,1,0.07],[49,1,0.06],[55,2,0.065], ] }, dungeon: { bpm: 50, notes: [ // мрачный хроматический дрон [130,3,0.07],[0,1,0],[116,2,0.06],[0,2,0], [138,3,0.065],[0,1,0],[123,2,0.06],[0,3,0], [146,2,0.07],[130,2,0.065],[0,4,0], ], bass: [ [32,4,0.09],[32,4,0.08],[36,4,0.09],[32,8,0.07], ] }, swamp: { bpm: 60, notes: [ // жуткая хроматика, нерегулярный ритм [185,0.5,0.05],[196,1,0.06],[0,0.5,0],[174,0.5,0.05], [0,1.5,0],[185,0.5,0.065],[207,1,0.055],[185,0.5,0.05], [0,2,0],[174,0.5,0.06],[164,2,0.055],[0,2,0], [155,0.5,0.05],[0,0.5,0],[164,1.5,0.065],[0,3,0], ], bass: [ [46,3,0.08],[0,1,0],[41,3,0.07],[0,2,0],[43,4,0.075],[0,3,0], ] }, mountain: { bpm: 55, notes: [ // медленный эпичный минор [220,2,0.07],[196,1,0.06],[174,1,0.065],[0,1,0], [185,2,0.07],[220,2,0.065],[0,2,0], [261,1.5,0.075],[246,0.5,0.065],[220,2,0.07],[0,1,0], [196,1,0.065],[174,1,0.06],[185,3,0.07],[0,2,0], ], bass: [ [55,4,0.09],[46,4,0.085],[49,4,0.09],[55,4,0.085], ] }, combat: { bpm: 145, notes: [ // быстрый staccato минор [220,0.5,0.08],[0,0.5,0],[261,0.5,0.075],[0,0.5,0], [196,0.5,0.08],[220,1,0.085],[0,0.5,0], [165,0.5,0.075],[185,0.5,0.08],[0,0.5,0],[220,0.5,0.075], [0,0.5,0],[246,0.5,0.08],[220,1,0.085],[0,1,0], [174,0.5,0.075],[196,0.5,0.08],[0,0.5,0],[220,0.5,0.075], [0,0.5,0],[196,0.5,0.08],[174,1.5,0.085],[0,1,0], ], bass: [ [55,0.5,0.1],[0,0.5,0],[55,0.5,0.09],[0,0.5,0], [55,0.5,0.1],[0,0.5,0],[55,0.5,0.09],[0,0.5,0], [49,0.5,0.1],[0,0.5,0],[49,0.5,0.09],[0,0.5,0], [49,0.5,0.1],[0,0.5,0],[55,1,0.095],[0,1,0], ] }, ruins: { bpm: 45, notes: [ // мрачная эолийская гамма, редкие ноты — атмосфера разрушенного замка [110,3,0.055],[0,2,0],[98,2,0.05],[0,3,0], [116,3,0.06],[110,2,0.05],[0,3,0], [92,2,0.055],[0,2,0],[104,4,0.06],[0,4,0], [110,2,0.05],[0,2,0],[98,3,0.055],[0,3,0], ], bass: [ [27,6,0.075],[0,2,0],[24,6,0.07],[0,4,0], [29,6,0.075],[0,2,0],[27,4,0.065],[0,6,0], ] }, cave: { bpm: 55, notes: [ // тёмная пещерная атмосфера [138,3,0.06],[0,2,0],[123,2,0.055],[0,3,0], [146,3,0.065],[138,2,0.055],[0,4,0], [116,2,0.06],[0,2,0],[130,3,0.06],[0,3,0], ], bass: [ [34,5,0.08],[0,3,0],[30,5,0.075],[0,4,0], [36,5,0.08],[0,3,0],[34,4,0.07],[0,5,0], ] }, abyss: { bpm: 40, notes: [ // зловещий хроматический дрейф [41,6,0.09],[0,2,0],[37,5,0.08],[0,3,0], [44,4,0.07],[0,4,0],[39,6,0.09],[0,2,0], [34,5,0.075],[0,3,0],[41,4,0.08],[0,4,0], ], bass: [ [20,8,0.1],[0,4,0],[18,8,0.09],[0,4,0], [22,8,0.1],[0,4,0],[20,6,0.09],[0,6,0], ] }, }, // ── Воспроизведение музыки ──────────────────────────────── playTheme(name) { if (this.currentTheme === name) return; // Тема 'menu' управляется через #menu-bgm в HTML напрямую if (name === 'menu') { // Остановить процедурную музыку при переходе в меню if (this._musicGain) { this._musicGain.disconnect(); if (this.ctx) { this._musicGain = this.ctx.createGain(); this._musicGain.gain.value = 1; this._musicGain.connect(this._master); } } this._musicSeqId++; this.currentTheme = 'menu'; return; } // ── Процедурные темы — требуют AudioContext ───────────── if (!this.ctx) return; // Остановить MP3 меню if (this._menuBgm && !this._menuBgm.paused) { this._menuBgm.pause(); this._menuBgm.currentTime = 0; } // Отключить старый musicGain — мгновенно глушит все запланированные ноты if (this._musicGain) { this._musicGain.disconnect(); } this._musicGain = this.ctx.createGain(); this._musicGain.gain.value = 1; this._musicGain.connect(this._master); this.currentTheme = name; this._musicSeqId++; const id = this._musicSeqId; const theme = this.THEMES[name]; if (!theme) return; this._scheduleMelody(theme.notes, theme.bpm, id, false); if (theme.bass) this._scheduleMelody(theme.bass, theme.bpm, id, true); }, stopMusic() { if (this._menuBgm && !this._menuBgm.paused) { this._menuBgm.pause(); this._menuBgm.currentTime = 0; } if (this._musicGain) { this._musicGain.disconnect(); if (this.ctx) { this._musicGain = this.ctx.createGain(); this._musicGain.gain.value = 1; this._musicGain.connect(this._master); } } this._musicSeqId++; this.currentTheme = null; }, _scheduleMelody(notes, bpm, loopId, isBass) { if (this._musicSeqId !== loopId) return; if (!this.ctx || this.muted) { // музыка заглушена — перепланировать через секунду const beat = 60 / bpm; const totalMs = notes.reduce((s, n) => s + n[1], 0) * beat * 1000; setTimeout(() => this._scheduleMelody(notes, bpm, loopId, isBass), totalMs); return; } let t = this.ctx.currentTime + 0.06; // совпадает с задержкой _musicGain восстановления const beat = 60 / bpm; notes.forEach(([freq, beats, gainVal]) => { if (freq > 0 && gainVal > 0) { const dur = beats * beat * 0.88; // ноты идут через _musicGain, а не напрямую в _master this._note(freq, t, dur, isBass ? 'triangle' : 'sine', gainVal, this._musicGain || null, isBass ? 300 : null); } t += beats * beat; }); const totalMs = notes.reduce((s, n) => s + n[1], 0) * beat * 1000; setTimeout(() => this._scheduleMelody(notes, bpm, loopId, isBass), totalMs + 30); }, };