From c31b1ed3d60a5142fb7e0ab77c00f39d1d59ed42 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 19:34:48 +0300 Subject: [PATCH] =?UTF-8?q?feat(studio):=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B0=2005=20=E2=80=94=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20(Ken=20Burns?= =?UTF-8?q?=20+=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoadingScreenOverlay: Ken-Burns фон (CSS pan+zoom) + 4 стиля (ken-burns/static/ parallax/particles) + карточка-композиция (cover/название места/автор/verified-SVG). Стартовый экран при входе в Play (showStartupLoadingScreen из enterPlayMode + поля проекта loadingScreen.* + serialize/deserialize). API game.loading. setBackground/isVisible/onHide + расширенный show. UI редактора: секция «Стартовый экран входа (Ken Burns)». Вики g5 #62 + статья. Тест-игра 2713. Co-Authored-By: Claude Opus 4.8 --- src/community/docsGames.js | 5 + src/editor/GameSettingsModal.jsx | 152 +++++++++++++++++ src/editor/engine/BabylonScene.js | 65 ++++++-- src/editor/engine/GameRuntime.js | 9 +- src/editor/engine/LoadingScreenOverlay.js | 192 ++++++++++++++++++++-- src/editor/engine/ScriptSandboxWorker.js | 27 +++ 6 files changed, 420 insertions(+), 30 deletions(-) diff --git a/src/community/docsGames.js b/src/community/docsGames.js index 1923434..6f9f317 100644 --- a/src/community/docsGames.js +++ b/src/community/docsGames.js @@ -373,4 +373,9 @@ export const GAMES = [ desc: 'Полный инвентарь как в Minecraft/RPG: сетка 8×5 + хотбар 9, стаки, 5 редкостей (цвет рамки), перетаскивание мышью, ПКМ-меню, tooltip, сортировка. Собираешь предметы — стаки растут.', mechanics: ['game.items.define([...]) — предметы (редкость/стак/иконка)', 'game.inventory.give / take', 'окно по I — сетка 8×5 + хотбар 9 (1-9)', 'drag-drop между слотами (swap + merge)', 'стаки с maxStack, 5 редкостей', 'ПКМ-меню: использовать / разделить / выбросить', 'tooltip + сортировка по редкости'], previewShot: 'guide-inventory-scene.png', openProjectId: 2685, ready: true }, + { id: 'guide-loadingscreen', num: 66, group: 'g5', stars: 2, icon: 'loader', + title: 'Экран загрузки — Ken Burns и название места', + desc: 'Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор с verified-галочкой — как в Roblox. Автор настраивает экран во вкладке «Стартовый экран».', + mechanics: ['красивый экран загрузки игры в плеере (GameLoadingScreen)', 'Ken Burns / static / parallax / particles', 'карточка-витрина + название места + автор + verified', 'настройка во вкладке «Стартовый экран» (свойства проекта)', 'game.loading.show({ style, placeName, studioName, duration }) — переходы', 'game.loading.onHide() — продолжить после загрузки', 'game.loading.setBackground / setText / setProgress'], + previewShot: 'guide-loadingscreen-scene.png', openProjectId: 2713, ready: true }, ]; diff --git a/src/editor/GameSettingsModal.jsx b/src/editor/GameSettingsModal.jsx index e579b3d..4b1ea9b 100644 --- a/src/editor/GameSettingsModal.jsx +++ b/src/editor/GameSettingsModal.jsx @@ -50,9 +50,21 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot const [loadingAccent, setLoadingAccent] = useState('#ffc020'); const [loadingSpinner, setLoadingSpinner] = useState(true); const [loadingSkip, setLoadingSkip] = useState(false); + // Задача 05: стартовый Ken-Burns экран + const [lsEnabled, setLsEnabled] = useState(true); + const [lsBackground, setLsBackground] = useState(''); + const [lsCover, setLsCover] = useState(''); + const [lsStyle, setLsStyle] = useState('ken-burns'); + const [lsPlaceName, setLsPlaceName] = useState(''); + const [lsStudioName, setLsStudioName] = useState(''); + const [lsVerified, setLsVerified] = useState(false); + const [lsDuration, setLsDuration] = useState(2.5); + const [lsProgressBar, setLsProgressBar] = useState(true); const [error, setError] = useState(''); const fileInputRef = useRef(null); const logoInputRef = useRef(null); + const lsBgInputRef = useRef(null); + const lsCoverInputRef = useRef(null); // Заполняем поля ОДИН РАЗ при открытии модала. // Не зависим от `initial` — родитель часто передаёт литерал-объект, @@ -71,6 +83,16 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot setLoadingAccent(ls.accentColor || '#ffc020'); setLoadingSpinner(ls.defaultSpinner !== false); setLoadingSkip(!!ls.defaultSkipButton); + // Задача 05: + setLsEnabled(ls.enabled !== false); + setLsBackground(ls.background || ''); + setLsCover(ls.cover || ''); + setLsStyle(ls.style || 'ken-burns'); + setLsPlaceName(ls.placeName || ''); + setLsStudioName(ls.studioName || ''); + setLsVerified(!!ls.verified); + setLsDuration(Number.isFinite(ls.duration) && ls.duration > 0 ? ls.duration : 2.5); + setLsProgressBar(ls.progressBar !== false); setMaxPlayers( typeof initial?.max_players === 'number' ? Math.max(2, Math.min(50, initial.max_players)) @@ -117,6 +139,17 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot reader.readAsDataURL(file); }; + // Задача 05: универсальный загрузчик изображения (фон / cover-карточка). + const handleLsImage = (e, setter) => { + const file = e.target.files?.[0]; + if (!file) return; + if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Только PNG, JPG или WEBP'); return; } + if (file.size > MAX_THUMBNAIL_BYTES) { setError('Изображение слишком большое (макс. 500 КБ)'); return; } + const reader = new FileReader(); + reader.onload = (ev) => { setter(ev.target.result); setError(''); }; + reader.readAsDataURL(file); + }; + const handleSubmit = (e) => { e.preventDefault(); const trimmedTitle = title.trim(); @@ -146,6 +179,16 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot accentColor: loadingAccent || '#ffc020', defaultSpinner: loadingSpinner, defaultSkipButton: loadingSkip, + // Задача 05: + enabled: lsEnabled, + background: lsBackground || null, + cover: lsCover || null, + style: lsStyle || 'ken-burns', + placeName: lsPlaceName.trim(), + studioName: lsStudioName.trim(), + verified: lsVerified, + duration: Math.max(1, Math.min(10, Number(lsDuration) || 2.5)), + progressBar: lsProgressBar, }, }); }; @@ -384,6 +427,115 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot + {/* Стартовый экран — Ken Burns + название места (задача 05) */} +
+
+ Стартовый экран входа (Ken Burns) +
+
+ Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор. +
+ + + {lsEnabled && ( + <> + {/* Фон + карточка */} +
+
+
+ {!lsBackground && фон (размытый)} +
+ + {lsBackground && ( + + )} + handleLsImage(e, setLsBackground)} /> +
+
+
+ {!lsCover && карточка} +
+ + {lsCover && ( + + )} + handleLsImage(e, setLsCover)} /> +
+
+ setLsPlaceName(e.target.value)} /> + setLsStudioName(e.target.value)} /> + +
+
+ + {/* Стиль + длительность + прогресс */} +
+ + + +
+ + )} +
+ {error &&
{error}
} diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index 1c241cf..ac0d69d 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -5942,7 +5942,7 @@ export class BabylonScene { this._updateSpawnMarker(); } - /** Задача 12: конфиг экрана загрузки из настроек проекта (логотип/акцент/дефолты). */ + /** Задача 12+05: конфиг экрана загрузки из настроек проекта. */ setLoadingConfig(cfg, thumbnail) { if (cfg && typeof cfg === 'object') { this._loadingConfig = { @@ -5950,6 +5950,16 @@ export class BabylonScene { accentColor: cfg.accentColor || '#ffc020', defaultSpinner: cfg.defaultSpinner !== false, defaultSkipButton: !!cfg.defaultSkipButton, + // --- Задача 05: стартовый экран при входе в Play --- + enabled: cfg.enabled !== false, // показывать ли стартовый экран + background: cfg.background || cfg.backgroundUrl || null, + cover: cfg.cover || cfg.coverUrl || null, + style: cfg.style || 'ken-burns', + placeName: cfg.placeName || '', + studioName: cfg.studioName || '', + verified: !!cfg.verified, + duration: Number.isFinite(cfg.duration) && cfg.duration > 0 ? Number(cfg.duration) : 2.5, + progressBar: cfg.progressBar !== false, }; } else { this._loadingConfig = null; @@ -5957,6 +5967,34 @@ export class BabylonScene { if (thumbnail !== undefined) this._projectThumbnail = thumbnail || null; } + /** + * Задача 05: показать СТАРТОВЫЙ экран загрузки при входе в Play. + * Зовётся из enterPlayMode; держится минимум `duration` сек либо до готовности сцены. + */ + showStartupLoadingScreen() { + const cfg = this._loadingConfig; + if (!cfg || cfg.enabled === false) return; + if (!this.gameRuntime) return; + try { + const ls = this.gameRuntime._ensureLoadingScreen?.(); + if (!ls) return; + ls.show({ + style: cfg.style, + background: cfg.background || cfg.cover || this._projectThumbnail, + cover: cfg.cover || this._projectThumbnail, + placeName: cfg.placeName || this._projectName || '', + studioName: cfg.studioName || '', + verified: cfg.verified, + duration: cfg.duration, + progressBar: cfg.progressBar, + spinner: true, + bgColor: '#070a14', + pauseSimulation: false, // стартовый — сцена грузится в фоне + blockInput: true, + }); + } catch (e) { /* ignore */ } + } + /** Установить тип модели персонажа (для Play). */ setPlayerModelType(typeId) { if (!typeId) return; @@ -6074,6 +6112,9 @@ export class BabylonScene { // поэтому скрипты стартуем в следующем кадре. this.gameRuntime = new GameRuntime(this); try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {} + // Задача 05: стартовый экран загрузки (Ken-Burns + название места), + // если настроен в проекте. Показываем поверх старта сцены. + try { this.showStartupLoadingScreen(); } catch (e) {} // Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime. // ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным // this.audioManager (AudioManager — ambient/music для всех проектов). @@ -7646,12 +7687,22 @@ export class BabylonScene { coins: this._skinsConfig.coins || 0, customGlbs: this._skinsConfig.customGlbs || [], } : undefined, - // Задача 12: конфиг экрана загрузки (логотип/акцент/дефолты). + // Задача 12+05: конфиг экрана загрузки (логотип/акцент/дефолты + стартовый Ken-Burns). loadingScreen: this._loadingConfig ? { logo: this._loadingConfig.logo || null, accentColor: this._loadingConfig.accentColor || '#ffc020', defaultSpinner: this._loadingConfig.defaultSpinner !== false, defaultSkipButton: !!this._loadingConfig.defaultSkipButton, + // Задача 05: + enabled: this._loadingConfig.enabled !== false, + background: this._loadingConfig.background || null, + cover: this._loadingConfig.cover || null, + style: this._loadingConfig.style || 'ken-burns', + placeName: this._loadingConfig.placeName || '', + studioName: this._loadingConfig.studioName || '', + verified: !!this._loadingConfig.verified, + duration: this._loadingConfig.duration || 2.5, + progressBar: this._loadingConfig.progressBar !== false, } : undefined, // Задача 13: конфиг главного меню (passthrough — меню задаётся скриптом). mainMenu: this._mainMenuConfig || undefined, @@ -8120,15 +8171,9 @@ export class BabylonScene { } else { this._skinsConfig = null; } - // Задача 12: конфиг экрана загрузки. + // Задача 12+05: конфиг экрана загрузки (через setLoadingConfig — единый маппинг). 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, - }; + this.setLoadingConfig(state.scene.loadingScreen); } else { this._loadingConfig = null; } diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 58ec24b..5170722 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -1326,6 +1326,8 @@ export class GameRuntime { 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 }); }, + // Задача 05: onHide — экран скрылся (любой). + () => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingHidden' }); }, ); this.scene3d.loadingScreen = ls; } @@ -1879,9 +1881,9 @@ export class GameRuntime { const id = ls.show(payload.opts || {}); // Вернуть worker'у real loadingId, чтобы хэндл (setProgress/close/колбэки) // нашёл нужный экран по replyId → local→real маппингу. - if (payload.replyId != null) { - for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id }); - } + // replyId может отсутствовать (стартовый экран) — всё равно шлём + // loadingShown для game.loading.isVisible() (задача 05). + 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; @@ -1889,6 +1891,7 @@ export class GameRuntime { 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.setBackground') { this.scene3d?.loadingScreen?.setBackground?.(payload?.background); return; } if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; } // === Damage Floaters (задача 40) — всплывающие цифры урона === diff --git a/src/editor/engine/LoadingScreenOverlay.js b/src/editor/engine/LoadingScreenOverlay.js index 47f6b56..d0666f2 100644 --- a/src/editor/engine/LoadingScreenOverlay.js +++ b/src/editor/engine/LoadingScreenOverlay.js @@ -35,7 +35,25 @@ function injectSpinnerCss() { 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}}'; + // 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); } catch { /* ignore */ } } @@ -49,14 +67,17 @@ export class LoadingScreenOverlay { // Мост наружу (GameRuntime подписывает) — id-based колбэки. this._onSkipCb = null; // (id) => void this._onCompleteCb = null; // (id) => void + this._onHideCb = null; // () => void — задача 05 (game.loading.onHide) + this._parallaxHandler = null; // DOM-ссылки активного экрана: this._els = null; } /** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */ - setBridge(onSkip, onComplete) { + setBridge(onSkip, onComplete, onHide) { this._onSkipCb = onSkip; this._onCompleteCb = onComplete; + if (onHide) this._onHideCb = onHide; } /** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */ @@ -104,6 +125,15 @@ export class LoadingScreenOverlay { logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12, // Текст под картинкой 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, pauseSimulation: opts.pauseSimulation !== false, @@ -163,20 +193,107 @@ export class LoadingScreenOverlay { // (используем 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}")`; + // --- Фоновый слой (Ken Burns / parallax / static) --- + // Размытое изображение игры на весь экран. Отдельный div под контентом, + // чтобы blur/анимация не трогали карточку и текст. + const bgUrl = this._resolveCover(st.background); + const bgLayer = document.createElement('div'); + let bgClass = ''; + if (bgUrl) { + 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'); - 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);'; + if (hasPlaceCard) { + textEl.style.cssText = + '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 || ''; // --- Прогресс-бар --- @@ -245,8 +362,13 @@ export class LoadingScreenOverlay { spinWrap.appendChild(spinTxt); spinWrap.appendChild(spinCircle); - root.appendChild(coverImg); - root.appendChild(textEl); + // Центральная композиция (карточка + название + автор + текст) — в content. + content.appendChild(coverImg); + content.appendChild(placeEl); + content.appendChild(studioRow); + content.appendChild(textEl); + root.appendChild(content); + // Нижние/угловые элементы — абсолютно на root (поверх фона, рядом с content). root.appendChild(barWrap); root.appendChild(percent); root.appendChild(skipBtn); @@ -255,7 +377,19 @@ export class LoadingScreenOverlay { parent.appendChild(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 = + '' + + '' + + ''; + return wrap; } /** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */ @@ -329,6 +463,23 @@ export class LoadingScreenOverlay { 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). */ close() { const st = this._st; @@ -361,6 +512,13 @@ export class LoadingScreenOverlay { 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 */ } } } + // Снять 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 */ } } this.root = null; this._els = null; diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 4469ea0..6d38fa4 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -70,6 +70,8 @@ let _toolUseHandlers = []; // При toolUse-событии воркер сначала вызывает per-tool колбэк, потом глобальные. let _toolCallbacks = {}; // { 'custom:1': { activated: fn, equipped: fn, unequipped: fn } } let _toolSeq = 0; +// Задача 05: зеркало видимости экрана загрузки (для game.loading.isVisible()). +let _loadingVisible = false; // Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }. let _players = { me: null, list: [] }; // Общее состояние комнаты game.room.get/set — зеркало из main thread. @@ -3032,6 +3034,7 @@ const game = { _localSeq: 0, _localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown) _handlers: new Map(), // localId → { onSkip:[], onComplete:[] } + _onHide: [], // задача 05 — глобальные подписки на скрытие show(opts) { opts = opts && typeof opts === 'object' ? opts : {}; const localId = ++this._localSeq; @@ -3050,11 +3053,27 @@ const game = { 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 }); }, + setBackground(b) { _send('loading.setBackground', { localId, background: b }); }, 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); }, }; }, + // --- Задача 05: управление активным экраном без хэндла (стартовый/любой текущий) --- + /** Подписаться на скрытие экрана загрузки. */ + onHide(fn) { if (typeof fn === 'function') this._onHide.push(fn); }, + /** Сменить фоновое изображение текущего экрана. */ + setBackground(b) { _send('loading.setBackground', { background: b }); }, + /** Сменить текст текущего экрана. */ + setText(t) { _send('loading.setText', { text: String(t == null ? '' : t) }); }, + /** Сменить cover текущего экрана. */ + setCover(c) { _send('loading.setCover', { cover: c }); }, + /** Ручной прогресс текущего экрана. */ + setProgress(v) { _send('loading.setProgress', { value: Number(v) || 0 }); }, + /** Скрыть текущий экран. */ + hide() { _send('loading.close', {}); }, + /** Виден ли экран сейчас (синхронно из локального зеркала). */ + isVisible() { return !!_loadingVisible; }, /** Типовой переход с фейковым прогрессом. Возвращает Promise (резолв по complete/skip). */ transition(opts) { opts = opts && typeof opts === 'object' ? { ...opts } : {}; @@ -4597,6 +4616,7 @@ self.onmessage = (e) => { } else if (t === 'loadingShown') { // Задача 12: реальный loadingId от runtime — маппим local→real, чтобы // setProgress/close/колбэки нашли нужный экран. + _loadingVisible = true; try { const lo = (typeof game !== 'undefined') && game.loading; if (lo && payload && payload.replyId) { @@ -4606,6 +4626,13 @@ self.onmessage = (e) => { } } } catch (e) {} + } else if (t === 'loadingHidden') { + // Задача 05: экран загрузки скрылся — обновляем зеркало + onHide-подписки. + _loadingVisible = false; + try { + const lo = (typeof game !== 'undefined') && game.loading; + if (lo) for (const fn of (lo._onHide || [])) _safeCall(fn, undefined, 'loading.onHide'); + } catch (e) {} } else if (t === 'loadingSkip' || t === 'loadingComplete') { // Игрок нажал «Пропустить» (skip) или прогресс достиг 100% (complete). // Находим local по real loadingId и зовём соответствующие подписчики.