From 247a5703c9361cfabc6abc45da081f0a32a139d8 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 19:34:55 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(player):=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=2005=20=E2=80=94=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20(Ken=20Bu?= =?UTF-8?q?rns)=20=E2=80=94=20=D1=84=D0=B8=D1=87=D0=B0-=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D1=81=D0=BE=20=D1=81=D1=82?= =?UTF-8?q?=D1=83=D0=B4=D0=B8=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Порт LoadingScreenOverlay (Ken-Burns/4 стиля/карточка/verified) + старт-экран при входе в Play + API game.loading.setBackground/isVisible/onHide. Идентично студии. worker SOURCE синтаксис проверен. Проверено headless в плеере (0 ошибок). Co-Authored-By: Claude Opus 4.8 --- src/engine/BabylonScene.js | 62 ++++++++-- src/engine/GameRuntime.js | 9 +- src/engine/LoadingScreenOverlay.js | 192 ++++++++++++++++++++++++++--- src/engine/ScriptSandboxWorker.js | 20 +++ 4 files changed, 255 insertions(+), 28 deletions(-) diff --git a/src/engine/BabylonScene.js b/src/engine/BabylonScene.js index 8c392b5..3e39418 100644 --- a/src/engine/BabylonScene.js +++ b/src/engine/BabylonScene.js @@ -5413,6 +5413,56 @@ export class BabylonScene { return this._isPlaying; } + /** Задача 12+05: конфиг экрана загрузки из настроек проекта. */ + setLoadingConfig(cfg, thumbnail) { + if (cfg && typeof cfg === 'object') { + this._loadingConfig = { + logo: cfg.logo || null, + accentColor: cfg.accentColor || '#ffc020', + defaultSpinner: cfg.defaultSpinner !== false, + defaultSkipButton: !!cfg.defaultSkipButton, + // Задача 05: + 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; + } + if (thumbnail !== undefined) this._projectThumbnail = thumbnail || null; + } + + /** Задача 05: стартовый экран загрузки при входе в Play (Ken-Burns + название места). */ + 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 */ } + } + /** * Переключить в режим игры. Создаём PlayerController, прячем ghost-блок, * запоминаем позицию редактор-камеры чтобы вернуть при exit. @@ -5526,6 +5576,8 @@ 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 для всех проектов). @@ -7431,15 +7483,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/engine/GameRuntime.js b/src/engine/GameRuntime.js index 3b966a2..8150e7e 100644 --- a/src/engine/GameRuntime.js +++ b/src/engine/GameRuntime.js @@ -360,6 +360,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; } @@ -1742,9 +1744,9 @@ export class GameRuntime { 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 }); - } + // 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; @@ -1752,6 +1754,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/engine/LoadingScreenOverlay.js b/src/engine/LoadingScreenOverlay.js index 47f6b56..d0666f2 100644 --- a/src/engine/LoadingScreenOverlay.js +++ b/src/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/engine/ScriptSandboxWorker.js b/src/engine/ScriptSandboxWorker.js index ca6053d..b5a3cd7 100644 --- a/src/engine/ScriptSandboxWorker.js +++ b/src/engine/ScriptSandboxWorker.js @@ -70,6 +70,8 @@ let _placeOnPlaceHandlers = []; let _placeOnCancelHandlers = []; let _placeOnMoveHandlers = []; let _invUiSlotClickHandlers = []; +// Задача 05: зеркало видимости экрана загрузки (для game.loading.isVisible()). +let _loadingVisible = false; // Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }. let _players = { me: null, list: [] }; // Общее состояние комнаты game.room.get/set — зеркало из main thread. @@ -2727,6 +2729,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; @@ -2745,11 +2748,20 @@ 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) }); }, + 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 } : {}; @@ -4122,6 +4134,7 @@ self.onmessage = (e) => { } catch (e) {} } else if (t === 'loadingShown') { // Задача 12: реальный loadingId от runtime — маппим local→real. + _loadingVisible = true; try { const lo = (typeof game !== 'undefined') && game.loading; if (lo && payload && payload.replyId) { @@ -4131,6 +4144,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') { // Игрок нажал «Пропустить» или прогресс достиг 100% — зовём подписчиков. try { From f5a96fbec0ca7ed55cae6f31bb1daa0cf590bef4 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 19:46:20 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(player):=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=2005=20=E2=80=94=20=D0=BA=D1=80=D0=B0=D1=81=D0=B8?= =?UTF-8?q?=D0=B2=D1=8B=D0=B9=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D0=98=D0=93=D0=A0?= =?UTF-8?q?=D0=AB=20=D0=BF=D1=80=D0=B8=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B5=20?= =?UTF-8?q?(=D0=B0=20=D0=BD=D0=B5=20=D0=B2=20=D1=81=D1=82=D1=83=D0=B4?= =?UTF-8?q?=D0=B8=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Главное по задаче 05: переделан React loading-оверлей в KubikonPlayer (тот, что игрок видит после клика «Играть» пока грузится игра). Новый компонент GameLoadingScreen: Ken Burns фон + карточка-витрина + название места + автор + verified-галочка + прогресс-бар (реальный 0→100%) + спиннер. Данные: project_data.scene.loadingScreen (настройки автора из студии) → мета игры (title/thumbnail/автор) → дефолт. 0 ошибок, проверено headless. Co-Authored-By: Claude Opus 4.8 --- src/KubikonPlayer/GameLoadingScreen.jsx | 198 ++++++++++++++++++++++++ src/KubikonPlayer/KubikonPlayer.jsx | 62 +++----- 2 files changed, 221 insertions(+), 39 deletions(-) create mode 100644 src/KubikonPlayer/GameLoadingScreen.jsx diff --git a/src/KubikonPlayer/GameLoadingScreen.jsx b/src/KubikonPlayer/GameLoadingScreen.jsx new file mode 100644 index 0000000..63ade54 --- /dev/null +++ b/src/KubikonPlayer/GameLoadingScreen.jsx @@ -0,0 +1,198 @@ +/** + * GameLoadingScreen — красивый экран загрузки игры в плеере (задача 05). + * + * Показывается пока грузится игра (после клика «Играть» на странице игры → + * открытие плеера). Композиция как в Roblox: + * - размытый фон-обложка игры с медленным Ken Burns (pan + zoom); + * - карточка-витрина по центру (обложка игры); + * - крупное название места; + * - автор + verified-галочка; + * - прогресс-бар + спиннер «ЗАГРУЗКА». + * + * Данные берёт из меты игры (title/thumbnail/автор) и, если автор настроил в + * студии вкладку «Стартовый экран» — из project_data.scene.loadingScreen + * (placeName / studioName / style / verified / background / cover). + */ +import React, { useEffect, useRef, useState } from 'react'; + +// Один раз вставляем CSS-keyframes (нельзя инлайнить в style). +let _cssInjected = false; +function injectCss() { + if (_cssInjected || typeof document === 'undefined') return; + _cssInjected = true; + const s = document.createElement('style'); + s.id = 'kbn-game-loading-css'; + s.textContent = + '@keyframes kbnGlsKen{0%{transform:scale(1.05) translate3d(0,0,0)}50%{transform:scale(1.15) translate3d(-3%,-2%,0)}100%{transform:scale(1.05) translate3d(-6%,0,0)}}' + + '.kbnGlsKen{animation:kbnGlsKen 22s ease-in-out infinite}' + + '@keyframes kbnGlsSpin{to{transform:rotate(360deg)}}' + + '.kbnGlsSpin{animation:kbnGlsSpin 0.85s linear infinite}' + + '@keyframes kbnGlsRise{0%{transform:translateY(0) scale(1);opacity:0}12%{opacity:.9}88%{opacity:.6}100%{transform:translateY(-110vh) scale(1.4);opacity:0}}' + + '.kbnGlsP{animation:kbnGlsRise linear infinite}' + + '@keyframes kbnGlsGlow{0%,100%{box-shadow:0 18px 60px rgba(0,0,0,.6),0 0 0 rgba(120,160,255,0)}50%{box-shadow:0 18px 60px rgba(0,0,0,.6),0 0 44px rgba(120,160,255,.4)}}' + + '.kbnGlsCard{animation:kbnGlsGlow 4s ease-in-out infinite}' + + '@keyframes kbnGlsBar{0%{transform:translateX(-100%)}100%{transform:translateX(250%)}}' + + '.kbnGlsBarRun{animation:kbnGlsBar 1.2s ease-in-out infinite}' + + '@media (prefers-reduced-motion:reduce){.kbnGlsKen,.kbnGlsP,.kbnGlsCard,.kbnGlsBarRun{animation:none}}'; + document.head.appendChild(s); +} + +function VerifiedBadge() { + return ( + + + + + ); +} + +/** + * props: + * meta — ответ getProjectForPlay (title, thumbnail, author_username/username, ...) + * loadingScreen — project_data.scene.loadingScreen (опц., настройки автора) + * progress — 0..1 (если null — «бегущая» полоса без процента) + */ +export default function GameLoadingScreen({ meta, loadingScreen, progress }) { + injectCss(); + const ls = loadingScreen || {}; + const [fade, setFade] = useState(0); + const rootRef = useRef(null); + + useEffect(() => { const t = setTimeout(() => setFade(1), 20); return () => clearTimeout(t); }, []); + + // Источники данных: настройки автора → мета игры → дефолт. + const bg = ls.background || meta?.thumbnail || null; + const cover = ls.cover || meta?.thumbnail || null; + const placeName = ls.placeName || meta?.title || 'Загрузка игры'; + const studioName = ls.studioName + || meta?.author_username || meta?.username || meta?.author || ''; + const verified = ls.verified != null ? !!ls.verified + : !!(meta?.author_verified || meta?.is_verified); + const style = ls.style || 'ken-burns'; + const accent = ls.accentColor || '#5fd0ff'; + const hasProgress = typeof progress === 'number' && progress >= 0; + const pct = hasProgress ? Math.round(Math.max(0, Math.min(1, progress)) * 100) : null; + + // parallax по мыши + const bgRef = useRef(null); + useEffect(() => { + if (style !== 'parallax' || !bgRef.current) return; + const h = (e) => { + const cx = (e.clientX / window.innerWidth - 0.5) * 26; + const cy = (e.clientY / window.innerHeight - 0.5) * 16; + if (bgRef.current) bgRef.current.style.transform = `translate3d(${-cx}px,${-cy}px,0) scale(1.1)`; + }; + window.addEventListener('mousemove', h); + return () => window.removeEventListener('mousemove', h); + }, [style]); + + const particles = style === 'particles' + ? Array.from({ length: 24 }, (_, i) => { + const size = 2 + (i % 4); + const dur = 7 + (i % 7); + return ( + + ); + }) : null; + + return ( +
+ {/* Фоновый слой (Ken Burns / parallax / static) */} + {bg && ( +
+ )} + {/* particles */} + {particles &&
{particles}
} + + {/* Контент */} +
+ {/* Карточка-витрина */} +
+ {!cover && ( + РУБЛОКС • 3D + )} +
+ + {/* Название места */} +
{placeName}
+ + {/* Автор + verified */} + {studioName && ( +
+ {studioName} + {verified && } +
+ )} + + {/* Прогресс-бар */} +
+ {hasProgress ? ( +
+ ) : ( +
+ )} +
+ + {/* Спиннер + статус */} +
+ + {pct != null ? `${pct}%` : 'ЗАГРУЗКА'} +
+
+
+ ); +} diff --git a/src/KubikonPlayer/KubikonPlayer.jsx b/src/KubikonPlayer/KubikonPlayer.jsx index 9debc37..f3ebee5 100644 --- a/src/KubikonPlayer/KubikonPlayer.jsx +++ b/src/KubikonPlayer/KubikonPlayer.jsx @@ -22,6 +22,7 @@ import { useAuth } from '../auth/PlayerAuth'; import RublocsLogo from '../components/RublocsLogo/RublocsLogo'; import useDeviceType from '../hooks/useDeviceType'; import KubikonMobileControls from './KubikonMobileControls'; +import GameLoadingScreen from './GameLoadingScreen'; // Плеер живёт на player.rublox.pro — он не знает SPA-роутов Майнкрафтии // (/kubikon, /login, /auth). Поэтому вместо navigate(...) делаем @@ -216,6 +217,9 @@ const KubikonPlayer = () => { const [forbidden, setForbidden] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); + // Задача 05: конфиг красивого экрана загрузки (из project_data.scene.loadingScreen). + const [loadingScreenCfg, setLoadingScreenCfg] = useState(null); + const [loadProgress, setLoadProgress] = useState(0); // Раньше была стартовая заглушка «тапни чтобы начать» — убрали по // фидбэку, она бесила. Теперь fullscreen опционально через кнопку // в углу. Этот state остался для совместимости с handleMobileStart. @@ -551,11 +555,18 @@ const KubikonPlayer = () => { setMeta(data); setLikesCount(data.likes_count || 0); setDislikesCount(data.dislikes_count || 0); + setLoadProgress(0.3); if (data.project_data) { const parsed = JSON.parse(data.project_data); initialStateRef.current = parsed; + // Задача 05: красивый экран загрузки — конфиг автора (если задан в студии). + try { + const lsc = parsed?.scene?.loadingScreen; + if (lsc && typeof lsc === 'object' && lsc.enabled !== false) setLoadingScreenCfg(lsc); + } catch (e) { /* ignore */ } await scene.loadFromState(parsed); + setLoadProgress(0.7); } // Ждём пока Babylon реально загрузит и скомпилит все @@ -592,6 +603,7 @@ const KubikonPlayer = () => { skinFolderRef.current = mySkin; try { scene.setPlayerModelType?.(mySkin); } catch (e) {} + setLoadProgress(1); setLoading(false); // Засчитываем плей. Передаём user_id (если залогинен) — // это активирует self-cooldown (автор не накручивает себе) @@ -970,6 +982,11 @@ const KubikonPlayer = () => { // Очищаем ref'ы — иначе следующий connectMultiplayer выйдет // на if (mpSyncRef.current || roomRef.current) return. try { sync.stop?.(); } catch (e) {} + // ВАЖНО: dispose() сносит ВСЕ старые меши remote-игроков со + // сцены. Без этого при auto-reconnect (Colyseus rejoin) новый + // MultiplayerSync видит пустую Map и при +remote создаёт + // дубль-меш на каждый кадр (см. фикс 2026-06-05). + try { sync.dispose?.(); } catch (e) {} mpSyncRef.current = null; roomRef.current = null; // Code 1000 / 1001 — нормальное закрытие. Code >= 4000 — наш @@ -1133,46 +1150,13 @@ const KubikonPlayer = () => { outline: 'none', }} /> - {/* Loading-оверлей */} + {/* Задача 05: красивый экран загрузки игры (Ken Burns + название места + автор). */} {loading && ( -
-
-
- -
-
-
- Загрузка игры… -
-
- Рублокс • 3D -
-
+ )} {/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */} From ccf76d539bf6205c3b5aa92e1594cadfcc54d35c Mon Sep 17 00:00:00 2001 From: min Date: Wed, 10 Jun 2026 01:29:00 +0300 Subject: [PATCH 3/3] =?UTF-8?q?feat(player):=20=D0=B3=D1=80=D0=B0=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0/=D1=8D=D1=84=D1=84=D0=B5=D0=BA=D1=82=D1=8B?= =?UTF-8?q?=20(=D1=84=D0=B8=D1=87=D0=B0-=D0=BF=D0=B0=D1=80=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20=D1=81=D0=BE=20=D1=81=D1=82=D1=83=D0=B4?= =?UTF-8?q?=D0=B8=D0=B5=D0=B9)=20+=20realtime=20=D0=BD=D0=B0=20game.rublox?= =?UTF-8?q?.pro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GraphicsManager (постобработка/материалы/API game.graphics) — паритет со студией, применяется при загрузке игры если автор настроил. Новые материалы chrome/water/ iridescent. Realtime-эндпоинт переведён на game.rublox.pro (S1 NPM прямо, без hop через S2 — чинит разрывы WebSocket). MultiplayerSync улучшен. Co-Authored-By: Claude Opus 4.8 --- src/KubikonPlayer/KubikonPlayer.jsx | 10 +- src/api/API.js | 7 +- src/engine/BabylonScene.js | 42 ++++ src/engine/GameRuntime.js | 4 + src/engine/GraphicsManager.js | 328 ++++++++++++++++++++++++++++ src/engine/MultiplayerSync.js | 66 +++++- src/engine/PrimitiveManager.js | 30 ++- src/engine/ScriptSandboxWorker.js | 35 +++ 8 files changed, 507 insertions(+), 15 deletions(-) create mode 100644 src/engine/GraphicsManager.js diff --git a/src/KubikonPlayer/KubikonPlayer.jsx b/src/KubikonPlayer/KubikonPlayer.jsx index f3ebee5..da4fb05 100644 --- a/src/KubikonPlayer/KubikonPlayer.jsx +++ b/src/KubikonPlayer/KubikonPlayer.jsx @@ -39,12 +39,12 @@ function exitPlayer(gameId) { // (флаг читает onBeforeUnload listener ниже). try { window.__rubloxExplicitExit = true; } catch {} const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; - const RUBLOX_HOME = env.VITE_RUBLOX_HOME || 'https://rublox.pro/app'; + const RUBLOX_HOME = (env.VITE_RUBLOX_HOME || 'https://rublox.pro/app').replace(/\/+$/, ''); if (gameId) { - // Передаём gameId через ?game= — главный сайт прочитает и снова - // откроет карточку игры (юзер возвращается на ту же страницу). - const sep = RUBLOX_HOME.includes('?') ? '&' : '?'; - window.location.assign(`${RUBLOX_HOME}${sep}game=${gameId}`); + // У страницы игры теперь свой URL: /app/game/ (им можно делиться). + // Возвращаемся прямо на него. base RUBLOX_HOME оканчивается на /app. + const base = RUBLOX_HOME.endsWith('/app') ? RUBLOX_HOME : `${RUBLOX_HOME}/app`; + window.location.assign(`${base}/game/${gameId}`); } else { window.location.assign(RUBLOX_HOME); } diff --git a/src/api/API.js b/src/api/API.js index bc7662f..313dfdd 100644 --- a/src/api/API.js +++ b/src/api/API.js @@ -30,10 +30,13 @@ export const STORYS_addres = BASE + '/api-storys'; // env-настроенные прямые URL. const IS_HTTPS = typeof window !== 'undefined' && window.location.protocol === 'https:'; +// 2026-06-05: realtime теперь прямо на game.rublox.pro (S1 NPM → S1 VM 110), +// не через minecraftia-school.ru/api-game (лишний hop S2 NPM → S1 NAT +// давал разрывы WebSocket каждую секунду). export const REALTIME_HTTP = ENV.VITE_REALTIME_HTTP - ?? (IS_HTTPS ? `${window.location.origin}/api-game` : 'http://localhost:8685'); + ?? (IS_HTTPS ? 'https://game.rublox.pro' : 'http://localhost:8685'); export const REALTIME_WS = ENV.VITE_REALTIME_WS - ?? (IS_HTTPS ? `wss://${window.location.host}/api-game` : 'ws://localhost:8685'); + ?? (IS_HTTPS ? 'wss://game.rublox.pro' : 'ws://localhost:8685'); // Главный сайт Рублокса — куда редиректить юзеров без ticket/JWT. export const RUBLOX_HOME = ENV.VITE_RUBLOX_HOME ?? 'https://rublox.pro/app'; diff --git a/src/engine/BabylonScene.js b/src/engine/BabylonScene.js index 3e39418..ff89d95 100644 --- a/src/engine/BabylonScene.js +++ b/src/engine/BabylonScene.js @@ -96,6 +96,7 @@ import { GdForest } from './GdForest'; import { GdPlayerCube } from './GdPlayerCube'; import { GdPlayerTrail } from './GdPlayerTrail'; import { GdPostFx } from './GdPostFx'; +import { GraphicsManager } from './GraphicsManager'; import { PhysicsAABB } from './PhysicsAABB'; import { PlayerController } from './PlayerController'; import { SelectionManager } from './SelectionManager'; @@ -1649,6 +1650,42 @@ export class BabylonScene { this._ssaoEnabled = false; } + /** + * Система графики/эффектов («шейдеры»). Лениво создаём GraphicsManager. + * Идентична студийной (фича-парность). Применяется при загрузке игры, + * если автор настроил graphics в проекте (и не 'off'). + */ + _ensureGraphics() { + if (this._graphics) { + const cam = this.scene?.activeCamera || this.camera; + if (cam) this._graphics.setCamera(cam); + return this._graphics; + } + const cam = this.scene?.activeCamera || this.camera; + if (!this.scene || !cam) return null; + this._graphics = new GraphicsManager(this.scene, cam, this, { + mobile: !!this._isMobileMode, + }); + return this._graphics; + } + + setGraphics(settings) { + const g = this._ensureGraphics(); + if (!g) return null; + const cfg = g.apply(settings || {}); + this._graphicsConfig = cfg; + return cfg; + } + + getGraphicsState() { + return this._graphics ? this._graphics.serialize() : (this._graphicsConfig || null); + } + + disableGraphics() { + if (this._graphics) this._graphics.disableAll(); + this._graphicsConfig = null; + } + /** * Включить/выключить SSAO пост-эффект (контактные тени). * Используем SSAORenderingPipeline v1 — v2 ломал thin-instance рендер @@ -7611,6 +7648,11 @@ export class BabylonScene { if (state.scene.environment && this.environment) { this.environment.load(state.scene.environment); } + // Графика/эффекты (шейдеры) — применяем если автор настроил и не 'off'. + if (state.scene.graphics && state.scene.graphics.preset + && state.scene.graphics.preset !== 'off') { + try { this.setGraphics(state.scene.graphics); } catch (e) { /* ignore */ } + } // Кастомное небо (задача 16) if (state.scene.skybox && this.skybox) { this.skybox.load(state.scene.skybox); diff --git a/src/engine/GameRuntime.js b/src/engine/GameRuntime.js index 8150e7e..3857103 100644 --- a/src/engine/GameRuntime.js +++ b/src/engine/GameRuntime.js @@ -3331,6 +3331,10 @@ export class GameRuntime { } catch (e) {} return; } + if (cmd === 'graphics.set') { + try { this.scene3d?.setGraphics?.(payload || {}); } catch (e) {} + return; + } // === Задача 03: GUI tween === if (cmd === 'gui.tween') { try { diff --git a/src/engine/GraphicsManager.js b/src/engine/GraphicsManager.js new file mode 100644 index 0000000..fcb156b --- /dev/null +++ b/src/engine/GraphicsManager.js @@ -0,0 +1,328 @@ +/** + * GraphicsManager — система визуальных эффектов («шейдеры») для игр Рублокса. + * + * Управляет: + * - постобработкой экрана через Babylon DefaultRenderingPipeline: + * bloom (свечение), FXAA (сглаживание), виньетка, цветокоррекция + * (контраст/насыщенность/экспозиция), тонмаппинг, глубина резкости (DoF); + * - качеством теней (через scene3d.setShadowQuality); + * - контактными тенями SSAO (через scene3d.setSsaoEnabled). + * + * Управляется И из настроек игры (вкладка «Графика»), И из скриптов + * (game.graphics.*). По умолчанию ВСЁ ВЫКЛЮЧЕНО (preset 'off') — старые игры + * не меняются, FPS не страдает. Автор включает осознанно. + * + * Mobile-safe: на слабых устройствах тяжёлые эффекты (DoF, SSAO, ultra-тени, + * HDR-bloom) автоматически урезаются, даже если в пресете включены. + * + * Один и тот же класс используется в студии и плеере (фича-парность). + * + * Использование: + * const gfx = new GraphicsManager(scene, camera, scene3d, { mobile }); + * gfx.apply({ preset: 'cinematic' }); + * gfx.apply({ bloom: { enabled: true, intensity: 0.7 } }); + * gfx.dispose(); + */ +import { + DefaultRenderingPipeline, Color4, ImageProcessingConfiguration, +} from '@babylonjs/core'; + +/** + * Именованные пресеты. Каждый — полный набор настроек. 'off' = чистая картинка + * (pipeline не создаётся вовсе). Значения подобраны так, чтобы быть заметными, + * но не «кислотными». + */ +export const GRAPHICS_PRESETS = { + off: { + bloom: { enabled: false }, + fxaa: false, + vignette: { enabled: false }, + grading: { enabled: false }, + dof: { enabled: false }, + ssao: false, + shadows: null, // null = не трогаем текущее качество теней + }, + // Лёгкий: только мягкое свечение + сглаживание. Дёшево, годится почти везде. + low: { + bloom: { enabled: true, intensity: 0.3, threshold: 0.9 }, + fxaa: true, + vignette: { enabled: false }, + grading: { enabled: false }, + dof: { enabled: false }, + ssao: false, + shadows: 'hard', + }, + // Средний: свечение + лёгкая виньетка + чуть насыщенности. + medium: { + bloom: { enabled: true, intensity: 0.45, threshold: 0.85 }, + fxaa: true, + vignette: { enabled: true, weight: 0.5 }, + grading: { enabled: true, contrast: 1.05, saturation: 1.1, exposure: 1.0 }, + dof: { enabled: false }, + ssao: false, + shadows: 'soft', + }, + // Высокий: всё кроме DoF, SSAO включён. + high: { + bloom: { enabled: true, intensity: 0.6, threshold: 0.82 }, + fxaa: true, + vignette: { enabled: true, weight: 0.6 }, + grading: { enabled: true, contrast: 1.1, saturation: 1.2, exposure: 1.05 }, + dof: { enabled: false }, + ssao: true, + shadows: 'soft', + }, + // Ультра: + глубина резкости + мягкие каскадные тени. + ultra: { + bloom: { enabled: true, intensity: 0.7, threshold: 0.8 }, + fxaa: true, + vignette: { enabled: true, weight: 0.65 }, + grading: { enabled: true, contrast: 1.12, saturation: 1.25, exposure: 1.05 }, + dof: { enabled: true, focusDistance: 18, focalLength: 50, aperture: 1.2 }, + ssao: true, + shadows: 'high', + }, + // === Стилевые пресеты (художественные) === + cinematic: { + bloom: { enabled: true, intensity: 0.55, threshold: 0.8 }, + fxaa: true, + vignette: { enabled: true, weight: 0.85 }, + grading: { enabled: true, contrast: 1.18, saturation: 1.05, exposure: 1.0 }, + dof: { enabled: true, focusDistance: 22, focalLength: 60, aperture: 1.0 }, + ssao: true, + shadows: 'soft', + }, + vivid: { + bloom: { enabled: true, intensity: 0.65, threshold: 0.78 }, + fxaa: true, + vignette: { enabled: false }, + grading: { enabled: true, contrast: 1.1, saturation: 1.5, exposure: 1.1 }, + dof: { enabled: false }, + ssao: false, + shadows: 'soft', + }, + night: { + bloom: { enabled: true, intensity: 0.8, threshold: 0.7 }, + fxaa: true, + vignette: { enabled: true, weight: 1.0 }, + grading: { enabled: true, contrast: 1.2, saturation: 0.85, exposure: 0.8 }, + dof: { enabled: false }, + ssao: true, + shadows: 'soft', + }, + retro: { + bloom: { enabled: false }, + fxaa: false, // намеренно «пиксельно» + vignette: { enabled: true, weight: 1.2 }, + grading: { enabled: true, contrast: 1.3, saturation: 0.7, exposure: 0.95 }, + dof: { enabled: false }, + ssao: false, + shadows: 'hard', + }, + soft: { + bloom: { enabled: true, intensity: 0.4, threshold: 0.88 }, + fxaa: true, + vignette: { enabled: true, weight: 0.4 }, + grading: { enabled: true, contrast: 0.95, saturation: 1.05, exposure: 1.05 }, + dof: { enabled: false }, + ssao: false, + shadows: 'soft', + }, +}; + +// Глубокое слияние пресета и пользовательских оверрайдов. +function _mergeConfig(base, over) { + const out = JSON.parse(JSON.stringify(base || {})); + if (!over) return out; + for (const k of Object.keys(over)) { + const v = over[k]; + if (v && typeof v === 'object' && !Array.isArray(v)) { + out[k] = { ...(out[k] || {}), ...v }; + } else { + out[k] = v; + } + } + return out; +} + +export class GraphicsManager { + /** + * @param scene Babylon Scene + * @param camera активная камера (для pipeline) + * @param scene3d ссылка на BabylonScene (для setShadowQuality / setSsaoEnabled / света) + * @param opts { mobile:boolean } + */ + constructor(scene, camera, scene3d, opts = {}) { + this.scene = scene; + this.camera = camera; + this.scene3d = scene3d; + this.mobile = !!opts.mobile; + this._pipeline = null; + // Текущая активная конфигурация (после merge + mobile-clamp). + this.config = _mergeConfig(GRAPHICS_PRESETS.off, null); + this.config.preset = 'off'; + this.enabled = false; + } + + /** Сменить камеру (например после смены режима камеры) — пересобрать pipeline. */ + setCamera(camera) { + if (camera === this.camera) return; + this.camera = camera; + if (this.enabled) this._rebuildPipeline(); + } + + /** + * Применить настройки графики. Принимает либо {preset}, либо отдельные + * секции (bloom/vignette/grading/dof/ssao/fxaa/shadows), либо и то и другое + * (оверрайды поверх пресета). Сохраняет состояние в this.config. + */ + apply(settings = {}) { + let cfg; + if (settings.preset && GRAPHICS_PRESETS[settings.preset]) { + cfg = _mergeConfig(GRAPHICS_PRESETS[settings.preset], settings); + cfg.preset = settings.preset; + } else { + // частичный апдейт поверх текущего + cfg = _mergeConfig(this.config, settings); + cfg.preset = settings.preset || this.config.preset || 'custom'; + } + this.config = this._clampForMobile(cfg); + this._applyConfig(); + return this.config; + } + + /** Полностью выключить эффекты (как preset 'off'). */ + disableAll() { + return this.apply({ preset: 'off' }); + } + + /** Текущая конфигурация (для serialize). */ + serialize() { + // Храним «как просили» (preset + явные оверрайды). Для простоты — весь cfg. + return JSON.parse(JSON.stringify(this.config)); + } + + // --- внутреннее --- + + /** На слабых устройствах гасим самое дорогое, что бы ни просили. */ + _clampForMobile(cfg) { + if (!this.mobile) return cfg; + const c = JSON.parse(JSON.stringify(cfg)); + if (c.dof) c.dof.enabled = false; // DoF дорогой + c.ssao = false; // SSAO дорогой + if (c.shadows === 'high' || c.shadows === 'medium') c.shadows = 'hard'; + // bloom оставляем, но без HDR (решается в _rebuildPipeline) + c._mobileClamped = true; + return c; + } + + _applyConfig() { + const c = this.config; + const anyPipelineFx = (c.bloom && c.bloom.enabled) || c.fxaa + || (c.vignette && c.vignette.enabled) || (c.grading && c.grading.enabled) + || (c.dof && c.dof.enabled); + + // Тени и SSAO — через scene3d (они вне pipeline). + try { + if (c.shadows && this.scene3d?.setShadowQuality) { + this.scene3d.setShadowQuality(c.shadows); + } + } catch (e) { /* ignore */ } + try { + if (this.scene3d?.setSsaoEnabled) this.scene3d.setSsaoEnabled(!!c.ssao); + } catch (e) { /* ignore */ } + + if (!anyPipelineFx) { + this.enabled = false; + this._disposePipeline(); + return; + } + this.enabled = true; + this._rebuildPipeline(); + } + + _rebuildPipeline() { + this._disposePipeline(); + if (!this.scene || !this.camera) return; + const c = this.config; + const useHdr = (c.bloom && c.bloom.enabled) && !this.mobile; + + const p = new DefaultRenderingPipeline('rbx_graphics', useHdr, this.scene, [this.camera]); + + // Bloom + p.bloomEnabled = !!(c.bloom && c.bloom.enabled); + if (p.bloomEnabled) { + p.bloomThreshold = c.bloom.threshold ?? 0.85; + p.bloomWeight = c.bloom.intensity ?? 0.5; + p.bloomKernel = this.mobile ? 32 : 64; + p.bloomScale = 0.5; + } + + // FXAA + p.fxaaEnabled = !!c.fxaa; + p.samples = this.mobile ? 1 : 4; + + // Image processing: виньетка + цветокоррекция + (опц.) тонмаппинг + const ip = p.imageProcessing; + if (ip) { + p.imageProcessingEnabled = true; + ip.toneMappingEnabled = false; // как в GdPostFx — иначе картинка темнеет + // экспозиция/контраст из grading + if (c.grading && c.grading.enabled) { + ip.exposure = c.grading.exposure ?? 1.0; + ip.contrast = c.grading.contrast ?? 1.0; + ip.colorCurvesEnabled = true; + try { + const curves = ip.colorCurves; + if (curves) { + // saturation: 1.0 = норма → curves в диапазоне примерно -100..100 + const sat = c.grading.saturation ?? 1.0; + curves.globalSaturation = Math.round((sat - 1.0) * 60); + } + } catch (e) { /* ignore */ } + } else { + ip.exposure = 1.0; ip.contrast = 1.0; + } + // виньетка + if (c.vignette && c.vignette.enabled) { + ip.vignetteEnabled = true; + ip.vignetteWeight = c.vignette.weight ?? 0.6; + ip.vignetteColor = new Color4(0, 0, 0, 0); + ip.vignetteStretch = 0.3; + ip.vignetteCameraFov = 0.5; + ip.vignetteBlendMode = ImageProcessingConfiguration.VIGNETTEMODE_MULTIPLY; + } else { + ip.vignetteEnabled = false; + } + } + + // Depth of Field (глубина резкости) — только desktop + if (c.dof && c.dof.enabled && !this.mobile) { + p.depthOfFieldEnabled = true; + try { + p.depthOfFieldBlurLevel = 1; // 0..2 + p.depthOfField.focusDistance = (c.dof.focusDistance ?? 18) * 1000; // мм + p.depthOfField.focalLength = c.dof.focalLength ?? 50; + p.depthOfField.fStop = c.dof.aperture ?? 1.2; + } catch (e) { /* ignore */ } + } else { + p.depthOfFieldEnabled = false; + } + + this._pipeline = p; + } + + _disposePipeline() { + if (this._pipeline) { + try { this._pipeline.dispose(); } catch (e) { /* ignore */ } + this._pipeline = null; + } + } + + dispose() { + this._disposePipeline(); + this.scene = null; + this.camera = null; + this.scene3d = null; + } +} diff --git a/src/engine/MultiplayerSync.js b/src/engine/MultiplayerSync.js index 413cce2..36dbbaa 100644 --- a/src/engine/MultiplayerSync.js +++ b/src/engine/MultiplayerSync.js @@ -137,9 +137,16 @@ export class MultiplayerSync { // 1. Подписки на state const $ = getStateCallbacks(this.room); + // Защита от повторного срабатывания onAdd (Colyseus 0.16 + immediate:true + // может триггерить .onAdd на каждый schema patch). Локальный set хранит + // sessionId которые уже обработаны в ТЕКУЩЕМ sync объекте. + const _addedSessionIds = new Set(); const handleAdd = (player, sessionId) => { // Не рисуем СЕБЯ как remote-меш — у нас уже есть PlayerController if (sessionId === this.room.sessionId) return; + // Защита от дублирующих onAdd событий для уже добавленного игрока + if (_addedSessionIds.has(sessionId)) return; + _addedSessionIds.add(sessionId); this._addRemotePlayer(sessionId, player); // Подписываемся на изменения этого Player'а $(player).onChange(() => this._updateRemoteTarget(sessionId, player)); @@ -149,7 +156,11 @@ export class MultiplayerSync { this._attachRemoteWeapon(sessionId, val || ''); }); }; + // Используем тот же set в handleRemove чтобы при настоящем уходе игрока + // потом можно было его снова добавить. + this._addedSessionIds = _addedSessionIds; const handleRemove = (player, sessionId) => { + if (this._addedSessionIds) this._addedSessionIds.delete(sessionId); this._removeRemotePlayer(sessionId); }; @@ -289,8 +300,20 @@ export class MultiplayerSync { // Интерполяция remote-игроков (позиция + yaw ставится на root, // модель — child root'а — следует за ним). + // 2026-06-05: читаем target напрямую из room.state.players — + // в Colyseus 0.16 onChange может не срабатывать для всех полей + // (особенно yaw/animState), а target.x/y/z/yaw обновляется + // через _updateRemoteTarget только из onChange. Подстраховка. for (const rp of this.remotePlayers.values()) { if (!rp.root || !rp.target) continue; + const livePlayer = this.room?.state?.players?.get?.(rp.sessionId); + if (livePlayer) { + rp.target.x = livePlayer.x; + rp.target.y = livePlayer.y; + rp.target.z = livePlayer.z; + rp.target.yaw = livePlayer.yaw || 0; + if (livePlayer.animState) rp.animState = livePlayer.animState; + } const cur = rp.current; cur.x += (rp.target.x - cur.x) * LERP_FACTOR; cur.y += (rp.target.y - cur.y) * LERP_FACTOR; @@ -332,13 +355,25 @@ export class MultiplayerSync { // Развилка: R15-скины анимируются процедурно через R15Animator // (как локальный игрок), Kenney-модели — через glTF AnimationGroups. if (rp.isR15 && rp.r15Animator && rp.modelLoaded) { - // Серверный animState: 'idle' | 'run' | 'attack'. R15Animator - // понимает idle/walk/run/jump/fall. Сервер не различает - // walk/run и не шлёт прыжки → маппим run→run, attack→idle - // (атака показывается отдельным swing-ом руки ниже). - const r15State = rp.isDead - ? 'idle' - : (rp.animState === 'run' ? 'run' : 'idle'); + // Серверный animState: 'idle' | 'run' | 'jump' | 'fall' | 'attack'. + // R15Animator понимает idle/walk/run/jump/fall. + // 2026-06-05: раньше run/jump/fall маппились в idle (баг + // в маппинге), из-за чего у remote-игроков не было + // анимации ни ходьбы, ни прыжка. Теперь пробрасываем + // напрямую. attack показывается отдельным swing руки. + let r15State; + if (rp.isDead) { + r15State = 'idle'; + } else if (rp.animState === 'jump') { + r15State = 'jump'; + } else if (rp.animState === 'fall') { + r15State = 'fall'; + } else if (rp.animState === 'run') { + r15State = 'run'; + } else { + // 'attack' или 'idle' или неизвестное — стоим + r15State = 'idle'; + } rp.r15Animator.setState(r15State); rp.r15Animator.update(dt); } else if (!rp.isR15) { @@ -632,6 +667,23 @@ export class MultiplayerSync { // === Внутреннее: меши remote-игроков === // ================================================================= _addRemotePlayer(sessionId, player) { + // Защита от дублей при Colyseus reconnect: state получается заново + // и onAdd срабатывает для тех же sessionId. Без этой проверки в + // сцене появляются клоны игроков (см. issue после 2026-06-05). + if (this.remotePlayers && this.remotePlayers.has(sessionId)) { + const existing = this.remotePlayers.get(sessionId); + // Обновим target позицию и пометим что игрок жив + const sx2 = player.x || 0, sy2 = player.y || 0, sz2 = player.z || 0, yaw2 = player.yaw || 0; + existing.target = { x: sx2, y: sy2, z: sz2, yaw: yaw2 }; + existing.username = player.username || sessionId; + existing.modelType = player.modelType || existing.modelType; + existing.hp = player.hp ?? existing.hp; + existing.maxHp = player.maxHp ?? existing.maxHp; + existing.isDead = !!player.isDead; + existing.animState = player.animState || existing.animState; + console.log(`[MultiplayerSync] re-add (reconnect): ${sessionId} (${player.username}) — обновили без пересоздания меша`); + return; + } const sx = player.x || 0; const sy = player.y || 0; const sz = player.z || 0; diff --git a/src/engine/PrimitiveManager.js b/src/engine/PrimitiveManager.js index fd8a085..6c29f4d 100644 --- a/src/engine/PrimitiveManager.js +++ b/src/engine/PrimitiveManager.js @@ -485,12 +485,40 @@ export class PrimitiveManager { break; case 'glass': mat.alpha = 0.4; - mat.specularColor = new Color3(0.5, 0.5, 0.5); + mat.specularColor = new Color3(0.8, 0.85, 0.9); + mat.specularPower = 96; + mat.backFaceCulling = false; break; case 'neon': mat.emissiveColor = Color3.FromHexString(color || '#888888'); mat.specularColor = new Color3(0, 0, 0); break; + case 'chrome': { + const cc = Color3.FromHexString(color || '#cfd6e0'); + mat.diffuseColor = new Color3(cc.r * 0.6, cc.g * 0.6, cc.b * 0.6); + mat.specularColor = new Color3(1, 1, 1); + mat.specularPower = 128; + mat.emissiveColor = new Color3(cc.r * 0.12, cc.g * 0.12, cc.b * 0.14); + break; + } + case 'water': { + const wc = Color3.FromHexString(color || '#3aa0ff'); + mat.diffuseColor = wc; + mat.alpha = 0.55; + mat.specularColor = new Color3(0.9, 0.95, 1.0); + mat.specularPower = 64; + mat.emissiveColor = new Color3(wc.r * 0.1, wc.g * 0.14, wc.b * 0.2); + mesh._isWater = true; + break; + } + case 'iridescent': { + const ic = Color3.FromHexString(color || '#a06bff'); + mat.diffuseColor = ic; + mat.emissiveColor = new Color3(ic.r * 0.5, ic.g * 0.35, ic.b * 0.6); + mat.specularColor = new Color3(1, 1, 1); + mat.specularPower = 96; + break; + } case 'studs': { // Лего-материал (паритет со студией): почти-белая diffuse × цвет // меша + normal map. emissive = доля цвета → сочность (Roblox-look). diff --git a/src/engine/ScriptSandboxWorker.js b/src/engine/ScriptSandboxWorker.js index b5a3cd7..b1e06f9 100644 --- a/src/engine/ScriptSandboxWorker.js +++ b/src/engine/ScriptSandboxWorker.js @@ -3377,6 +3377,41 @@ const game = { _send('environment.setTimeOfDay', { hours: h }); }, }, + /** + * graphics — визуальные эффекты («шейдеры»): постобработка, свечение, + * цветокоррекция, тени. По умолчанию всё выключено. + */ + graphics: { + setPreset(preset) { + if (typeof preset !== 'string') return; + _send('graphics.set', { preset }); + }, + set(settings) { + if (typeof settings !== 'object' || !settings) return; + _send('graphics.set', settings); + }, + setBloom(on, opts) { + _send('graphics.set', { bloom: { enabled: !!on, ...(opts || {}) } }); + }, + setVignette(weight) { + const w = Number(weight) || 0; + _send('graphics.set', { vignette: { enabled: w > 0, weight: w } }); + }, + setColorGrading(opts) { + if (typeof opts !== 'object' || !opts) return; + _send('graphics.set', { grading: { enabled: true, ...opts } }); + }, + setAntialiasing(on) { _send('graphics.set', { fxaa: !!on }); }, + setDepthOfField(on, opts) { + _send('graphics.set', { dof: { enabled: !!on, ...(opts || {}) } }); + }, + setShadows(quality) { + if (typeof quality !== 'string') return; + _send('graphics.set', { shadows: quality }); + }, + setSSAO(on) { _send('graphics.set', { ssao: !!on }); }, + off() { _send('graphics.set', { preset: 'off' }); }, + }, /** * Управление режимами ввода — курсор и камера. * В режиме 'ui' мышь работает как обычный курсор (как в браузере),