feat(player): обновлённый LoadingScreenOverlay с blur-фоном из студии
All checks were successful
CI / Lint (pull_request) Successful in 52s
CI / Build (pull_request) Successful in 1m31s
CI / Secret scan (pull_request) Successful in 21s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped

В студии был обновлён loading-экран:
- размытый фон из cover-картинки
- квадратная обложка по центру вместо широкой
- имя автора под названием
- более крупный прогресс с процентом

Плеер остался на старой версии (синий фон, широкая обложка),
поэтому в проде разница была заметна — фикс делает плеер
1-в-1 со студией.
This commit is contained in:
min 2026-06-10 00:48:36 +03:00
parent 60f0ba009d
commit adc950accf

View File

@ -35,7 +35,25 @@ function injectSpinnerCss() {
style.textContent = style.textContent =
'@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' + '@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
'.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' + '.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' +
'@media (prefers-reduced-motion:reduce){.kbn-ls-spinner{animation:none}}'; // Ken Burns — медленный pan+zoom фона (задача 05).
'@keyframes kbn-ls-kenburns{' +
'0%{transform:scale(1.0) translate3d(0,0,0)}' +
'50%{transform:scale(1.10) translate3d(-3%,-2%,0)}' +
'100%{transform:scale(1.0) translate3d(-6%,0,0)}}' +
'.kbn-ls-kenburns{animation:kbn-ls-kenburns 24s ease-in-out infinite}' +
// particles — медленно всплывающие искры.
'@keyframes kbn-ls-rise{' +
'0%{transform:translateY(0) scale(1);opacity:0}' +
'10%{opacity:0.9}' +
'90%{opacity:0.7}' +
'100%{transform:translateY(-110vh) scale(1.4);opacity:0}}' +
'.kbn-ls-particle{animation:kbn-ls-rise linear infinite}' +
// лёгкий «дыхательный» glow карточки-превью.
'@keyframes kbn-ls-cardglow{' +
'0%,100%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 0 rgba(120,160,255,0)}' +
'50%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 40px rgba(120,160,255,0.35)}}' +
'.kbn-ls-cardglow{animation:kbn-ls-cardglow 4s ease-in-out infinite}' +
'@media (prefers-reduced-motion:reduce){.kbn-ls-spinner,.kbn-ls-kenburns,.kbn-ls-particle,.kbn-ls-cardglow{animation:none}}';
document.head.appendChild(style); document.head.appendChild(style);
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@ -49,14 +67,17 @@ export class LoadingScreenOverlay {
// Мост наружу (GameRuntime подписывает) — id-based колбэки. // Мост наружу (GameRuntime подписывает) — id-based колбэки.
this._onSkipCb = null; // (id) => void this._onSkipCb = null; // (id) => void
this._onCompleteCb = null; // (id) => void this._onCompleteCb = null; // (id) => void
this._onHideCb = null; // () => void — задача 05 (game.loading.onHide)
this._parallaxHandler = null;
// DOM-ссылки активного экрана: // DOM-ссылки активного экрана:
this._els = null; this._els = null;
} }
/** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */ /** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */
setBridge(onSkip, onComplete) { setBridge(onSkip, onComplete, onHide) {
this._onSkipCb = onSkip; this._onSkipCb = onSkip;
this._onCompleteCb = onComplete; this._onCompleteCb = onComplete;
if (onHide) this._onHideCb = onHide;
} }
/** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */ /** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */
@ -104,6 +125,15 @@ export class LoadingScreenOverlay {
logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12, logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12,
// Текст под картинкой // Текст под картинкой
text: opts.text != null ? String(opts.text) : '', text: opts.text != null ? String(opts.text) : '',
// --- Задача 05: Ken-Burns фон + карточка места ---
// style: 'ken-burns' | 'static' | 'parallax' | 'particles'
style: opts.style || cfg.style || 'ken-burns',
// фоновое размытое изображение (на весь экран); резолвится в _resolveCover.
background: opts.background != null ? opts.background : (cfg.background || null),
// карточка-витрина по центру (название места + автор), как в Roblox.
placeName: opts.placeName != null ? String(opts.placeName) : (cfg.placeName || ''),
studioName: opts.studioName != null ? String(opts.studioName) : (cfg.studioName || ''),
verified: opts.verified != null ? !!opts.verified : !!cfg.verified,
// Поведение // Поведение
blockInput: opts.blockInput !== false, blockInput: opts.blockInput !== false,
pauseSimulation: opts.pauseSimulation !== false, pauseSimulation: opts.pauseSimulation !== false,
@ -163,20 +193,107 @@ export class LoadingScreenOverlay {
// (используем opacity всего root для fade, а bgOpacity — через rgba фон): // (используем opacity всего root для fade, а bgOpacity — через rgba фон):
root.style.background = this._bgRgba(st.bgColor, st.bgOpacity); root.style.background = this._bgRgba(st.bgColor, st.bgOpacity);
// --- Cover (картинка по центру) --- // --- Фоновый слой (Ken Burns / parallax / static) ---
const coverUrl = this._resolveCover(cover); // Размытое изображение игры на весь экран. Отдельный div под контентом,
const coverImg = document.createElement('div'); // чтобы blur/анимация не трогали карточку и текст.
coverImg.style.cssText = const bgUrl = this._resolveCover(st.background);
'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' + const bgLayer = document.createElement('div');
'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' + let bgClass = '';
'background-color:#1a1f2b;margin-bottom:140px;'; if (bgUrl) {
if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`; if (st.style === 'ken-burns') bgClass = 'kbn-ls-kenburns';
bgLayer.className = bgClass;
bgLayer.style.cssText =
'position:absolute;inset:-8%;z-index:0;background-size:cover;background-position:center;' +
'filter:blur(8px) brightness(0.55);will-change:transform;' +
`background-image:url("${bgUrl}");`;
// parallax — лёгкий сдвиг по мыши (2 слоя имитируем одним + transform).
if (st.style === 'parallax') {
bgLayer.style.transition = 'transform 0.25s ease-out';
this._parallaxHandler = (e) => {
const cx = (e.clientX / window.innerWidth - 0.5) * 28;
const cy = (e.clientY / window.innerHeight - 0.5) * 18;
bgLayer.style.transform = `translate3d(${-cx}px,${-cy}px,0) scale(1.06)`;
};
window.addEventListener('mousemove', this._parallaxHandler);
}
root.appendChild(bgLayer);
}
// --- Текст под картинкой --- // --- particles слой (медленные искры) ---
if (st.style === 'particles') {
const pLayer = document.createElement('div');
pLayer.style.cssText = 'position:absolute;inset:0;z-index:1;overflow:hidden;pointer-events:none;';
for (let i = 0; i < 26; i++) {
const sp = document.createElement('span');
sp.className = 'kbn-ls-particle';
const size = 2 + Math.round(Math.random() * 4);
const dur = 7 + Math.random() * 10;
sp.style.cssText =
`position:absolute;bottom:-10px;left:${(Math.random() * 100).toFixed(1)}%;` +
`width:${size}px;height:${size}px;border-radius:50%;` +
`background:rgba(${180 + Math.round(Math.random() * 70)},${190 + Math.round(Math.random() * 60)},255,0.85);` +
`box-shadow:0 0 ${size * 2}px rgba(140,170,255,0.7);` +
`animation-duration:${dur.toFixed(1)}s;animation-delay:${(-Math.random() * dur).toFixed(1)}s;`;
pLayer.appendChild(sp);
}
root.appendChild(pLayer);
}
// Обёртка контента (над фоном).
const content = document.createElement('div');
content.style.cssText =
'position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;height:100%;';
// --- Cover (картинка-карточка по центру) ---
const coverUrl = this._resolveCover(cover);
// Режим карточки места (задача 05): квадрат + название + автор под ней.
const hasPlaceCard = !!(st.placeName || st.studioName);
const coverImg = document.createElement('div');
if (hasPlaceCard) {
coverImg.className = 'kbn-ls-cardglow';
coverImg.style.cssText =
'width:min(42vw,400px);aspect-ratio:1/1;border-radius:18px;' +
'background-size:cover;background-position:center;background-color:#1a1f2b;' +
'border:2px solid rgba(255,255,255,0.12);';
} else {
coverImg.style.cssText =
'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' +
'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' +
'background-color:#1a1f2b;margin-bottom:140px;';
}
if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`;
else if (bgUrl && hasPlaceCard) coverImg.style.backgroundImage = `url("${bgUrl}")`;
// --- Название места (крупный белый, под карточкой) ---
const placeEl = document.createElement('div');
placeEl.style.cssText =
'margin-top:22px;color:#fff;font-size:38px;font-weight:800;letter-spacing:0.5px;' +
'text-align:center;text-shadow:0 3px 14px rgba(0,0,0,0.7);' +
(st.placeName ? '' : 'display:none;');
placeEl.textContent = st.placeName || '';
// --- Автор + verified-галочка ---
const studioRow = document.createElement('div');
studioRow.style.cssText =
'margin-top:8px;display:flex;align-items:center;gap:7px;' +
'color:#cdd6e6;font-size:16px;font-weight:600;text-shadow:0 1px 4px rgba(0,0,0,0.6);' +
(st.studioName ? '' : 'display:none;');
const studioTxt = document.createElement('span');
studioTxt.textContent = st.studioName || '';
studioRow.appendChild(studioTxt);
if (st.verified) studioRow.appendChild(this._buildVerifiedBadge());
// --- Текст под картинкой (для не-карточного режима / mid-game) ---
const textEl = document.createElement('div'); const textEl = document.createElement('div');
textEl.style.cssText = if (hasPlaceCard) {
'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' + textEl.style.cssText =
'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);'; 'margin-top:14px;color:#e8edf5;font-size:16px;font-weight:500;text-align:center;' +
'text-shadow:0 1px 3px rgba(0,0,0,0.6);' + (st.text ? '' : 'display:none;');
} else {
textEl.style.cssText =
'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' +
'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);';
}
textEl.textContent = st.text || ''; textEl.textContent = st.text || '';
// --- Прогресс-бар --- // --- Прогресс-бар ---
@ -245,8 +362,13 @@ export class LoadingScreenOverlay {
spinWrap.appendChild(spinTxt); spinWrap.appendChild(spinTxt);
spinWrap.appendChild(spinCircle); spinWrap.appendChild(spinCircle);
root.appendChild(coverImg); // Центральная композиция (карточка + название + автор + текст) — в content.
root.appendChild(textEl); content.appendChild(coverImg);
content.appendChild(placeEl);
content.appendChild(studioRow);
content.appendChild(textEl);
root.appendChild(content);
// Нижние/угловые элементы — абсолютно на root (поверх фона, рядом с content).
root.appendChild(barWrap); root.appendChild(barWrap);
root.appendChild(percent); root.appendChild(percent);
root.appendChild(skipBtn); root.appendChild(skipBtn);
@ -255,7 +377,19 @@ export class LoadingScreenOverlay {
parent.appendChild(root); parent.appendChild(root);
this.root = root; this.root = root;
this._els = { root, coverImg, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap }; this._els = { root, content, coverImg, placeEl, studioRow, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap };
}
/** Verified-галочка (синий круг + белый чек) — inline SVG, без emoji-шрифта. */
_buildVerifiedBadge() {
const wrap = document.createElement('span');
wrap.style.cssText = 'display:inline-flex;align-items:center;';
wrap.innerHTML =
'<svg width="18" height="18" viewBox="0 0 24 24" aria-label="verified">' +
'<circle cx="12" cy="12" r="11" fill="#3897f0"/>' +
'<path d="M7 12.5l3 3 7-7" fill="none" stroke="#fff" stroke-width="2.4" ' +
'stroke-linecap="round" stroke-linejoin="round"/></svg>';
return wrap;
} }
/** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */ /** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */
@ -329,6 +463,23 @@ export class LoadingScreenOverlay {
if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`; if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`;
} }
/** Задача 05: сменить фоновое (Ken-Burns) изображение на лету. */
setBackground(bg) {
if (!this._st || !this._els) return;
const url = this._resolveCover(bg);
if (!url) return;
this._st.background = bg;
// фоновый слой — первый ребёнок root с background-image; найдём его.
const layer = this._els.root.querySelector('.kbn-ls-kenburns')
|| this._els.root.firstElementChild;
if (layer && layer !== this._els.content) layer.style.backgroundImage = `url("${url}")`;
}
/** Задача 05: виден ли экран сейчас. */
isVisible() {
return !!(this._st && this._st.phase !== 'out');
}
/** Закрыть программно (с fadeOut). */ /** Закрыть программно (с fadeOut). */
close() { close() {
const st = this._st; const st = this._st;
@ -361,6 +512,13 @@ export class LoadingScreenOverlay {
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } } if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } }
if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = false; } catch { /* ignore */ } } if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = false; } catch { /* ignore */ } }
} }
// Снять parallax-listener (задача 05).
if (this._parallaxHandler) {
try { window.removeEventListener('mousemove', this._parallaxHandler); } catch { /* ignore */ }
this._parallaxHandler = null;
}
// onHide-мост (задача 05) — сообщаем скриптам что экран скрылся.
if (this._onHideCb) { try { this._onHideCb(); } catch { /* ignore */ } }
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } } if (this.root) { try { this.root.remove(); } catch { /* ignore */ } }
this.root = null; this.root = null;
this._els = null; this._els = null;