All checks were successful
Программный экран загрузки для перехода между мирами: - game.loading.show(opts) → хэндл (setProgress/setText/setCover/close/onSkip/onComplete) - game.loading.transition(opts) → Promise (фейковый прогресс за duration) - cover sceneSnapshot, прогресс-бар+процент, спиннер, кнопка Пропустить, логотип - blockInput + пауза симуляции, fadeIn/Out; tick независим от paused - настройки проекта «Экран загрузки» (логотип/акцент/дефолты) + 3 сниппета - LoadingScreenOverlay.js (новый DOM-оверлей), worker namespace loading, cmd loading.* + _ensureLoadingScreen, serialize/load конфига в scene - вики g5 #59 guide-taxi (карточка + урок), тест-игра «Такси-босс» id 2427 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
400 lines
18 KiB
JavaScript
400 lines
18 KiB
JavaScript
/**
|
||
* LoadingScreenOverlay — внутриигровой экран загрузки (задача 12).
|
||
*
|
||
* Программный mid-game transition: чёрный фон (fadeIn/Out), картинка-превью
|
||
* (cover) по центру, прогресс-бар (жёлтый по серому) + процент, спиннер
|
||
* «ЗАГРУЗКА» справа-снизу (CSS keyframes), кнопка «ПРОПУСТИТЬ» по центру-снизу
|
||
* (появляется через 0.5с — анти-accidental), логотип игры слева-снизу.
|
||
*
|
||
* Вызывается из скрипта через game.loading.show(opts) / game.loading.transition(opts).
|
||
* Покрывает и кейс задачи 05 (начальный экран при входе).
|
||
*
|
||
* Реализация — лёгкий DOM-оверлей поверх canvas (как ShopInventoryUi), а не
|
||
* Babylon-GUI: фиксированный layout с прогресс-баром/спиннером/кнопкой на HTML
|
||
* делается быстрее и доступнее. Класс самодостаточен: хранит state, рисует DOM,
|
||
* имеет tick(dt) для fade-фаз и авто-duration (в отличие от ShopInventoryUi,
|
||
* которому tick не нужен).
|
||
*
|
||
* Один активный экран одновременно: повторный show() мгновенно закрывает
|
||
* предыдущий (как ModalManager) — нет утечки overlay'ев при нескольких
|
||
* transition подряд.
|
||
*
|
||
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
|
||
*/
|
||
|
||
const EASE_OUT = (t) => 1 - Math.pow(1 - t, 3);
|
||
|
||
// CSS спиннера вставляем один раз в <head> (keyframes нельзя инлайнить в style).
|
||
let _spinCssInjected = false;
|
||
function injectSpinnerCss() {
|
||
if (_spinCssInjected) return;
|
||
_spinCssInjected = true;
|
||
try {
|
||
const style = document.createElement('style');
|
||
style.id = 'kbn-loading-spin-css';
|
||
style.textContent =
|
||
'@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
|
||
'.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' +
|
||
'@media (prefers-reduced-motion:reduce){.kbn-ls-spinner{animation:none}}';
|
||
document.head.appendChild(style);
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
export class LoadingScreenOverlay {
|
||
constructor(scene3d) {
|
||
this.s = scene3d;
|
||
this.root = null;
|
||
this._st = null; // state активного экрана или null
|
||
this._idSeq = 0;
|
||
// Мост наружу (GameRuntime подписывает) — id-based колбэки.
|
||
this._onSkipCb = null; // (id) => void
|
||
this._onCompleteCb = null; // (id) => void
|
||
// DOM-ссылки активного экрана:
|
||
this._els = null;
|
||
}
|
||
|
||
/** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */
|
||
setBridge(onSkip, onComplete) {
|
||
this._onSkipCb = onSkip;
|
||
this._onCompleteCb = onComplete;
|
||
}
|
||
|
||
/** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */
|
||
_cfg() {
|
||
return (this.s && this.s._loadingConfig) || {};
|
||
}
|
||
|
||
/**
|
||
* Показать экран загрузки. Возвращает числовой id (для матчинга команд).
|
||
* opts — см. 12_ingame_loading.md §2.2.
|
||
*/
|
||
show(opts) {
|
||
injectSpinnerCss();
|
||
opts = opts && typeof opts === 'object' ? opts : {};
|
||
// Один активный — мгновенно убрать предыдущий.
|
||
if (this._st) this._instantClose();
|
||
|
||
const cfg = this._cfg();
|
||
const accent = opts.progressColor || cfg.accentColor || '#ffc020';
|
||
const st = {
|
||
id: ++this._idSeq,
|
||
// Фон
|
||
bgColor: opts.bgColor || '#000',
|
||
bgOpacity: opts.bgOpacity != null ? Number(opts.bgOpacity) : 1,
|
||
fadeIn: opts.fadeIn != null ? Number(opts.fadeIn) : 0.3,
|
||
fadeOut: opts.fadeOut != null ? Number(opts.fadeOut) : 0.3,
|
||
// Прогресс
|
||
progressBar: opts.progressBar !== false,
|
||
progressColor: accent,
|
||
progressBgColor: opts.progressBgColor || '#444',
|
||
percentText: opts.percentText !== false,
|
||
progress: Math.max(0, Math.min(1, Number(opts.initialProgress) || 0)),
|
||
duration: Number.isFinite(opts.duration) && opts.duration > 0 ? Number(opts.duration) : null,
|
||
manualProgress: false,
|
||
// Спиннер
|
||
spinner: opts.spinner != null ? !!opts.spinner : (cfg.defaultSpinner !== false),
|
||
spinnerText: opts.spinnerText != null ? String(opts.spinnerText) : 'ЗАГРУЗКА',
|
||
// Кнопка Пропустить
|
||
skipButton: opts.skipButton != null ? !!opts.skipButton : !!cfg.defaultSkipButton,
|
||
skipButtonText: opts.skipButtonText != null ? String(opts.skipButtonText) : 'ПРОПУСТИТЬ',
|
||
skipButtonColor: opts.skipButtonColor || accent,
|
||
skipShown: false,
|
||
// Логотип
|
||
logo: opts.logo || cfg.logo || (this.s && this.s._projectThumbnail) || null,
|
||
logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12,
|
||
// Текст под картинкой
|
||
text: opts.text != null ? String(opts.text) : '',
|
||
// Поведение
|
||
blockInput: opts.blockInput !== false,
|
||
pauseSimulation: opts.pauseSimulation !== false,
|
||
// Жизненный цикл
|
||
phase: 'in', // 'in' | 'visible' | 'out'
|
||
alpha: 0,
|
||
elapsed: 0, // время с момента полного появления (для duration/skip)
|
||
fadeT: 0,
|
||
completed: false, // onComplete уже вызывался
|
||
};
|
||
this._st = st;
|
||
this._build(st, opts.cover);
|
||
|
||
// Блок ввода + пауза симуляции.
|
||
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(true); } catch { /* ignore */ } }
|
||
if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = true; } catch { /* ignore */ } }
|
||
|
||
return st.id;
|
||
}
|
||
|
||
/** Резолв cover в URL/dataURL. */
|
||
_resolveCover(cover) {
|
||
if (!cover) return null;
|
||
if (typeof cover === 'string') {
|
||
// asset:xxx → пробуем через AssetManager, иначе как прямой URL.
|
||
try {
|
||
const r = this.s.assetManager?.resolveUrl?.(cover);
|
||
if (r) return r;
|
||
} catch { /* ignore */ }
|
||
return cover;
|
||
}
|
||
if (typeof cover === 'object') {
|
||
if (cover.sceneSnapshot) {
|
||
try {
|
||
const canvas = this.s.engine?.getRenderingCanvas?.();
|
||
if (canvas) return canvas.toDataURL('image/jpeg', 0.72);
|
||
} catch { /* ignore */ }
|
||
return null;
|
||
}
|
||
if (cover.url) return cover.url;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
_build(st, cover) {
|
||
const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body;
|
||
try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ }
|
||
|
||
const root = document.createElement('div');
|
||
root.className = 'kbn-loading';
|
||
root.style.cssText =
|
||
'position:absolute;inset:0;z-index:60;overflow:hidden;' +
|
||
'display:flex;align-items:center;justify-content:center;' +
|
||
'opacity:0;transition:none;font-family:system-ui,"Segoe UI",sans-serif;' +
|
||
`background:${st.bgColor};`;
|
||
// фон с настраиваемой непрозрачностью — отдельный слой, чтобы контент был непрозрачным
|
||
// (используем opacity всего root для fade, а bgOpacity — через rgba фон):
|
||
root.style.background = this._bgRgba(st.bgColor, st.bgOpacity);
|
||
|
||
// --- Cover (картинка по центру) ---
|
||
const coverUrl = this._resolveCover(cover);
|
||
const coverImg = document.createElement('div');
|
||
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}")`;
|
||
|
||
// --- Текст под картинкой ---
|
||
const textEl = document.createElement('div');
|
||
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 || '';
|
||
|
||
// --- Прогресс-бар ---
|
||
const barWrap = document.createElement('div');
|
||
barWrap.style.cssText =
|
||
'position:absolute;left:50%;transform:translateX(-50%);bottom:120px;' +
|
||
`width:min(74vw,1180px);height:14px;border-radius:8px;background:${st.progressBgColor};` +
|
||
'overflow:hidden;box-shadow:inset 0 1px 3px rgba(0,0,0,0.5);' +
|
||
(st.progressBar ? '' : 'display:none;');
|
||
const bar = document.createElement('div');
|
||
bar.style.cssText =
|
||
`height:100%;width:${(st.progress * 100).toFixed(1)}%;border-radius:8px;` +
|
||
`background:linear-gradient(90deg,${st.progressColor},${this._lighten(st.progressColor)});` +
|
||
'transition:width 0.12s linear;box-shadow:0 0 8px rgba(255,200,40,0.4);';
|
||
barWrap.appendChild(bar);
|
||
|
||
// --- Процент ---
|
||
const percent = document.createElement('div');
|
||
percent.style.cssText =
|
||
'position:absolute;left:50%;transform:translateX(-50%);bottom:74px;' +
|
||
`color:${st.progressColor};font-size:30px;font-weight:800;text-shadow:0 2px 4px rgba(0,0,0,0.5);` +
|
||
(st.percentText ? '' : 'display:none;');
|
||
percent.textContent = `${Math.round(st.progress * 100)}%`;
|
||
|
||
// --- Кнопка Пропустить ---
|
||
const skipBtn = document.createElement('button');
|
||
skipBtn.type = 'button';
|
||
skipBtn.textContent = st.skipButtonText;
|
||
skipBtn.style.cssText =
|
||
'position:absolute;left:50%;transform:translateX(-50%);bottom:18px;' +
|
||
'min-width:260px;padding:14px 36px;border:none;border-radius:12px;cursor:pointer;' +
|
||
`background:linear-gradient(180deg,${this._lighten(st.skipButtonColor)},${st.skipButtonColor});` +
|
||
'color:#3a2a00;font-size:18px;font-weight:800;letter-spacing:0.5px;' +
|
||
'box-shadow:0 6px 16px rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.4);' +
|
||
'opacity:0;transition:opacity 0.25s,transform 0.1s;pointer-events:none;' +
|
||
(st.skipButton ? '' : 'display:none;');
|
||
skipBtn.onmouseenter = () => { skipBtn.style.transform = 'translateX(-50%) translateY(-2px)'; };
|
||
skipBtn.onmouseleave = () => { skipBtn.style.transform = 'translateX(-50%)'; };
|
||
skipBtn.onclick = () => {
|
||
if (skipBtn.style.pointerEvents === 'none') return;
|
||
this._fireSkip();
|
||
};
|
||
|
||
// --- Логотип (слева снизу) ---
|
||
const logo = document.createElement('div');
|
||
logo.style.cssText =
|
||
'position:absolute;left:28px;bottom:24px;max-width:200px;max-height:110px;' +
|
||
`border-radius:${st.logoCornerRadius}px;background-size:contain;background-repeat:no-repeat;` +
|
||
'background-position:left bottom;width:200px;height:90px;';
|
||
if (st.logo) logo.style.backgroundImage = `url("${st.logo}")`;
|
||
else logo.style.display = 'none';
|
||
|
||
// --- Спиннер + «ЗАГРУЗКА» (справа снизу) ---
|
||
const spinWrap = document.createElement('div');
|
||
spinWrap.style.cssText =
|
||
'position:absolute;right:32px;bottom:32px;display:flex;align-items:center;gap:14px;' +
|
||
'color:#fff;font-size:20px;font-weight:700;letter-spacing:1px;' +
|
||
(st.spinner ? '' : 'display:none;');
|
||
const spinTxt = document.createElement('span');
|
||
spinTxt.textContent = st.spinnerText;
|
||
const spinCircle = document.createElement('span');
|
||
spinCircle.className = 'kbn-ls-spinner';
|
||
spinCircle.style.cssText =
|
||
`display:inline-block;width:28px;height:28px;border:3px solid rgba(255,255,255,0.25);` +
|
||
`border-top-color:${st.progressColor};border-radius:50%;`;
|
||
spinWrap.appendChild(spinTxt);
|
||
spinWrap.appendChild(spinCircle);
|
||
|
||
root.appendChild(coverImg);
|
||
root.appendChild(textEl);
|
||
root.appendChild(barWrap);
|
||
root.appendChild(percent);
|
||
root.appendChild(skipBtn);
|
||
root.appendChild(logo);
|
||
root.appendChild(spinWrap);
|
||
parent.appendChild(root);
|
||
|
||
this.root = root;
|
||
this._els = { root, coverImg, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap };
|
||
}
|
||
|
||
/** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */
|
||
tick(dt) {
|
||
const st = this._st;
|
||
if (!st || !this._els) return;
|
||
dt = Number(dt) || 0;
|
||
|
||
if (st.phase === 'in') {
|
||
st.fadeT += dt;
|
||
const d = st.fadeIn > 0 ? st.fadeIn : 0.0001;
|
||
st.alpha = Math.min(1, EASE_OUT(st.fadeT / d));
|
||
this._els.root.style.opacity = String(st.alpha);
|
||
if (st.fadeT >= d) { st.phase = 'visible'; st.alpha = 1; st.fadeT = 0; }
|
||
} else if (st.phase === 'visible') {
|
||
st.elapsed += dt;
|
||
// Кнопка Пропустить — появляется через 0.5с.
|
||
if (!st.skipShown && st.skipButton && st.elapsed >= 0.5) {
|
||
st.skipShown = true;
|
||
this._els.skipBtn.style.opacity = '1';
|
||
this._els.skipBtn.style.pointerEvents = 'auto';
|
||
}
|
||
// Авто-duration (если не было ручного setProgress).
|
||
if (st.duration && !st.manualProgress) {
|
||
st.progress = Math.min(1, st.elapsed / st.duration);
|
||
this._applyProgress(st);
|
||
if (st.progress >= 1 && !st.completed) {
|
||
st.completed = true;
|
||
this._fireComplete();
|
||
this.close();
|
||
}
|
||
}
|
||
} else if (st.phase === 'out') {
|
||
st.fadeT += dt;
|
||
const d = st.fadeOut > 0 ? st.fadeOut : 0.0001;
|
||
st.alpha = Math.max(0, 1 - EASE_OUT(st.fadeT / d));
|
||
this._els.root.style.opacity = String(st.alpha);
|
||
if (st.fadeT >= d) this._teardown();
|
||
}
|
||
}
|
||
|
||
_applyProgress(st) {
|
||
if (!this._els) return;
|
||
this._els.bar.style.width = `${(st.progress * 100).toFixed(1)}%`;
|
||
this._els.percent.textContent = `${Math.round(st.progress * 100)}%`;
|
||
}
|
||
|
||
setProgress(value) {
|
||
const st = this._st;
|
||
if (!st) return;
|
||
st.manualProgress = true;
|
||
st.progress = Math.max(0, Math.min(1, Number(value) || 0));
|
||
this._applyProgress(st);
|
||
if (st.progress >= 1 && !st.completed) {
|
||
st.completed = true;
|
||
this._fireComplete();
|
||
this.close();
|
||
}
|
||
}
|
||
|
||
setText(text) {
|
||
const st = this._st;
|
||
if (!st || !this._els) return;
|
||
st.text = String(text == null ? '' : text);
|
||
this._els.textEl.textContent = st.text;
|
||
}
|
||
|
||
setCover(cover) {
|
||
if (!this._st || !this._els) return;
|
||
const url = this._resolveCover(cover);
|
||
if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`;
|
||
}
|
||
|
||
/** Закрыть программно (с fadeOut). */
|
||
close() {
|
||
const st = this._st;
|
||
if (!st) return;
|
||
if (st.phase !== 'out') { st.phase = 'out'; st.fadeT = 0; }
|
||
}
|
||
|
||
_fireSkip() {
|
||
const st = this._st;
|
||
if (!st) return;
|
||
if (this._onSkipCb) { try { this._onSkipCb(st.id); } catch { /* ignore */ } }
|
||
this.close();
|
||
}
|
||
|
||
_fireComplete() {
|
||
const st = this._st;
|
||
if (!st) return;
|
||
if (this._onCompleteCb) { try { this._onCompleteCb(st.id); } catch { /* ignore */ } }
|
||
}
|
||
|
||
/** Мгновенно убрать без fade (повторный show / выход из Play). */
|
||
_instantClose() {
|
||
this._teardown();
|
||
}
|
||
|
||
_teardown() {
|
||
// Снять блок ввода / паузу.
|
||
const st = this._st;
|
||
if (st) {
|
||
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 (this.root) { try { this.root.remove(); } catch { /* ignore */ } }
|
||
this.root = null;
|
||
this._els = null;
|
||
this._st = null;
|
||
}
|
||
|
||
dispose() {
|
||
this._instantClose();
|
||
this._onSkipCb = null;
|
||
this._onCompleteCb = null;
|
||
}
|
||
|
||
// --- утилиты цвета ---
|
||
_lighten(hex) {
|
||
try {
|
||
const h = String(hex).replace('#', '');
|
||
if (h.length !== 6) return hex;
|
||
const r = Math.min(255, parseInt(h.slice(0, 2), 16) + 40);
|
||
const g = Math.min(255, parseInt(h.slice(2, 4), 16) + 40);
|
||
const b = Math.min(255, parseInt(h.slice(4, 6), 16) + 40);
|
||
return `rgb(${r},${g},${b})`;
|
||
} catch { return hex; }
|
||
}
|
||
|
||
_bgRgba(hex, opacity) {
|
||
try {
|
||
const h = String(hex).replace('#', '');
|
||
if (h.length !== 6) return hex;
|
||
const r = parseInt(h.slice(0, 2), 16);
|
||
const g = parseInt(h.slice(2, 4), 16);
|
||
const b = parseInt(h.slice(4, 6), 16);
|
||
const a = opacity != null ? Math.max(0, Math.min(1, opacity)) : 1;
|
||
return `rgba(${r},${g},${b},${a})`;
|
||
} catch { return hex; }
|
||
}
|
||
}
|