Большой консолидирующий коммит после поднятия 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>
194 lines
7.1 KiB
JavaScript
194 lines
7.1 KiB
JavaScript
/**
|
||
* GdMusicPreview — превью 20 треков GD-музыки.
|
||
*
|
||
* Маршрут: /admin-preview/gd-music
|
||
*
|
||
* Для каждого трека:
|
||
* - проверяет есть ли реальный mp3 в /music/gd/
|
||
* - если есть — играет файл через <audio>
|
||
* - если нет — играет процедурный fallback через Web Audio (SynthPlayer)
|
||
*
|
||
* Показывает BPM, длительность, эпоху, kind (main/boss).
|
||
* Одновременно играет только один трек.
|
||
*/
|
||
import React, { useEffect, useRef, useState } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useAuth } from '../auth/AuthContext.jsx';
|
||
import styles from './GdMusicPreview.module.css';
|
||
import { TRACKS, EPOCH_INFO } from './gdMusic/musicCatalog';
|
||
import { SynthPlayer, trackFileExists } from './gdMusic/musicSynth';
|
||
|
||
function formatDuration(sec) {
|
||
const m = Math.floor(sec / 60);
|
||
const s = sec % 60;
|
||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
function TrackCard({ track, isPlaying, onPlay, onStop, fileStatus }) {
|
||
const epoch = EPOCH_INFO[track.epoch - 1];
|
||
const isBoss = track.kind === 'boss';
|
||
|
||
return (
|
||
<div className={`${styles.card} ${isBoss ? styles.cardBoss : ''}`}>
|
||
<div className={styles.cardHeader} style={{ borderTopColor: epoch.color }}>
|
||
<span className={styles.epochBadge}>
|
||
{epoch.emoji} E{track.epoch}
|
||
</span>
|
||
<span className={isBoss ? styles.kindBoss : styles.kindMain}>
|
||
{isBoss ? '⚔ БОСС' : '♪ main'}
|
||
</span>
|
||
</div>
|
||
<div className={styles.cardBody}>
|
||
<div className={styles.title}>{track.title}</div>
|
||
<div className={styles.stats}>
|
||
<span><strong>{track.bpm}</strong> BPM</span>
|
||
<span>{formatDuration(track.durationSec)}</span>
|
||
<span className={fileStatus === 'file' ? styles.statusFile : styles.statusSynth}>
|
||
{fileStatus === 'file' ? '📁 файл' : (fileStatus === 'synth' ? '🎹 синтез' : '⋯')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className={styles.cardActions}>
|
||
{isPlaying ? (
|
||
<button className={styles.btnStop} onClick={onStop}>⏸ Стоп</button>
|
||
) : (
|
||
<button className={styles.btnPlay} onClick={onPlay}>▶ Играть</button>
|
||
)}
|
||
<code className={styles.trackId}>{track.id}</code>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GdMusicPreview() {
|
||
const { userRole, isLoading } = useAuth();
|
||
const navigate = useNavigate();
|
||
|
||
const [playingId, setPlayingId] = useState(null);
|
||
const [fileStatuses, setFileStatuses] = useState({}); // trackId → 'file' | 'synth' | undefined
|
||
const audioRef = useRef(null);
|
||
const synthRef = useRef(null);
|
||
|
||
// При монтировании — проверяем все mp3-файлы (HEAD-запросы)
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
const statuses = {};
|
||
// параллельно
|
||
await Promise.all(TRACKS.map(async (t) => {
|
||
const exists = await trackFileExists(t.file);
|
||
statuses[t.id] = exists ? 'file' : 'synth';
|
||
}));
|
||
if (!cancelled) setFileStatuses(statuses);
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
// Остановить всё
|
||
const stopAll = () => {
|
||
if (audioRef.current) {
|
||
audioRef.current.pause();
|
||
audioRef.current.currentTime = 0;
|
||
}
|
||
if (synthRef.current) {
|
||
synthRef.current.stop();
|
||
synthRef.current = null;
|
||
}
|
||
setPlayingId(null);
|
||
};
|
||
|
||
const play = async (track) => {
|
||
stopAll();
|
||
const status = fileStatuses[track.id];
|
||
setPlayingId(track.id);
|
||
if (status === 'file') {
|
||
const a = new Audio(track.file);
|
||
a.volume = 0.7;
|
||
a.loop = false;
|
||
audioRef.current = a;
|
||
a.onended = () => setPlayingId(curr => curr === track.id ? null : curr);
|
||
try { await a.play(); }
|
||
catch (e) { console.warn('[gd-music] audio.play() failed', e); }
|
||
} else {
|
||
const p = new SynthPlayer(track.fallbackSynth, track.bpm);
|
||
synthRef.current = p;
|
||
await p.start();
|
||
}
|
||
};
|
||
|
||
// Cleanup при unmount
|
||
useEffect(() => {
|
||
return () => stopAll();
|
||
}, []);
|
||
|
||
if (isLoading) return <div className={styles.loading}>Загрузка...</div>;
|
||
if (userRole !== 'admin') {
|
||
return (
|
||
<div className={styles.denied}>
|
||
<h2>Доступ только для администратора</h2>
|
||
<button onClick={() => navigate('/')} className={styles.backBtn}>На главную</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Группируем по эпохам
|
||
const tracksByEpoch = {};
|
||
for (const t of TRACKS) {
|
||
if (!tracksByEpoch[t.epoch]) tracksByEpoch[t.epoch] = [];
|
||
tracksByEpoch[t.epoch].push(t);
|
||
}
|
||
|
||
const filesFound = Object.values(fileStatuses).filter(s => s === 'file').length;
|
||
const synthCount = Object.values(fileStatuses).filter(s => s === 'synth').length;
|
||
|
||
return (
|
||
<div className={styles.root}>
|
||
<div className={styles.topbar}>
|
||
<h1 className={styles.h1}>GD-Музыка — превью {TRACKS.length} треков</h1>
|
||
<div className={styles.subline}>
|
||
{filesFound > 0 && <span>📁 файлов: {filesFound}</span>}
|
||
{synthCount > 0 && <span style={{ marginLeft: 16 }}>🎹 синтез: {synthCount} (Web Audio fallback)</span>}
|
||
<div style={{ marginTop: 6 }}>
|
||
Промпты для Suno: <code>RUBLOX_GD_MUSIC_PROMPTS.md</code> ·
|
||
Файлы класть в: <code>/public/music/gd/<trackId>.mp3</code>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{EPOCH_INFO.map(epoch => (
|
||
<div key={epoch.n} className={styles.epochSection}>
|
||
<h2 className={styles.epochTitle} style={{ color: epoch.color }}>
|
||
{epoch.emoji} Эпоха {epoch.n} — {epoch.name}
|
||
</h2>
|
||
<div className={styles.grid}>
|
||
{(tracksByEpoch[epoch.n] || []).map(track => (
|
||
<TrackCard
|
||
key={track.id}
|
||
track={track}
|
||
isPlaying={playingId === track.id}
|
||
onPlay={() => play(track)}
|
||
onStop={stopAll}
|
||
fileStatus={fileStatuses[track.id]}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
<div className={styles.footer}>
|
||
<p><strong>Как заменить fallback на реальную музыку:</strong></p>
|
||
<ol>
|
||
<li>Купить Suno Pro ($10/мес)</li>
|
||
<li>Скопировать промпт из <code>RUBLOX_GD_MUSIC_PROMPTS.md</code></li>
|
||
<li>Сгенерировать в Suno, скачать mp3</li>
|
||
<li>Положить в <code>/public/music/gd/<trackId>.mp3</code></li>
|
||
<li>Перезагрузить страницу → плеер автоматически подцепит файл</li>
|
||
</ol>
|
||
<p>Синтез нужен ровно для теста <strong>темпа</strong> (BPM правильный) пока нет реальных треков.</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default GdMusicPreview;
|