player/src/engine/AudioManager.js
МИН 87444ee2c8 Initial public release: Rublox Player v1.0
Open-source web player for Rublox games, dual-licensed under
AGPL-3.0 + Commercial.

Highlights:
- Babylon.js 7 + React 18 + Vite 5 stack
- Self-contained engine (~46k lines): BlockManager, ModelManager,
  PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD
  gamemodes
- Configurable backend via VITE_API_BASE and friends — works against
  staging (dev-api.rublox.pro) out of the box
- Standalone mode (VITE_STANDALONE=true) loads a bundled sample game
  for first-run without any backend
- Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG
- Lint + format scaffolding (ESLint + Prettier + EditorConfig)
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Removed before public release:
- frontend_deploy.py (contained production SSH credentials)
- ~27 admin endpoints (kept in private repo)
- Hard-coded internal URLs and IPs
- All previous git history (clean repo init)
2026-05-27 23:04:04 +03:00

386 lines
14 KiB
JavaScript
Raw 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.

/**
* AudioManager — фоновый звук сцены через Web Audio API.
*
* Все звуки генерируются ПРОЦЕДУРНО — никаких mp3-файлов не нужно.
*
* Каналы:
* ambient — длительный фон (ветер/лес/вода/пещера) через шум + фильтры
* music — короткие мелодии-петли через oscillator (минорный/мажорный лад)
*
* Воспроизводится только в Play-режиме.
*/
export const AMBIENT_PRESETS = [
{ id: 'none', name: 'Без звука' },
{ id: 'forest', name: 'Лес' },
{ id: 'wind', name: 'Ветер' },
{ id: 'water', name: 'Вода' },
{ id: 'cave', name: 'Пещера' },
];
export const MUSIC_PRESETS = [
{ id: 'none', name: 'Без музыки' },
{ id: 'calm', name: 'Спокойная' },
{ id: 'happy', name: 'Весёлая' },
{ id: 'epic', name: 'Эпическая' },
{ id: 'mystery', name: 'Загадочная' },
];
// Ноты в Hz (4-я октава для калькуляций)
const NOTES = {
C: 261.63, 'C#': 277.18, D: 293.66, 'D#': 311.13,
E: 329.63, F: 349.23, 'F#': 369.99, G: 392.00,
'G#': 415.30, A: 440.00, 'A#': 466.16, B: 493.88,
};
function n(note, octave = 4) {
return NOTES[note] * Math.pow(2, octave - 4);
}
// Простые мелодии — массив [нота, октава, длительность в "тиках" по 0.25с]
const MELODIES = {
calm: {
bpm: 60,
notes: [
['C',5,2],['E',5,2],['G',5,2],['E',5,2],
['F',5,2],['A',5,2],['G',5,4],
['D',5,2],['F',5,2],['A',5,2],['F',5,2],
['E',5,2],['G',5,2],['C',5,4],
],
},
happy: {
bpm: 120,
notes: [
['C',5,1],['E',5,1],['G',5,1],['C',6,1],
['G',5,1],['E',5,1],['C',5,2],
['F',5,1],['A',5,1],['C',6,1],['F',6,1],
['C',6,1],['A',5,1],['F',5,2],
],
},
epic: {
bpm: 80,
notes: [
['A',3,2],['E',4,2],['A',4,2],['C',5,2],
['B',4,2],['G',4,2],['E',4,4],
['F',4,2],['A',4,2],['C',5,2],['E',5,2],
['D',5,2],['B',4,2],['A',4,4],
],
},
mystery: {
bpm: 70,
notes: [
['A',4,3],['C',5,1],['E',5,2],['F',5,2],
['E',5,2],['D',5,2],['C',5,4],
['G',4,3],['B',4,1],['D',5,2],['F',5,2],
['E',5,2],['C',5,2],['A',4,4],
],
},
};
export class AudioManager {
constructor() {
this.ambientId = 'none';
this.ambientVolume = 0.3;
this.musicId = 'none';
this.musicVolume = 0.25;
this._ctx = null;
this._ambientNodes = [];
this._musicTimer = null;
this._musicNodes = [];
this._playing = false;
}
setAmbient({ preset, volume } = {}) {
if (preset !== undefined) this.ambientId = preset;
if (volume !== undefined) this.ambientVolume = Math.max(0, Math.min(1, volume));
if (this._playing) this._restartAmbient();
}
setMusic({ preset, volume } = {}) {
if (preset !== undefined) this.musicId = preset;
if (volume !== undefined) this.musicVolume = Math.max(0, Math.min(1, volume));
if (this._playing) this._restartMusic();
}
start() {
this._ensureContext();
if (!this._ctx) return;
// Браузеры требуют user-gesture перед воспроизведением.
// Кнопка Play даёт его — resume() работает.
if (this._ctx.state === 'suspended') {
this._ctx.resume().catch(() => {});
}
this._playing = true;
this._restartAmbient();
this._restartMusic();
}
stop() {
this._playing = false;
this._stopAmbient();
this._stopMusic();
}
_ensureContext() {
if (this._ctx) return;
try {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
this._ctx = new Ctx();
} catch (e) { /* ignore */ }
}
// ============ AMBIENT ============
_restartAmbient() {
this._stopAmbient();
if (this.ambientId === 'none' || !this._ctx) return;
const ctx = this._ctx;
const dest = ctx.destination;
// Главный gain — общая громкость канала
const masterGain = ctx.createGain();
masterGain.gain.value = this.ambientVolume * 0.5;
masterGain.connect(dest);
if (this.ambientId === 'wind') {
// Розовый шум через белый шум + фильтр + медленная LFO для амплитуды
const noise = this._createWhiteNoise();
const filt = ctx.createBiquadFilter();
filt.type = 'lowpass';
filt.frequency.value = 800;
filt.Q.value = 0.8;
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
lfo.frequency.value = 0.15;
lfoGain.gain.value = 0.3;
lfo.connect(lfoGain);
const winGain = ctx.createGain();
winGain.gain.value = 0.5;
lfoGain.connect(winGain.gain);
noise.connect(filt);
filt.connect(winGain);
winGain.connect(masterGain);
noise.start();
lfo.start();
this._ambientNodes.push(noise, lfo, filt, lfoGain, winGain);
} else if (this.ambientId === 'forest') {
// Шум листьев + случайные «птицы» (короткие чирикания)
const noise = this._createWhiteNoise();
const filt = ctx.createBiquadFilter();
filt.type = 'highpass';
filt.frequency.value = 1500;
const ng = ctx.createGain();
ng.gain.value = 0.15;
noise.connect(filt);
filt.connect(ng);
ng.connect(masterGain);
noise.start();
this._ambientNodes.push(noise, filt, ng);
// Птицы каждые 3-7 секунд
this._scheduleBirds(masterGain);
} else if (this.ambientId === 'water') {
// Журчание — фильтрованный шум с медленным сдвигом частоты
const noise = this._createWhiteNoise();
const filt = ctx.createBiquadFilter();
filt.type = 'bandpass';
filt.frequency.value = 1200;
filt.Q.value = 1.2;
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
lfo.frequency.value = 0.5;
lfoGain.gain.value = 400;
lfo.connect(lfoGain);
lfoGain.connect(filt.frequency);
const wg = ctx.createGain();
wg.gain.value = 0.6;
noise.connect(filt);
filt.connect(wg);
wg.connect(masterGain);
noise.start();
lfo.start();
this._ambientNodes.push(noise, lfo, filt, lfoGain, wg);
} else if (this.ambientId === 'cave') {
// Низкий гул + редкие «капли»
const osc1 = ctx.createOscillator();
osc1.type = 'sine';
osc1.frequency.value = 60;
const osc2 = ctx.createOscillator();
osc2.type = 'sine';
osc2.frequency.value = 90;
const g = ctx.createGain();
g.gain.value = 0.4;
osc1.connect(g);
osc2.connect(g);
g.connect(masterGain);
osc1.start();
osc2.start();
this._ambientNodes.push(osc1, osc2, g);
this._scheduleDrips(masterGain);
}
this._ambientMaster = masterGain;
}
_stopAmbient() {
for (const n of this._ambientNodes) {
try { n.stop?.(); } catch (e) {}
try { n.disconnect?.(); } catch (e) {}
}
this._ambientNodes = [];
if (this._birdsTimer) { clearTimeout(this._birdsTimer); this._birdsTimer = null; }
if (this._dripsTimer) { clearTimeout(this._dripsTimer); this._dripsTimer = null; }
if (this._ambientMaster) { try { this._ambientMaster.disconnect(); } catch (e) {} this._ambientMaster = null; }
}
_createWhiteNoise() {
const ctx = this._ctx;
const bufSize = 2 * ctx.sampleRate;
const buf = ctx.createBuffer(1, bufSize, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1;
const src = ctx.createBufferSource();
src.buffer = buf;
src.loop = true;
return src;
}
_scheduleBirds(dest) {
if (!this._playing) return;
const ctx = this._ctx;
const delay = 2 + Math.random() * 5;
this._birdsTimer = setTimeout(() => {
if (!this._playing) return;
const t = ctx.currentTime;
const baseFreq = 1500 + Math.random() * 1500;
for (let i = 0; i < 3; i++) {
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(baseFreq * (1 + i * 0.1), t + i * 0.08);
const g = ctx.createGain();
g.gain.setValueAtTime(0, t + i * 0.08);
g.gain.linearRampToValueAtTime(0.15, t + i * 0.08 + 0.02);
g.gain.linearRampToValueAtTime(0, t + i * 0.08 + 0.1);
osc.connect(g);
g.connect(dest);
osc.start(t + i * 0.08);
osc.stop(t + i * 0.08 + 0.12);
}
this._scheduleBirds(dest);
}, delay * 1000);
}
_scheduleDrips(dest) {
if (!this._playing) return;
const ctx = this._ctx;
const delay = 1.5 + Math.random() * 4;
this._dripsTimer = setTimeout(() => {
if (!this._playing) return;
const t = ctx.currentTime;
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(800 + Math.random() * 400, t);
osc.frequency.exponentialRampToValueAtTime(200, t + 0.3);
const g = ctx.createGain();
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(0.2, t + 0.01);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
osc.connect(g);
g.connect(dest);
osc.start(t);
osc.stop(t + 0.5);
this._scheduleDrips(dest);
}, delay * 1000);
}
// ============ MUSIC ============
_restartMusic() {
this._stopMusic();
if (this.musicId === 'none' || !MELODIES[this.musicId] || !this._ctx) return;
const ctx = this._ctx;
const masterGain = ctx.createGain();
masterGain.gain.value = this.musicVolume * 0.4;
masterGain.connect(ctx.destination);
this._musicMaster = masterGain;
this._playMelodyLoop();
}
_stopMusic() {
if (this._musicTimer) { clearTimeout(this._musicTimer); this._musicTimer = null; }
for (const n of this._musicNodes) {
try { n.stop?.(); } catch (e) {}
try { n.disconnect?.(); } catch (e) {}
}
this._musicNodes = [];
if (this._musicMaster) { try { this._musicMaster.disconnect(); } catch (e) {} this._musicMaster = null; }
}
_playMelodyLoop() {
if (!this._playing) return;
const melody = MELODIES[this.musicId];
if (!melody) return;
const ctx = this._ctx;
const tickDur = 60 / melody.bpm / 4; // 16-я нота
let t = ctx.currentTime + 0.05;
let totalTime = 0;
for (const [note, octave, ticks] of melody.notes) {
const dur = ticks * tickDur;
const freq = n(note, octave);
this._scheduleNote(freq, t, dur * 0.95);
t += dur;
totalTime += dur;
}
// Перезапускаем мелодию по окончании
this._musicTimer = setTimeout(() => this._playMelodyLoop(), (totalTime + 0.5) * 1000);
}
_scheduleNote(freq, startTime, duration) {
const ctx = this._ctx;
const osc = ctx.createOscillator();
osc.type = 'triangle'; // мягкий тон
osc.frequency.value = freq;
const g = ctx.createGain();
// ADSR-огибающая
g.gain.setValueAtTime(0, startTime);
g.gain.linearRampToValueAtTime(0.4, startTime + 0.02);
g.gain.linearRampToValueAtTime(0.2, startTime + 0.1);
g.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
osc.connect(g);
g.connect(this._musicMaster);
osc.start(startTime);
osc.stop(startTime + duration + 0.05);
this._musicNodes.push(osc, g);
// Чистим старые ноды чтобы не накапливались
if (this._musicNodes.length > 200) {
this._musicNodes.splice(0, 100);
}
}
// ============ STATE ============
serialize() {
return {
ambientId: this.ambientId,
ambientVolume: this.ambientVolume,
musicId: this.musicId,
musicVolume: this.musicVolume,
};
}
load(data) {
if (!data) return;
if (typeof data.ambientId === 'string') this.ambientId = data.ambientId;
if (typeof data.ambientVolume === 'number') this.ambientVolume = data.ambientVolume;
if (typeof data.musicId === 'string') this.musicId = data.musicId;
if (typeof data.musicVolume === 'number') this.musicVolume = data.musicVolume;
}
dispose() {
this.stop();
if (this._ctx) {
try { this._ctx.close(); } catch (e) {}
this._ctx = null;
}
}
}