studio/src/editor/engine/GameAudioManager.js
МИН 61fba4e174
Some checks failed
CI / Lint + Format (push) Failing after 32s
CI / Build (push) Failing after 37s
CI / Secret scan (push) Failing after 37s
CI / PR size check (push) Has been skipped
fix: починка билда + studio.rublox.pro инфра
Большой консолидирующий коммит после поднятия studio.rublox.pro (28 мая 2026).
Содержит изменения которые делались в процессе подготовки прод-окружения:

Фиксы импортов после выноса из minecraftia:
- Массовая замена путей ../../components → ../components (40+ файлов в src/community/, src/admin-preview/)
- Замена ../KubikonEditor/ → ../editor/, ../KubikonStudio/ → ../community/, ../AdminPreview/ → ../admin-preview/
- API.js скопирован из минки целиком (было 8 экспортов, стало 312)
- Добавлены PLAYER_URL, MyButton_1, недостающие компоненты
- Заменены require() на статические ES-imports в BabylonScene, PrimitiveManager, GameRuntime (Vite не поддерживает CJS require)

Структура ассетов:
- public/kubikon-templates/ → public/assets/kubikon-templates/
- public/kubikon-learn/ → public/assets/kubikon-learn/
- (код искал в /assets/, файлы лежали без /assets/)

Навигация роутов внутри студии:
- /kubikon-studio/docs → /docs (90+ навигационных вызовов sed-replaced)
- /kubikon-editor/X → /edit/X, /kubikon/play/X → /play/X, /kubikon/gd/X → /gd/X

UI:
- Новый компонент StudioHeader (61px, как в минке) + копия favicon
- WithHeader wrapper в App.jsx для всех страниц кроме fullscreen-редактора/плеера
- SSO ticket-flow в AuthContext (auto-redeem #ticket= при загрузке)
- Тёмная тема карточек игр в ВИКИ (фон #1c2231 вместо #fff, картинка впритык)

Документация:
- docs/ONBOARDING.md — путь нового контрибьютора от нуля до PR
- docs/TUTORIAL_ADD_SCRIPT_API.md — как добавить game.* API
- API_USAGE.md — список эндпоинтов backend
- README в подпапках engine/, engine/terrain/, engine/voxel/, engine/robloxterrain/, engine/types/

.gitignore:
- public/wiki/ исключён (73МБ PNG, будут на CDN отдельной задачей)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 05:01:13 +03:00

185 lines
6.7 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.

/**
* 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 '../../admin-preview/gdSfx/sfxFactories';
import { TRACKS } from '../../admin-preview/gdMusic/musicCatalog';
import { SynthPlayer, trackFileExists } from '../../admin-preview/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;
}
}
}