Files
RPG_FromClaude/audio.js
Maxim Dolgolyov ac1f348311 Initial commit: RPG game project
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 01:01:02 +03:00

480 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ══════════════════════════════════════════════════════════════
// 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);
},
};