/** * 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 спиннера вставляем один раз в (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; } } }