Главное по задаче 05: переделан React loading-оверлей в KubikonPlayer (тот, что игрок видит после клика «Играть» пока грузится игра). Новый компонент GameLoadingScreen: Ken Burns фон + карточка-витрина + название места + автор + verified-галочка + прогресс-бар (реальный 0→100%) + спиннер. Данные: project_data.scene.loadingScreen (настройки автора из студии) → мета игры (title/thumbnail/автор) → дефолт. 0 ошибок, проверено headless. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
199 lines
10 KiB
JavaScript
199 lines
10 KiB
JavaScript
/**
|
||
* GameLoadingScreen — красивый экран загрузки игры в плеере (задача 05).
|
||
*
|
||
* Показывается пока грузится игра (после клика «Играть» на странице игры →
|
||
* открытие плеера). Композиция как в Roblox:
|
||
* - размытый фон-обложка игры с медленным Ken Burns (pan + zoom);
|
||
* - карточка-витрина по центру (обложка игры);
|
||
* - крупное название места;
|
||
* - автор + verified-галочка;
|
||
* - прогресс-бар + спиннер «ЗАГРУЗКА».
|
||
*
|
||
* Данные берёт из меты игры (title/thumbnail/автор) и, если автор настроил в
|
||
* студии вкладку «Стартовый экран» — из project_data.scene.loadingScreen
|
||
* (placeName / studioName / style / verified / background / cover).
|
||
*/
|
||
import React, { useEffect, useRef, useState } from 'react';
|
||
|
||
// Один раз вставляем CSS-keyframes (нельзя инлайнить в style).
|
||
let _cssInjected = false;
|
||
function injectCss() {
|
||
if (_cssInjected || typeof document === 'undefined') return;
|
||
_cssInjected = true;
|
||
const s = document.createElement('style');
|
||
s.id = 'kbn-game-loading-css';
|
||
s.textContent =
|
||
'@keyframes kbnGlsKen{0%{transform:scale(1.05) translate3d(0,0,0)}50%{transform:scale(1.15) translate3d(-3%,-2%,0)}100%{transform:scale(1.05) translate3d(-6%,0,0)}}' +
|
||
'.kbnGlsKen{animation:kbnGlsKen 22s ease-in-out infinite}' +
|
||
'@keyframes kbnGlsSpin{to{transform:rotate(360deg)}}' +
|
||
'.kbnGlsSpin{animation:kbnGlsSpin 0.85s linear infinite}' +
|
||
'@keyframes kbnGlsRise{0%{transform:translateY(0) scale(1);opacity:0}12%{opacity:.9}88%{opacity:.6}100%{transform:translateY(-110vh) scale(1.4);opacity:0}}' +
|
||
'.kbnGlsP{animation:kbnGlsRise linear infinite}' +
|
||
'@keyframes kbnGlsGlow{0%,100%{box-shadow:0 18px 60px rgba(0,0,0,.6),0 0 0 rgba(120,160,255,0)}50%{box-shadow:0 18px 60px rgba(0,0,0,.6),0 0 44px rgba(120,160,255,.4)}}' +
|
||
'.kbnGlsCard{animation:kbnGlsGlow 4s ease-in-out infinite}' +
|
||
'@keyframes kbnGlsBar{0%{transform:translateX(-100%)}100%{transform:translateX(250%)}}' +
|
||
'.kbnGlsBarRun{animation:kbnGlsBar 1.2s ease-in-out infinite}' +
|
||
'@media (prefers-reduced-motion:reduce){.kbnGlsKen,.kbnGlsP,.kbnGlsCard,.kbnGlsBarRun{animation:none}}';
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
function VerifiedBadge() {
|
||
return (
|
||
<svg width="18" height="18" viewBox="0 0 24 24" style={{ flex: '0 0 auto' }} aria-label="verified">
|
||
<circle cx="12" cy="12" r="11" fill="#3897f0" />
|
||
<path d="M7 12.5l3 3 7-7" fill="none" stroke="#fff" strokeWidth="2.4"
|
||
strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* props:
|
||
* meta — ответ getProjectForPlay (title, thumbnail, author_username/username, ...)
|
||
* loadingScreen — project_data.scene.loadingScreen (опц., настройки автора)
|
||
* progress — 0..1 (если null — «бегущая» полоса без процента)
|
||
*/
|
||
export default function GameLoadingScreen({ meta, loadingScreen, progress }) {
|
||
injectCss();
|
||
const ls = loadingScreen || {};
|
||
const [fade, setFade] = useState(0);
|
||
const rootRef = useRef(null);
|
||
|
||
useEffect(() => { const t = setTimeout(() => setFade(1), 20); return () => clearTimeout(t); }, []);
|
||
|
||
// Источники данных: настройки автора → мета игры → дефолт.
|
||
const bg = ls.background || meta?.thumbnail || null;
|
||
const cover = ls.cover || meta?.thumbnail || null;
|
||
const placeName = ls.placeName || meta?.title || 'Загрузка игры';
|
||
const studioName = ls.studioName
|
||
|| meta?.author_username || meta?.username || meta?.author || '';
|
||
const verified = ls.verified != null ? !!ls.verified
|
||
: !!(meta?.author_verified || meta?.is_verified);
|
||
const style = ls.style || 'ken-burns';
|
||
const accent = ls.accentColor || '#5fd0ff';
|
||
const hasProgress = typeof progress === 'number' && progress >= 0;
|
||
const pct = hasProgress ? Math.round(Math.max(0, Math.min(1, progress)) * 100) : null;
|
||
|
||
// parallax по мыши
|
||
const bgRef = useRef(null);
|
||
useEffect(() => {
|
||
if (style !== 'parallax' || !bgRef.current) return;
|
||
const h = (e) => {
|
||
const cx = (e.clientX / window.innerWidth - 0.5) * 26;
|
||
const cy = (e.clientY / window.innerHeight - 0.5) * 16;
|
||
if (bgRef.current) bgRef.current.style.transform = `translate3d(${-cx}px,${-cy}px,0) scale(1.1)`;
|
||
};
|
||
window.addEventListener('mousemove', h);
|
||
return () => window.removeEventListener('mousemove', h);
|
||
}, [style]);
|
||
|
||
const particles = style === 'particles'
|
||
? Array.from({ length: 24 }, (_, i) => {
|
||
const size = 2 + (i % 4);
|
||
const dur = 7 + (i % 7);
|
||
return (
|
||
<span key={i} className="kbnGlsP" style={{
|
||
position: 'absolute', bottom: -10, left: `${(i * 37) % 100}%`,
|
||
width: size, height: size, borderRadius: '50%',
|
||
background: `rgba(${180 + (i * 7) % 70},${190 + (i * 5) % 60},255,0.85)`,
|
||
boxShadow: `0 0 ${size * 2}px rgba(140,170,255,0.7)`,
|
||
animationDuration: `${dur}s`, animationDelay: `${-(i % 7)}s`,
|
||
}} />
|
||
);
|
||
}) : null;
|
||
|
||
return (
|
||
<div ref={rootRef} style={{
|
||
position: 'absolute', inset: 0, zIndex: 60, overflow: 'hidden',
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||
background: 'radial-gradient(ellipse at center, #0e1430 0%, #070a14 70%)',
|
||
opacity: fade, transition: 'opacity 0.4s ease',
|
||
fontFamily: 'system-ui,"Segoe UI",sans-serif',
|
||
}}>
|
||
{/* Фоновый слой (Ken Burns / parallax / static) */}
|
||
{bg && (
|
||
<div ref={bgRef}
|
||
className={style === 'ken-burns' ? 'kbnGlsKen' : undefined}
|
||
style={{
|
||
position: 'absolute', inset: '-8%', zIndex: 0,
|
||
backgroundImage: `url("${bg}")`, backgroundSize: 'cover', backgroundPosition: 'center',
|
||
filter: 'blur(9px) brightness(0.5)', willChange: 'transform',
|
||
transition: style === 'parallax' ? 'transform 0.25s ease-out' : 'none',
|
||
}} />
|
||
)}
|
||
{/* particles */}
|
||
{particles && <div style={{ position: 'absolute', inset: 0, zIndex: 1, pointerEvents: 'none' }}>{particles}</div>}
|
||
|
||
{/* Контент */}
|
||
<div style={{
|
||
position: 'relative', zIndex: 2, display: 'flex',
|
||
flexDirection: 'column', alignItems: 'center',
|
||
}}>
|
||
{/* Карточка-витрина */}
|
||
<div className="kbnGlsCard" style={{
|
||
width: 'min(40vw,300px)', aspectRatio: '1/1', borderRadius: 18,
|
||
backgroundImage: cover ? `url("${cover}")` : 'none',
|
||
backgroundColor: '#1a1f2b', backgroundSize: 'cover', backgroundPosition: 'center',
|
||
border: '2px solid rgba(255,255,255,0.14)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
}}>
|
||
{!cover && (
|
||
<span style={{ color: '#5a6178', fontSize: 14, fontWeight: 700 }}>РУБЛОКС • 3D</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Название места */}
|
||
<div style={{
|
||
marginTop: 22, color: '#fff', fontSize: 34, fontWeight: 800,
|
||
letterSpacing: 0.4, textAlign: 'center', maxWidth: '80vw',
|
||
textShadow: '0 3px 14px rgba(0,0,0,0.7)',
|
||
}}>{placeName}</div>
|
||
|
||
{/* Автор + verified */}
|
||
{studioName && (
|
||
<div style={{
|
||
marginTop: 8, display: 'flex', alignItems: 'center', gap: 7,
|
||
color: '#cdd6e6', fontSize: 16, fontWeight: 600,
|
||
textShadow: '0 1px 4px rgba(0,0,0,0.6)',
|
||
}}>
|
||
<span>{studioName}</span>
|
||
{verified && <VerifiedBadge />}
|
||
</div>
|
||
)}
|
||
|
||
{/* Прогресс-бар */}
|
||
<div style={{
|
||
marginTop: 26, width: 'min(64vw,420px)', height: 10, borderRadius: 6,
|
||
background: 'rgba(255,255,255,0.12)', overflow: 'hidden',
|
||
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.5)', position: 'relative',
|
||
}}>
|
||
{hasProgress ? (
|
||
<div style={{
|
||
height: '100%', width: `${pct}%`, borderRadius: 6,
|
||
background: `linear-gradient(90deg, ${accent}, #ffffff)`,
|
||
transition: 'width 0.2s linear', boxShadow: `0 0 10px ${accent}`,
|
||
}} />
|
||
) : (
|
||
<div className="kbnGlsBarRun" style={{
|
||
height: '100%', width: '40%', borderRadius: 6,
|
||
background: `linear-gradient(90deg, transparent, ${accent}, transparent)`,
|
||
}} />
|
||
)}
|
||
</div>
|
||
|
||
{/* Спиннер + статус */}
|
||
<div style={{
|
||
marginTop: 16, display: 'flex', alignItems: 'center', gap: 12,
|
||
color: '#fff', fontSize: 15, fontWeight: 700, letterSpacing: 0.5,
|
||
}}>
|
||
<span className="kbnGlsSpin" style={{
|
||
display: 'inline-block', width: 20, height: 20,
|
||
border: '3px solid rgba(255,255,255,0.25)', borderTopColor: accent, borderRadius: '50%',
|
||
}} />
|
||
{pct != null ? `${pct}%` : 'ЗАГРУЗКА'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|