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)
185 lines
6.7 KiB
JavaScript
185 lines
6.7 KiB
JavaScript
/**
|
||
* GameAudioManager — менеджер аудио для проектов в плеере (GD и др.).
|
||
*
|
||
* Хранит один AudioContext на сессию плеера, поддерживает:
|
||
* - playSfx(name) — проигрывает один из 9 SFX (jump/death/orb_tap/...)
|
||
* - playMusic(trackId) — играет фоновую музыку (синтез fallback / mp3 если есть)
|
||
* - stopMusic()
|
||
* - muteMusic(bool)
|
||
*
|
||
* AudioContext создаётся лениво при первом playSfx/playMusic. Чтобы он не был
|
||
* suspended из-за autoplay policy, плеер вызывает unlock() при первом клике
|
||
* (см. KubikonPlayer.jsx).
|
||
*
|
||
* SFX и музыка — те же фабрики что в /admin-preview/gd-sfx и /admin-preview/gd-music.
|
||
* Реальные mp3 (если положены в /public/music/gd/) играются вместо fallback-синтеза.
|
||
*/
|
||
import { SFX_CATALOG } from '../AdminPreview/gdSfx/sfxFactories';
|
||
import { TRACKS } from '../AdminPreview/gdMusic/musicCatalog';
|
||
import { SynthPlayer, trackFileExists } from '../AdminPreview/gdMusic/musicSynth';
|
||
|
||
export class GameAudioManager {
|
||
constructor() {
|
||
this.ctx = null;
|
||
this.masterGain = null;
|
||
this.sfxIndex = {};
|
||
for (const s of SFX_CATALOG) this.sfxIndex[s.id] = s;
|
||
this.trackIndex = {};
|
||
for (const t of TRACKS) this.trackIndex[t.id] = t;
|
||
|
||
this.currentTrackId = null;
|
||
this.audioEl = null; // <audio> для mp3 трека (если файл есть)
|
||
this.synth = null; // SynthPlayer fallback
|
||
this.muted = false;
|
||
this.fileStatusCache = new Map();
|
||
this._pendingMusic = null; // если playMusic вызван до unlock — отложим
|
||
|
||
// Глобальный first-gesture listener — браузеры блокируют AudioContext
|
||
// до первого клика. Слушаем pointerdown/keydown/touchstart на window.
|
||
this._unlockHandler = () => {
|
||
this.unlock().then((ok) => {
|
||
if (ok && this._pendingMusic) {
|
||
const tid = this._pendingMusic;
|
||
this._pendingMusic = null;
|
||
this.playMusic(tid);
|
||
}
|
||
});
|
||
window.removeEventListener('pointerdown', this._unlockHandler);
|
||
window.removeEventListener('keydown', this._unlockHandler);
|
||
window.removeEventListener('touchstart', this._unlockHandler);
|
||
};
|
||
try {
|
||
window.addEventListener('pointerdown', this._unlockHandler, { once: false });
|
||
window.addEventListener('keydown', this._unlockHandler, { once: false });
|
||
window.addEventListener('touchstart', this._unlockHandler, { once: false });
|
||
} catch (e) { /* SSR / no window */ }
|
||
}
|
||
|
||
/** Создать AudioContext (или вернуть существующий). Должен вызываться внутри
|
||
* user-gesture цепочки (например onClick). */
|
||
async unlock() {
|
||
if (!this.ctx) {
|
||
const Ctor = window.AudioContext || window.webkitAudioContext;
|
||
if (!Ctor) return false;
|
||
this.ctx = new Ctor();
|
||
this.masterGain = this.ctx.createGain();
|
||
this.masterGain.gain.value = 0.21; // 0.7 × 0.3 (–70% по запросу юзера)
|
||
this.masterGain.connect(this.ctx.destination);
|
||
}
|
||
if (this.ctx.state === 'suspended') {
|
||
try { await this.ctx.resume(); } catch (e) { /* ignore */ }
|
||
}
|
||
return this.ctx.state === 'running';
|
||
}
|
||
|
||
/** Проиграть короткий SFX (синхронно, без задержки). */
|
||
playSfx(name) {
|
||
if (this.muted) return;
|
||
const sfx = this.sfxIndex[name];
|
||
if (!sfx || !this.ctx || !this.masterGain) return;
|
||
try {
|
||
sfx.play(this.ctx, this.masterGain);
|
||
} catch (e) {
|
||
console.warn('[GameAudio] sfx failed', name, e);
|
||
}
|
||
}
|
||
|
||
/** Запустить фоновую музыку. Если уже играет тот же трек — ничего.
|
||
* Если AudioContext ещё не разблокирован (нет user-gesture) — откладываем
|
||
* до первого клика (см. _unlockHandler). */
|
||
async playMusic(trackId) {
|
||
if (this.currentTrackId === trackId) return;
|
||
if (!this.ctx) {
|
||
// AudioContext ещё не создан — ждём первого user-gesture
|
||
this._pendingMusic = trackId;
|
||
return;
|
||
}
|
||
this.stopMusic();
|
||
const track = this.trackIndex[trackId];
|
||
if (!track) {
|
||
console.warn('[GameAudio] unknown track:', trackId);
|
||
return;
|
||
}
|
||
this.currentTrackId = trackId;
|
||
if (this.muted) return;
|
||
|
||
// Проверка наличия mp3-файла (с кешем)
|
||
let fileExists = this.fileStatusCache.get(trackId);
|
||
if (fileExists === undefined) {
|
||
try { fileExists = await trackFileExists(track.file); }
|
||
catch (e) { fileExists = false; }
|
||
this.fileStatusCache.set(trackId, fileExists);
|
||
}
|
||
|
||
if (fileExists) {
|
||
// Играем настоящий файл
|
||
try {
|
||
const a = new Audio(track.file);
|
||
a.loop = true;
|
||
a.volume = 0.165; // 0.55 × 0.3 (–70%)
|
||
await a.play();
|
||
this.audioEl = a;
|
||
} catch (e) {
|
||
console.warn('[GameAudio] audio.play() failed, fallback to synth', e);
|
||
this._startSynth(track);
|
||
}
|
||
} else {
|
||
this._startSynth(track);
|
||
}
|
||
}
|
||
|
||
_startSynth(track) {
|
||
try {
|
||
this.synth = new SynthPlayer(track.fallbackSynth, track.bpm);
|
||
// SynthPlayer создаёт свой AudioContext — ок, не конфликтует с нашим
|
||
this.synth.start();
|
||
} catch (e) {
|
||
console.warn('[GameAudio] synth.start() failed', e);
|
||
}
|
||
}
|
||
|
||
stopMusic() {
|
||
if (this.audioEl) {
|
||
try { this.audioEl.pause(); } catch (e) {}
|
||
this.audioEl = null;
|
||
}
|
||
if (this.synth) {
|
||
try { this.synth.stop(); } catch (e) {}
|
||
this.synth = null;
|
||
}
|
||
this.currentTrackId = null;
|
||
}
|
||
|
||
setMuted(mute) {
|
||
this.muted = !!mute;
|
||
if (this.muted) {
|
||
// временно глушим — но не стираем currentTrackId
|
||
if (this.audioEl) {
|
||
try { this.audioEl.volume = 0; } catch (e) {}
|
||
}
|
||
if (this.synth) {
|
||
const wasId = this.currentTrackId;
|
||
this.stopMusic();
|
||
this.currentTrackId = wasId;
|
||
}
|
||
} else if (this.currentTrackId) {
|
||
const tid = this.currentTrackId;
|
||
this.currentTrackId = null;
|
||
this.playMusic(tid);
|
||
}
|
||
}
|
||
|
||
dispose() {
|
||
this.stopMusic();
|
||
try {
|
||
window.removeEventListener('pointerdown', this._unlockHandler);
|
||
window.removeEventListener('keydown', this._unlockHandler);
|
||
window.removeEventListener('touchstart', this._unlockHandler);
|
||
} catch (e) {}
|
||
if (this.ctx) {
|
||
try { this.ctx.close(); } catch (e) {}
|
||
this.ctx = null;
|
||
}
|
||
}
|
||
}
|