Merge pull request '�������/������� (������� �� �������) + realtime �� game.rublox.pro' (#24) from fix/env-production-ci into main
This commit is contained in:
commit
c05ab68e6b
198
src/KubikonPlayer/GameLoadingScreen.jsx
Normal file
198
src/KubikonPlayer/GameLoadingScreen.jsx
Normal file
@ -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 (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" style={{ flex: '0 0 auto' }} aria-label="verified">
|
||||||
|
<circle cx="12" cy="12" r="11" fill="#3897f0" />
|
||||||
|
<path d="M7 12.5l3 3 7-7" fill="none" stroke="#fff" strokeWidth="2.4"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<span key={i} className="kbnGlsP" style={{
|
||||||
|
position: 'absolute', bottom: -10, left: `${(i * 37) % 100}%`,
|
||||||
|
width: size, height: size, borderRadius: '50%',
|
||||||
|
background: `rgba(${180 + (i * 7) % 70},${190 + (i * 5) % 60},255,0.85)`,
|
||||||
|
boxShadow: `0 0 ${size * 2}px rgba(140,170,255,0.7)`,
|
||||||
|
animationDuration: `${dur}s`, animationDelay: `${-(i % 7)}s`,
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
}) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} style={{
|
||||||
|
position: 'absolute', inset: 0, zIndex: 60, overflow: 'hidden',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'radial-gradient(ellipse at center, #0e1430 0%, #070a14 70%)',
|
||||||
|
opacity: fade, transition: 'opacity 0.4s ease',
|
||||||
|
fontFamily: 'system-ui,"Segoe UI",sans-serif',
|
||||||
|
}}>
|
||||||
|
{/* Фоновый слой (Ken Burns / parallax / static) */}
|
||||||
|
{bg && (
|
||||||
|
<div ref={bgRef}
|
||||||
|
className={style === 'ken-burns' ? 'kbnGlsKen' : undefined}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', inset: '-8%', zIndex: 0,
|
||||||
|
backgroundImage: `url("${bg}")`, backgroundSize: 'cover', backgroundPosition: 'center',
|
||||||
|
filter: 'blur(9px) brightness(0.5)', willChange: 'transform',
|
||||||
|
transition: style === 'parallax' ? 'transform 0.25s ease-out' : 'none',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
{/* particles */}
|
||||||
|
{particles && <div style={{ position: 'absolute', inset: 0, zIndex: 1, pointerEvents: 'none' }}>{particles}</div>}
|
||||||
|
|
||||||
|
{/* Контент */}
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', zIndex: 2, display: 'flex',
|
||||||
|
flexDirection: 'column', alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
{/* Карточка-витрина */}
|
||||||
|
<div className="kbnGlsCard" style={{
|
||||||
|
width: 'min(40vw,300px)', aspectRatio: '1/1', borderRadius: 18,
|
||||||
|
backgroundImage: cover ? `url("${cover}")` : 'none',
|
||||||
|
backgroundColor: '#1a1f2b', backgroundSize: 'cover', backgroundPosition: 'center',
|
||||||
|
border: '2px solid rgba(255,255,255,0.14)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{!cover && (
|
||||||
|
<span style={{ color: '#5a6178', fontSize: 14, fontWeight: 700 }}>РУБЛОКС • 3D</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Название места */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 22, color: '#fff', fontSize: 34, fontWeight: 800,
|
||||||
|
letterSpacing: 0.4, textAlign: 'center', maxWidth: '80vw',
|
||||||
|
textShadow: '0 3px 14px rgba(0,0,0,0.7)',
|
||||||
|
}}>{placeName}</div>
|
||||||
|
|
||||||
|
{/* Автор + verified */}
|
||||||
|
{studioName && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 8, display: 'flex', alignItems: 'center', gap: 7,
|
||||||
|
color: '#cdd6e6', fontSize: 16, fontWeight: 600,
|
||||||
|
textShadow: '0 1px 4px rgba(0,0,0,0.6)',
|
||||||
|
}}>
|
||||||
|
<span>{studioName}</span>
|
||||||
|
{verified && <VerifiedBadge />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Прогресс-бар */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 26, width: 'min(64vw,420px)', height: 10, borderRadius: 6,
|
||||||
|
background: 'rgba(255,255,255,0.12)', overflow: 'hidden',
|
||||||
|
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.5)', position: 'relative',
|
||||||
|
}}>
|
||||||
|
{hasProgress ? (
|
||||||
|
<div style={{
|
||||||
|
height: '100%', width: `${pct}%`, borderRadius: 6,
|
||||||
|
background: `linear-gradient(90deg, ${accent}, #ffffff)`,
|
||||||
|
transition: 'width 0.2s linear', boxShadow: `0 0 10px ${accent}`,
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
|
<div className="kbnGlsBarRun" style={{
|
||||||
|
height: '100%', width: '40%', borderRadius: 6,
|
||||||
|
background: `linear-gradient(90deg, transparent, ${accent}, transparent)`,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Спиннер + статус */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 16, display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
color: '#fff', fontSize: 15, fontWeight: 700, letterSpacing: 0.5,
|
||||||
|
}}>
|
||||||
|
<span className="kbnGlsSpin" style={{
|
||||||
|
display: 'inline-block', width: 20, height: 20,
|
||||||
|
border: '3px solid rgba(255,255,255,0.25)', borderTopColor: accent, borderRadius: '50%',
|
||||||
|
}} />
|
||||||
|
{pct != null ? `${pct}%` : 'ЗАГРУЗКА'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -22,6 +22,7 @@ import { useAuth } from '../auth/PlayerAuth';
|
|||||||
import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
|
import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
|
||||||
import useDeviceType from '../hooks/useDeviceType';
|
import useDeviceType from '../hooks/useDeviceType';
|
||||||
import KubikonMobileControls from './KubikonMobileControls';
|
import KubikonMobileControls from './KubikonMobileControls';
|
||||||
|
import GameLoadingScreen from './GameLoadingScreen';
|
||||||
|
|
||||||
// Плеер живёт на player.rublox.pro — он не знает SPA-роутов Майнкрафтии
|
// Плеер живёт на player.rublox.pro — он не знает SPA-роутов Майнкрафтии
|
||||||
// (/kubikon, /login, /auth). Поэтому вместо navigate(...) делаем
|
// (/kubikon, /login, /auth). Поэтому вместо navigate(...) делаем
|
||||||
@ -38,12 +39,12 @@ function exitPlayer(gameId) {
|
|||||||
// (флаг читает onBeforeUnload listener ниже).
|
// (флаг читает onBeforeUnload listener ниже).
|
||||||
try { window.__rubloxExplicitExit = true; } catch {}
|
try { window.__rubloxExplicitExit = true; } catch {}
|
||||||
const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
|
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) {
|
if (gameId) {
|
||||||
// Передаём gameId через ?game=<id> — главный сайт прочитает и снова
|
// У страницы игры теперь свой URL: /app/game/<id> (им можно делиться).
|
||||||
// откроет карточку игры (юзер возвращается на ту же страницу).
|
// Возвращаемся прямо на него. base RUBLOX_HOME оканчивается на /app.
|
||||||
const sep = RUBLOX_HOME.includes('?') ? '&' : '?';
|
const base = RUBLOX_HOME.endsWith('/app') ? RUBLOX_HOME : `${RUBLOX_HOME}/app`;
|
||||||
window.location.assign(`${RUBLOX_HOME}${sep}game=${gameId}`);
|
window.location.assign(`${base}/game/${gameId}`);
|
||||||
} else {
|
} else {
|
||||||
window.location.assign(RUBLOX_HOME);
|
window.location.assign(RUBLOX_HOME);
|
||||||
}
|
}
|
||||||
@ -216,6 +217,9 @@ const KubikonPlayer = () => {
|
|||||||
const [forbidden, setForbidden] = useState(false);
|
const [forbidden, setForbidden] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
// Задача 05: конфиг красивого экрана загрузки (из project_data.scene.loadingScreen).
|
||||||
|
const [loadingScreenCfg, setLoadingScreenCfg] = useState(null);
|
||||||
|
const [loadProgress, setLoadProgress] = useState(0);
|
||||||
// Раньше была стартовая заглушка «тапни чтобы начать» — убрали по
|
// Раньше была стартовая заглушка «тапни чтобы начать» — убрали по
|
||||||
// фидбэку, она бесила. Теперь fullscreen опционально через кнопку
|
// фидбэку, она бесила. Теперь fullscreen опционально через кнопку
|
||||||
// в углу. Этот state остался для совместимости с handleMobileStart.
|
// в углу. Этот state остался для совместимости с handleMobileStart.
|
||||||
@ -551,11 +555,18 @@ const KubikonPlayer = () => {
|
|||||||
setMeta(data);
|
setMeta(data);
|
||||||
setLikesCount(data.likes_count || 0);
|
setLikesCount(data.likes_count || 0);
|
||||||
setDislikesCount(data.dislikes_count || 0);
|
setDislikesCount(data.dislikes_count || 0);
|
||||||
|
setLoadProgress(0.3);
|
||||||
|
|
||||||
if (data.project_data) {
|
if (data.project_data) {
|
||||||
const parsed = JSON.parse(data.project_data);
|
const parsed = JSON.parse(data.project_data);
|
||||||
initialStateRef.current = parsed;
|
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);
|
await scene.loadFromState(parsed);
|
||||||
|
setLoadProgress(0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ждём пока Babylon реально загрузит и скомпилит все
|
// Ждём пока Babylon реально загрузит и скомпилит все
|
||||||
@ -592,6 +603,7 @@ const KubikonPlayer = () => {
|
|||||||
skinFolderRef.current = mySkin;
|
skinFolderRef.current = mySkin;
|
||||||
try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
|
try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
|
||||||
|
|
||||||
|
setLoadProgress(1);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
// Засчитываем плей. Передаём user_id (если залогинен) —
|
// Засчитываем плей. Передаём user_id (если залогинен) —
|
||||||
// это активирует self-cooldown (автор не накручивает себе)
|
// это активирует self-cooldown (автор не накручивает себе)
|
||||||
@ -970,6 +982,11 @@ const KubikonPlayer = () => {
|
|||||||
// Очищаем ref'ы — иначе следующий connectMultiplayer выйдет
|
// Очищаем ref'ы — иначе следующий connectMultiplayer выйдет
|
||||||
// на if (mpSyncRef.current || roomRef.current) return.
|
// на if (mpSyncRef.current || roomRef.current) return.
|
||||||
try { sync.stop?.(); } catch (e) {}
|
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;
|
mpSyncRef.current = null;
|
||||||
roomRef.current = null;
|
roomRef.current = null;
|
||||||
// Code 1000 / 1001 — нормальное закрытие. Code >= 4000 — наш
|
// Code 1000 / 1001 — нормальное закрытие. Code >= 4000 — наш
|
||||||
@ -1133,46 +1150,13 @@ const KubikonPlayer = () => {
|
|||||||
outline: 'none',
|
outline: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Loading-оверлей */}
|
{/* Задача 05: красивый экран загрузки игры (Ken Burns + название места + автор). */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{
|
<GameLoadingScreen
|
||||||
position: 'absolute', inset: 0,
|
meta={meta}
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
loadingScreen={loadingScreenCfg}
|
||||||
background:
|
progress={loadProgress}
|
||||||
'radial-gradient(ellipse at center, rgba(51,87,255,0.22) 0%, rgba(7,10,20,0.96) 60%)',
|
/>
|
||||||
gap: 18, color: HUD.text,
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
position: 'relative',
|
|
||||||
animation: 'hudFloat 3s ease-in-out infinite',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', inset: -10,
|
|
||||||
borderRadius: 20,
|
|
||||||
animation: 'hudPulseRing 1.6s ease-out infinite',
|
|
||||||
}} />
|
|
||||||
<RublocsLogo size={72} />
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 10,
|
|
||||||
fontSize: 15, fontWeight: 700, letterSpacing: 0.3,
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: 14, height: 14,
|
|
||||||
border: `2.5px solid ${HUD.accentBg}`,
|
|
||||||
borderTopColor: HUD.accent,
|
|
||||||
borderRadius: '50%',
|
|
||||||
animation: 'hudSpin 0.8s linear infinite',
|
|
||||||
}} />
|
|
||||||
Загрузка игры…
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: 11, color: HUD.textDim,
|
|
||||||
textTransform: 'uppercase', letterSpacing: 1.4, fontWeight: 700,
|
|
||||||
}}>
|
|
||||||
Рублокс • 3D
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */}
|
{/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */}
|
||||||
|
|||||||
@ -30,10 +30,13 @@ export const STORYS_addres = BASE + '/api-storys';
|
|||||||
// env-настроенные прямые URL.
|
// env-настроенные прямые URL.
|
||||||
const IS_HTTPS = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
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
|
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
|
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.
|
// Главный сайт Рублокса — куда редиректить юзеров без ticket/JWT.
|
||||||
export const RUBLOX_HOME = ENV.VITE_RUBLOX_HOME ?? 'https://rublox.pro/app';
|
export const RUBLOX_HOME = ENV.VITE_RUBLOX_HOME ?? 'https://rublox.pro/app';
|
||||||
|
|||||||
@ -96,6 +96,7 @@ import { GdForest } from './GdForest';
|
|||||||
import { GdPlayerCube } from './GdPlayerCube';
|
import { GdPlayerCube } from './GdPlayerCube';
|
||||||
import { GdPlayerTrail } from './GdPlayerTrail';
|
import { GdPlayerTrail } from './GdPlayerTrail';
|
||||||
import { GdPostFx } from './GdPostFx';
|
import { GdPostFx } from './GdPostFx';
|
||||||
|
import { GraphicsManager } from './GraphicsManager';
|
||||||
import { PhysicsAABB } from './PhysicsAABB';
|
import { PhysicsAABB } from './PhysicsAABB';
|
||||||
import { PlayerController } from './PlayerController';
|
import { PlayerController } from './PlayerController';
|
||||||
import { SelectionManager } from './SelectionManager';
|
import { SelectionManager } from './SelectionManager';
|
||||||
@ -1649,6 +1650,42 @@ export class BabylonScene {
|
|||||||
this._ssaoEnabled = false;
|
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 пост-эффект (контактные тени).
|
* Включить/выключить SSAO пост-эффект (контактные тени).
|
||||||
* Используем SSAORenderingPipeline v1 — v2 ломал thin-instance рендер
|
* Используем SSAORenderingPipeline v1 — v2 ломал thin-instance рендер
|
||||||
@ -5436,6 +5473,56 @@ export class BabylonScene {
|
|||||||
return this._isPlaying;
|
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-блок,
|
* Переключить в режим игры. Создаём PlayerController, прячем ghost-блок,
|
||||||
* запоминаем позицию редактор-камеры чтобы вернуть при exit.
|
* запоминаем позицию редактор-камеры чтобы вернуть при exit.
|
||||||
@ -5549,6 +5636,8 @@ export class BabylonScene {
|
|||||||
// поэтому скрипты стартуем в следующем кадре.
|
// поэтому скрипты стартуем в следующем кадре.
|
||||||
this.gameRuntime = new GameRuntime(this);
|
this.gameRuntime = new GameRuntime(this);
|
||||||
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
|
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
|
||||||
|
// Задача 05: стартовый экран загрузки (Ken-Burns + название места).
|
||||||
|
try { this.showStartupLoadingScreen(); } catch (e) {}
|
||||||
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
|
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
|
||||||
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
|
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
|
||||||
// this.audioManager (AudioManager — ambient/music для всех проектов).
|
// this.audioManager (AudioManager — ambient/music для всех проектов).
|
||||||
@ -7454,15 +7543,9 @@ export class BabylonScene {
|
|||||||
} else {
|
} else {
|
||||||
this._skinsConfig = null;
|
this._skinsConfig = null;
|
||||||
}
|
}
|
||||||
// Задача 12: конфиг экрана загрузки.
|
// Задача 12+05: конфиг экрана загрузки (через setLoadingConfig — единый маппинг).
|
||||||
if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') {
|
if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') {
|
||||||
const ls = state.scene.loadingScreen;
|
this.setLoadingConfig(state.scene.loadingScreen);
|
||||||
this._loadingConfig = {
|
|
||||||
logo: ls.logo || null,
|
|
||||||
accentColor: ls.accentColor || '#ffc020',
|
|
||||||
defaultSpinner: ls.defaultSpinner !== false,
|
|
||||||
defaultSkipButton: !!ls.defaultSkipButton,
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
this._loadingConfig = null;
|
this._loadingConfig = null;
|
||||||
}
|
}
|
||||||
@ -7588,6 +7671,11 @@ export class BabylonScene {
|
|||||||
if (state.scene.environment && this.environment) {
|
if (state.scene.environment && this.environment) {
|
||||||
this.environment.load(state.scene.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)
|
// Кастомное небо (задача 16)
|
||||||
if (state.scene.skybox && this.skybox) {
|
if (state.scene.skybox && this.skybox) {
|
||||||
this.skybox.load(state.scene.skybox);
|
this.skybox.load(state.scene.skybox);
|
||||||
|
|||||||
@ -537,6 +537,8 @@ export class GameRuntime {
|
|||||||
ls.setBridge(
|
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: 'loadingSkip', loadingId: id }); },
|
||||||
(id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingComplete', 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;
|
this.scene3d.loadingScreen = ls;
|
||||||
}
|
}
|
||||||
@ -1980,9 +1982,9 @@ export class GameRuntime {
|
|||||||
if (ls && payload) {
|
if (ls && payload) {
|
||||||
try {
|
try {
|
||||||
const id = ls.show(payload.opts || {});
|
const id = ls.show(payload.opts || {});
|
||||||
if (payload.replyId != null) {
|
// replyId может отсутствовать (стартовый экран) — всё равно шлём
|
||||||
for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id });
|
// 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)); }
|
} catch (e) { this._log('error', 'loading.show: ' + (e?.message || e)); }
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@ -1990,6 +1992,7 @@ export class GameRuntime {
|
|||||||
if (cmd === 'loading.setProgress') { this.scene3d?.loadingScreen?.setProgress(payload?.value); return; }
|
if (cmd === 'loading.setProgress') { this.scene3d?.loadingScreen?.setProgress(payload?.value); return; }
|
||||||
if (cmd === 'loading.setText') { this.scene3d?.loadingScreen?.setText(payload?.text); return; }
|
if (cmd === 'loading.setText') { this.scene3d?.loadingScreen?.setText(payload?.text); return; }
|
||||||
if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); 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; }
|
if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; }
|
||||||
|
|
||||||
// === Damage Floaters (задача 40) ===
|
// === Damage Floaters (задача 40) ===
|
||||||
@ -3566,6 +3569,10 @@ export class GameRuntime {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (cmd === 'graphics.set') {
|
||||||
|
try { this.scene3d?.setGraphics?.(payload || {}); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// === Задача 03: GUI tween ===
|
// === Задача 03: GUI tween ===
|
||||||
if (cmd === 'gui.tween') {
|
if (cmd === 'gui.tween') {
|
||||||
try {
|
try {
|
||||||
|
|||||||
328
src/engine/GraphicsManager.js
Normal file
328
src/engine/GraphicsManager.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -137,9 +137,16 @@ export class MultiplayerSync {
|
|||||||
// 1. Подписки на state
|
// 1. Подписки на state
|
||||||
const $ = getStateCallbacks(this.room);
|
const $ = getStateCallbacks(this.room);
|
||||||
|
|
||||||
|
// Защита от повторного срабатывания onAdd (Colyseus 0.16 + immediate:true
|
||||||
|
// может триггерить .onAdd на каждый schema patch). Локальный set хранит
|
||||||
|
// sessionId которые уже обработаны в ТЕКУЩЕМ sync объекте.
|
||||||
|
const _addedSessionIds = new Set();
|
||||||
const handleAdd = (player, sessionId) => {
|
const handleAdd = (player, sessionId) => {
|
||||||
// Не рисуем СЕБЯ как remote-меш — у нас уже есть PlayerController
|
// Не рисуем СЕБЯ как remote-меш — у нас уже есть PlayerController
|
||||||
if (sessionId === this.room.sessionId) return;
|
if (sessionId === this.room.sessionId) return;
|
||||||
|
// Защита от дублирующих onAdd событий для уже добавленного игрока
|
||||||
|
if (_addedSessionIds.has(sessionId)) return;
|
||||||
|
_addedSessionIds.add(sessionId);
|
||||||
this._addRemotePlayer(sessionId, player);
|
this._addRemotePlayer(sessionId, player);
|
||||||
// Подписываемся на изменения этого Player'а
|
// Подписываемся на изменения этого Player'а
|
||||||
$(player).onChange(() => this._updateRemoteTarget(sessionId, player));
|
$(player).onChange(() => this._updateRemoteTarget(sessionId, player));
|
||||||
@ -149,7 +156,11 @@ export class MultiplayerSync {
|
|||||||
this._attachRemoteWeapon(sessionId, val || '');
|
this._attachRemoteWeapon(sessionId, val || '');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
// Используем тот же set в handleRemove чтобы при настоящем уходе игрока
|
||||||
|
// потом можно было его снова добавить.
|
||||||
|
this._addedSessionIds = _addedSessionIds;
|
||||||
const handleRemove = (player, sessionId) => {
|
const handleRemove = (player, sessionId) => {
|
||||||
|
if (this._addedSessionIds) this._addedSessionIds.delete(sessionId);
|
||||||
this._removeRemotePlayer(sessionId);
|
this._removeRemotePlayer(sessionId);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -289,8 +300,20 @@ export class MultiplayerSync {
|
|||||||
|
|
||||||
// Интерполяция remote-игроков (позиция + yaw ставится на root,
|
// Интерполяция remote-игроков (позиция + yaw ставится на root,
|
||||||
// модель — child 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()) {
|
for (const rp of this.remotePlayers.values()) {
|
||||||
if (!rp.root || !rp.target) continue;
|
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;
|
const cur = rp.current;
|
||||||
cur.x += (rp.target.x - cur.x) * LERP_FACTOR;
|
cur.x += (rp.target.x - cur.x) * LERP_FACTOR;
|
||||||
cur.y += (rp.target.y - cur.y) * LERP_FACTOR;
|
cur.y += (rp.target.y - cur.y) * LERP_FACTOR;
|
||||||
@ -332,13 +355,25 @@ export class MultiplayerSync {
|
|||||||
// Развилка: R15-скины анимируются процедурно через R15Animator
|
// Развилка: R15-скины анимируются процедурно через R15Animator
|
||||||
// (как локальный игрок), Kenney-модели — через glTF AnimationGroups.
|
// (как локальный игрок), Kenney-модели — через glTF AnimationGroups.
|
||||||
if (rp.isR15 && rp.r15Animator && rp.modelLoaded) {
|
if (rp.isR15 && rp.r15Animator && rp.modelLoaded) {
|
||||||
// Серверный animState: 'idle' | 'run' | 'attack'. R15Animator
|
// Серверный animState: 'idle' | 'run' | 'jump' | 'fall' | 'attack'.
|
||||||
// понимает idle/walk/run/jump/fall. Сервер не различает
|
// R15Animator понимает idle/walk/run/jump/fall.
|
||||||
// walk/run и не шлёт прыжки → маппим run→run, attack→idle
|
// 2026-06-05: раньше run/jump/fall маппились в idle (баг
|
||||||
// (атака показывается отдельным swing-ом руки ниже).
|
// в маппинге), из-за чего у remote-игроков не было
|
||||||
const r15State = rp.isDead
|
// анимации ни ходьбы, ни прыжка. Теперь пробрасываем
|
||||||
? 'idle'
|
// напрямую. attack показывается отдельным swing руки.
|
||||||
: (rp.animState === 'run' ? 'run' : 'idle');
|
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.setState(r15State);
|
||||||
rp.r15Animator.update(dt);
|
rp.r15Animator.update(dt);
|
||||||
} else if (!rp.isR15) {
|
} else if (!rp.isR15) {
|
||||||
@ -632,6 +667,23 @@ export class MultiplayerSync {
|
|||||||
// === Внутреннее: меши remote-игроков ===
|
// === Внутреннее: меши remote-игроков ===
|
||||||
// =================================================================
|
// =================================================================
|
||||||
_addRemotePlayer(sessionId, player) {
|
_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 sx = player.x || 0;
|
||||||
const sy = player.y || 0;
|
const sy = player.y || 0;
|
||||||
const sz = player.z || 0;
|
const sz = player.z || 0;
|
||||||
|
|||||||
@ -485,12 +485,40 @@ export class PrimitiveManager {
|
|||||||
break;
|
break;
|
||||||
case 'glass':
|
case 'glass':
|
||||||
mat.alpha = 0.4;
|
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;
|
break;
|
||||||
case 'neon':
|
case 'neon':
|
||||||
mat.emissiveColor = Color3.FromHexString(color || '#888888');
|
mat.emissiveColor = Color3.FromHexString(color || '#888888');
|
||||||
mat.specularColor = new Color3(0, 0, 0);
|
mat.specularColor = new Color3(0, 0, 0);
|
||||||
break;
|
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': {
|
case 'studs': {
|
||||||
// Лего-материал (паритет со студией): почти-белая diffuse × цвет
|
// Лего-материал (паритет со студией): почти-белая diffuse × цвет
|
||||||
// меша + normal map. emissive = доля цвета → сочность (Roblox-look).
|
// меша + normal map. emissive = доля цвета → сочность (Roblox-look).
|
||||||
|
|||||||
@ -70,6 +70,8 @@ let _placeOnPlaceHandlers = [];
|
|||||||
let _placeOnCancelHandlers = [];
|
let _placeOnCancelHandlers = [];
|
||||||
let _placeOnMoveHandlers = [];
|
let _placeOnMoveHandlers = [];
|
||||||
let _invUiSlotClickHandlers = [];
|
let _invUiSlotClickHandlers = [];
|
||||||
|
// Задача 05: зеркало видимости экрана загрузки (для game.loading.isVisible()).
|
||||||
|
let _loadingVisible = false;
|
||||||
// Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }.
|
// Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }.
|
||||||
let _players = { me: null, list: [] };
|
let _players = { me: null, list: [] };
|
||||||
// Общее состояние комнаты game.room.get/set — зеркало из main thread.
|
// Общее состояние комнаты game.room.get/set — зеркало из main thread.
|
||||||
@ -2776,6 +2778,7 @@ const game = {
|
|||||||
_localSeq: 0,
|
_localSeq: 0,
|
||||||
_localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown)
|
_localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown)
|
||||||
_handlers: new Map(), // localId → { onSkip:[], onComplete:[] }
|
_handlers: new Map(), // localId → { onSkip:[], onComplete:[] }
|
||||||
|
_onHide: [], // задача 05 — глобальные подписки на скрытие
|
||||||
show(opts) {
|
show(opts) {
|
||||||
opts = opts && typeof opts === 'object' ? opts : {};
|
opts = opts && typeof opts === 'object' ? opts : {};
|
||||||
const localId = ++this._localSeq;
|
const localId = ++this._localSeq;
|
||||||
@ -2794,11 +2797,20 @@ const game = {
|
|||||||
setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); },
|
setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); },
|
||||||
setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); },
|
setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); },
|
||||||
setCover(c) { _send('loading.setCover', { localId, cover: c }); },
|
setCover(c) { _send('loading.setCover', { localId, cover: c }); },
|
||||||
|
setBackground(b) { _send('loading.setBackground', { localId, background: b }); },
|
||||||
close() { _send('loading.close', { localId }); },
|
close() { _send('loading.close', { localId }); },
|
||||||
onSkip(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onSkip.push(fn); },
|
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); },
|
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). */
|
/** Типовой переход с фейковым прогрессом. Возвращает Promise (резолв по complete/skip). */
|
||||||
transition(opts) {
|
transition(opts) {
|
||||||
opts = opts && typeof opts === 'object' ? { ...opts } : {};
|
opts = opts && typeof opts === 'object' ? { ...opts } : {};
|
||||||
@ -3489,6 +3501,41 @@ const game = {
|
|||||||
_send('environment.setTimeOfDay', { hours: h });
|
_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' мышь работает как обычный курсор (как в браузере),
|
* В режиме 'ui' мышь работает как обычный курсор (как в браузере),
|
||||||
@ -4270,6 +4317,7 @@ self.onmessage = (e) => {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
} else if (t === 'loadingShown') {
|
} else if (t === 'loadingShown') {
|
||||||
// Задача 12: реальный loadingId от runtime — маппим local→real.
|
// Задача 12: реальный loadingId от runtime — маппим local→real.
|
||||||
|
_loadingVisible = true;
|
||||||
try {
|
try {
|
||||||
const lo = (typeof game !== 'undefined') && game.loading;
|
const lo = (typeof game !== 'undefined') && game.loading;
|
||||||
if (lo && payload && payload.replyId) {
|
if (lo && payload && payload.replyId) {
|
||||||
@ -4279,6 +4327,13 @@ self.onmessage = (e) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (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') {
|
} else if (t === 'loadingSkip' || t === 'loadingComplete') {
|
||||||
// Игрок нажал «Пропустить» или прогресс достиг 100% — зовём подписчиков.
|
// Игрок нажал «Пропустить» или прогресс достиг 100% — зовём подписчиков.
|
||||||
try {
|
try {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user