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..da4fb05 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(...) делаем
@@ -38,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);
}
@@ -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 && (
-
+
)}
{/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */}
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 086ad76..309c93b 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 рендер
@@ -5436,6 +5473,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.
@@ -5549,6 +5636,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 для всех проектов).
@@ -7454,15 +7543,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;
}
@@ -7588,6 +7671,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 781b61c..3f2c5b9 100644
--- a/src/engine/GameRuntime.js
+++ b/src/engine/GameRuntime.js
@@ -537,6 +537,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;
}
@@ -1980,9 +1982,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;
@@ -1990,6 +1992,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) ===
@@ -3566,6 +3569,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 38100ff..cdeffb4 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.
@@ -2776,6 +2778,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;
@@ -2794,11 +2797,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 } : {};
@@ -3489,6 +3501,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' мышь работает как обычный курсор (как в браузере),
@@ -4270,6 +4317,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) {
@@ -4279,6 +4327,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 {