Initial commit: RPG game project

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-02-25 01:01:02 +03:00
commit ac1f348311
24 changed files with 13329 additions and 0 deletions

479
audio.js Normal file
View 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);
},
};