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)
386 lines
14 KiB
JavaScript
386 lines
14 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
}
|
||
}
|