Большой консолидирующий коммит после поднятия 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>
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 '../../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;
|
||
}
|
||
}
|
||
}
|