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:
parent
247a5703c9
commit
f5a96fbec0
198
src/KubikonPlayer/GameLoadingScreen.jsx
Normal file
198
src/KubikonPlayer/GameLoadingScreen.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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, прицел в первом лице) */}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user