From 302db5e1f44f7810bd3bd8b6c8dc7c79dc313944 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 2 Jun 2026 22:00:42 +0300 Subject: [PATCH] =?UTF-8?q?feat(12):=20=D0=B2=D0=BD=D1=83=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=B8=D0=B3=D1=80=D0=BE=D0=B2=D0=BE=D0=B9=20Loading=20Sc?= =?UTF-8?q?reen=20(game.loading)=20=E2=80=94=20=D0=BF=D0=BE=D1=80=D1=82=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D0=BB=D0=B5=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фича-парность со студией: LoadingScreenOverlay.js (DOM-оверлей), namespace game.loading в worker (хэндл local→real + колбэки через globalEvent), cmd loading.* + _ensureLoadingScreen в GameRuntime, class-ref + tick + load конфига в BabylonScene. Проверено: экран загрузки работает в плеере на тест-игре «Такси-босс» 2427. Co-Authored-By: Claude Opus 4.8 --- src/engine/BabylonScene.js | 23 ++ src/engine/GameRuntime.js | 35 +++ src/engine/LoadingScreenOverlay.js | 399 +++++++++++++++++++++++++++++ src/engine/ScriptSandboxWorker.js | 77 ++++++ 4 files changed, 534 insertions(+) create mode 100644 src/engine/LoadingScreenOverlay.js diff --git a/src/engine/BabylonScene.js b/src/engine/BabylonScene.js index abd7686..c3533b0 100644 --- a/src/engine/BabylonScene.js +++ b/src/engine/BabylonScene.js @@ -66,6 +66,7 @@ import { ConstraintManager } from './ConstraintManager'; import { BeamManager } from './BeamManager'; import { PlacementManager } from './PlacementManager'; import { ShopInventoryUi } from './ShopInventoryUi'; +import { LoadingScreenOverlay } from './LoadingScreenOverlay'; import { ZombieSpawnerManager } from './ZombieSpawnerManager'; import { DynamicsManager } from './DynamicsManager'; import { Environment } from './Environment'; @@ -151,6 +152,11 @@ export class BabylonScene { this.shopInventoryUi = null; this._PlacementManagerClass = PlacementManager; this._ShopInventoryUiClass = ShopInventoryUi; + // Экран загрузки (задача 12). + this.loadingScreen = null; + this._LoadingScreenOverlayClass = LoadingScreenOverlay; + this._loadingConfig = null; + this._projectThumbnail = null; this.spawnerManager = null; // спавнеры зомби this.environment = null; this.audioManager = null; @@ -1495,6 +1501,10 @@ export class BabylonScene { if (this._isPlaying && this.modalManager?.tick) { try { this.modalManager.tick(dt); } catch (e) {} } + // Задача 12: loadingScreen.tick — fade/auto-duration независимо от paused. + if (this._isPlaying && this.loadingScreen?.tick) { + try { this.loadingScreen.tick(dt); } catch (e) {} + } // Tick пользовательских скриптов: в Play-режиме или в solo-debug if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) { this.gameRuntime.tick(dt); @@ -7327,6 +7337,18 @@ export class BabylonScene { } else { this._skinsConfig = null; } + // Задача 12: конфиг экрана загрузки. + if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') { + const ls = state.scene.loadingScreen; + this._loadingConfig = { + logo: ls.logo || null, + accentColor: ls.accentColor || '#ffc020', + defaultSpinner: ls.defaultSpinner !== false, + defaultSkipButton: !!ls.defaultSkipButton, + }; + } else { + this._loadingConfig = null; + } // ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже — // PlayerController.start() её ждёт, но если предзагрузить сейчас, @@ -7537,6 +7559,7 @@ export class BabylonScene { // Placement mode (задача 11): сброс активной сессии + виджета магазина. if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) {} this.placementManager = null; } if (this.shopInventoryUi) { try { this.shopInventoryUi.dispose(); } catch (e) {} this.shopInventoryUi = null; } + if (this.loadingScreen) { try { this.loadingScreen.dispose(); } catch (e) {} this.loadingScreen = null; } // Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением) if (this.gdLevelManager) { diff --git a/src/engine/GameRuntime.js b/src/engine/GameRuntime.js index 19497be..684efad 100644 --- a/src/engine/GameRuntime.js +++ b/src/engine/GameRuntime.js @@ -335,6 +335,23 @@ export class GameRuntime { return this.scene3d.shopInventoryUi || null; } + /** Ленивая инициализация экрана загрузки (задача 12). */ + _ensureLoadingScreen() { + if (this.scene3d?.loadingScreen) return this.scene3d.loadingScreen; + if (!this.scene3d) return null; + try { + if (this.scene3d._LoadingScreenOverlayClass) { + const ls = new this.scene3d._LoadingScreenOverlayClass(this.scene3d); + ls.setBridge( + (id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingSkip', loadingId: id }); }, + (id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingComplete', loadingId: id }); }, + ); + this.scene3d.loadingScreen = ls; + } + } catch (e) { this._log('error', 'loadingScreen init: ' + (e?.message || e)); } + return this.scene3d.loadingScreen || null; + } + /** slug → _modelTypeId движка. Встроенные → 'skin_', character-* как есть. */ _resolveSkinTypeId(slug) { if (!slug) return 'character-a'; @@ -1645,6 +1662,24 @@ export class GameRuntime { } if (cmd === 'inventoryUi.remove') { this.scene3d?.shopInventoryUi?.remove(); return; } + // === Экран загрузки (задача 12) === + if (cmd === 'loading.show') { + const ls = this._ensureLoadingScreen(); + if (ls && payload) { + try { + const id = ls.show(payload.opts || {}); + if (payload.replyId != null) { + for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id }); + } + } catch (e) { this._log('error', 'loading.show: ' + (e?.message || e)); } + } + return; + } + if (cmd === 'loading.setProgress') { this.scene3d?.loadingScreen?.setProgress(payload?.value); return; } + if (cmd === 'loading.setText') { this.scene3d?.loadingScreen?.setText(payload?.text); return; } + if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; } + if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; } + if (cmd === 'fx.create') { // payload: { kind: 'beam'|'trail', localRef, ... } const bm = this.scene3d?.beamManager; diff --git a/src/engine/LoadingScreenOverlay.js b/src/engine/LoadingScreenOverlay.js new file mode 100644 index 0000000..47f6b56 --- /dev/null +++ b/src/engine/LoadingScreenOverlay.js @@ -0,0 +1,399 @@ +/** + * 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; } + } +} diff --git a/src/engine/ScriptSandboxWorker.js b/src/engine/ScriptSandboxWorker.js index 529161f..cc191aa 100644 --- a/src/engine/ScriptSandboxWorker.js +++ b/src/engine/ScriptSandboxWorker.js @@ -2484,6 +2484,55 @@ const game = { return m; }, }, + /** + * Экран загрузки (задача 12) — программный mid-game transition. + * const lo = game.loading.show({ progressBar:true, spinner:true }); + * lo.setProgress(0.5); lo.close(); + * await game.loading.transition({ cover:{sceneSnapshot:true}, duration:4 }); + * Хэндл возвращается синхронно (локальный id). Колбэки onSkip/onComplete + * приходят через globalEvent (loadingSkip/loadingComplete) — см. ниже. + */ + loading: { + _localSeq: 0, + _localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown) + _handlers: new Map(), // localId → { onSkip:[], onComplete:[] } + show(opts) { + opts = opts && typeof opts === 'object' ? opts : {}; + const localId = ++this._localSeq; + const replyId = '_lshow_' + localId; + const h = { onSkip: [], onComplete: [] }; + if (typeof opts.onSkip === 'function') h.onSkip.push(opts.onSkip); + if (typeof opts.onComplete === 'function') h.onComplete.push(opts.onComplete); + this._handlers.set(localId, h); + // Функции нельзя слать в main — вырезаем перед _send. + const safe = {}; + for (const k in opts) { if (typeof opts[k] !== 'function') safe[k] = opts[k]; } + _send('loading.show', { opts: safe, replyId }); + const self = this; + return { + _localId: localId, + setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); }, + setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); }, + setCover(c) { _send('loading.setCover', { localId, cover: c }); }, + close() { _send('loading.close', { localId }); }, + onSkip(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onSkip.push(fn); }, + onComplete(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onComplete.push(fn); }, + }; + }, + /** Типовой переход с фейковым прогрессом. Возвращает Promise (резолв по complete/skip). */ + transition(opts) { + opts = opts && typeof opts === 'object' ? { ...opts } : {}; + if (!Number.isFinite(opts.duration) || opts.duration <= 0) opts.duration = 3; + const self = this; + return new Promise((resolve) => { + const h = self.show(opts); + let done = false; + const finish = () => { if (done) return; done = true; resolve(); }; + h.onComplete(finish); + h.onSkip(finish); + }); + }, + }, /** * Инвентарь игрока (Фаза 4.2) — 5 слотов hot-bar. * game.inventory.add({ name: 'Зелье', kind: 'item' }) @@ -3745,6 +3794,34 @@ self.onmessage = (e) => { for (const fn of cbs) _safeCall(fn, payload && payload.id, 'modal.onClose'); } } catch (e) {} + } else if (t === 'loadingShown') { + // Задача 12: реальный loadingId от runtime — маппим local→real. + try { + const lo = (typeof game !== 'undefined') && game.loading; + if (lo && payload && payload.replyId) { + const localId = Number(String(payload.replyId).replace(/^_lshow_/, '')); + if (Number.isFinite(localId) && payload.loadingId != null) { + lo._localToReal.set(localId, payload.loadingId); + } + } + } catch (e) {} + } else if (t === 'loadingSkip' || t === 'loadingComplete') { + // Игрок нажал «Пропустить» или прогресс достиг 100% — зовём подписчиков. + try { + const lo = (typeof game !== 'undefined') && game.loading; + const real = payload && payload.loadingId; + if (lo && real != null) { + for (const [local, r] of lo._localToReal) { + if (r === real) { + const h = lo._handlers.get(local); + if (h) { + const arr = t === 'loadingSkip' ? h.onSkip : h.onComplete; + for (const fn of arr) _safeCall(fn, undefined, 'loading.' + t); + } + } + } + } + } catch (e) {} } else if (t === 'skinChanged') { // Задача 07: скин игрока сменился — обновляем зеркало и зовём подписчиков. const slug = payload && payload.slug; -- 2.47.2