Initial commit: RPG game project
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
479
audio.js
Normal file
479
audio.js
Normal file
@@ -0,0 +1,479 @@
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user