/** * SoundLibrary — библиотека пользовательских звуков проекта (Фаза 5.5). * * Зачем: даёт автору загрузить свои короткие звуки (mp3/wav/ogg) и * проигрывать их в игре через game.sound.* — в т.ч. как 3D-позиционный * звук от точки в мире. * * Как хранится: каждый звук — { id, name, dataUrl }, где dataUrl — * base64 аудио. Сериализуется в scene.sounds, едет в БД с проектом * (своего файлового хранилища у Рублокс-веба нет — как картинки в * AssetManager). * * Ограничения (звук в base64 раздувает JSON проекта): * MAX_SOUNDS — не больше 8 звуков на проект; * MAX_BYTES — не больше 2 МБ на звук (mp3 ~30-60 сек; для коротких * фоновых треков и эффектов хватает). */ export const MAX_SOUNDS = 8; export const MAX_SOUND_BYTES = 2 * 1024 * 1024; let _soundIdSeq = 1; export class SoundLibrary { constructor() { // Map id → { id, name, dataUrl } this.sounds = new Map(); } /** Все звуки массивом — для UI-панели. */ list() { return [...this.sounds.values()]; } get(id) { return this.sounds.get(id) || null; } /** dataURL по id — то, что отдаём в Audio / decodeAudioData. */ getDataUrl(id) { const s = this.sounds.get(id); return s ? s.dataUrl : null; } count() { return this.sounds.size; } /** * Загрузить звук из File (input type=file). * Возвращает Promise<{ ok, id?, error? }>. */ addFromFile(file) { return new Promise((resolve) => { if (this.sounds.size >= MAX_SOUNDS) { resolve({ ok: false, error: `Лимит ${MAX_SOUNDS} звуков на проект` }); return; } if (!file || !/^audio\//.test(file.type)) { resolve({ ok: false, error: 'Это не звуковой файл' }); return; } // Грубая проверка размера ДО чтения (base64 ~+33% сверху). if (file.size > MAX_SOUND_BYTES) { resolve({ ok: false, error: `Звук слишком большой (макс ${Math.round(MAX_SOUND_BYTES / 1024)} КБ)`, }); return; } const reader = new FileReader(); reader.onerror = () => resolve({ ok: false, error: 'Не удалось прочитать файл' }); reader.onload = () => { const dataUrl = reader.result; if (typeof dataUrl !== 'string') { resolve({ ok: false, error: 'Битый файл' }); return; } if (dataUrl.length > MAX_SOUND_BYTES * 1.4) { resolve({ ok: false, error: 'Звук слишком большой' }); return; } const name = (file.name || 'звук').replace(/\.[^.]+$/, ''); const id = this.add(name, dataUrl); resolve({ ok: true, id }); }; reader.readAsDataURL(file); }); } /** Добавить звук с готовым dataUrl. Возвращает id. */ add(name, dataUrl) { const id = `sound_${_soundIdSeq++}`; this.sounds.set(id, { id, name: name || 'звук', dataUrl }); return id; } rename(id, name) { const s = this.sounds.get(id); if (s) s.name = name || s.name; } remove(id) { this.sounds.delete(id); } // ============ STATE ============ serialize() { return this.list().map(s => ({ id: s.id, name: s.name, dataUrl: s.dataUrl, })); } load(data) { this.sounds.clear(); if (!Array.isArray(data)) return; let maxNum = 0; for (const s of data) { if (!s || typeof s.id !== 'string' || typeof s.dataUrl !== 'string') continue; this.sounds.set(s.id, { id: s.id, name: typeof s.name === 'string' ? s.name : 'звук', dataUrl: s.dataUrl, }); const m = /^sound_(\d+)$/.exec(s.id); if (m) maxNum = Math.max(maxNum, Number(m[1])); } if (maxNum >= _soundIdSeq) _soundIdSeq = maxNum + 1; } dispose() { this.sounds.clear(); } }