fix(player): задача 05 — красивый экран загрузки ИГРЫ при входе (а не в студии)

Главное по задаче 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>
This commit is contained in:
min 2026-06-07 19:46:20 +03:00
parent 247a5703c9
commit f5a96fbec0
2 changed files with 221 additions and 39 deletions

View File

@ -0,0 +1,198 @@
/**
* 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>
);
}

View File

@ -22,6 +22,7 @@ import { useAuth } from '../auth/PlayerAuth';
import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
import useDeviceType from '../hooks/useDeviceType';
import KubikonMobileControls from './KubikonMobileControls';
import GameLoadingScreen from './GameLoadingScreen';
// Плеер живёт на player.rublox.pro он не знает SPA-роутов Майнкрафтии
// (/kubikon, /login, /auth). Поэтому вместо navigate(...) делаем
@ -216,6 +217,9 @@ const KubikonPlayer = () => {
const [forbidden, setForbidden] = useState(false);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
// Задача 05: конфиг красивого экрана загрузки (из project_data.scene.loadingScreen).
const [loadingScreenCfg, setLoadingScreenCfg] = useState(null);
const [loadProgress, setLoadProgress] = useState(0);
// Раньше была стартовая заглушка «тапни чтобы начать» убрали по
// фидбэку, она бесила. Теперь fullscreen опционально через кнопку
// в углу. Этот state остался для совместимости с handleMobileStart.
@ -551,11 +555,18 @@ const KubikonPlayer = () => {
setMeta(data);
setLikesCount(data.likes_count || 0);
setDislikesCount(data.dislikes_count || 0);
setLoadProgress(0.3);
if (data.project_data) {
const parsed = JSON.parse(data.project_data);
initialStateRef.current = parsed;
// Задача 05: красивый экран загрузки конфиг автора (если задан в студии).
try {
const lsc = parsed?.scene?.loadingScreen;
if (lsc && typeof lsc === 'object' && lsc.enabled !== false) setLoadingScreenCfg(lsc);
} catch (e) { /* ignore */ }
await scene.loadFromState(parsed);
setLoadProgress(0.7);
}
// Ждём пока Babylon реально загрузит и скомпилит все
@ -592,6 +603,7 @@ const KubikonPlayer = () => {
skinFolderRef.current = mySkin;
try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
setLoadProgress(1);
setLoading(false);
// Засчитываем плей. Передаём user_id (если залогинен)
// это активирует self-cooldown (автор не накручивает себе)
@ -970,6 +982,11 @@ const KubikonPlayer = () => {
// Очищаем ref'ы иначе следующий connectMultiplayer выйдет
// на if (mpSyncRef.current || roomRef.current) return.
try { sync.stop?.(); } catch (e) {}
// ВАЖНО: dispose() сносит ВСЕ старые меши remote-игроков со
// сцены. Без этого при auto-reconnect (Colyseus rejoin) новый
// MultiplayerSync видит пустую Map и при +remote создаёт
// дубль-меш на каждый кадр (см. фикс 2026-06-05).
try { sync.dispose?.(); } catch (e) {}
mpSyncRef.current = null;
roomRef.current = null;
// Code 1000 / 1001 нормальное закрытие. Code >= 4000 наш
@ -1133,46 +1150,13 @@ const KubikonPlayer = () => {
outline: 'none',
}}
/>
{/* Loading-оверлей */}
{/* Задача 05: красивый экран загрузки игры (Ken Burns + название места + автор). */}
{loading && (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background:
'radial-gradient(ellipse at center, rgba(51,87,255,0.22) 0%, rgba(7,10,20,0.96) 60%)',
gap: 18, color: HUD.text,
}}>
<div style={{
position: 'relative',
animation: 'hudFloat 3s ease-in-out infinite',
}}>
<div style={{
position: 'absolute', inset: -10,
borderRadius: 20,
animation: 'hudPulseRing 1.6s ease-out infinite',
}} />
<RublocsLogo size={72} />
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
fontSize: 15, fontWeight: 700, letterSpacing: 0.3,
}}>
<div style={{
width: 14, height: 14,
border: `2.5px solid ${HUD.accentBg}`,
borderTopColor: HUD.accent,
borderRadius: '50%',
animation: 'hudSpin 0.8s linear infinite',
}} />
Загрузка игры
</div>
<div style={{
fontSize: 11, color: HUD.textDim,
textTransform: 'uppercase', letterSpacing: 1.4, fontWeight: 700,
}}>
Рублокс 3D
</div>
</div>
<GameLoadingScreen
meta={meta}
loadingScreen={loadingScreenCfg}
progress={loadProgress}
/>
)}
{/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */}