Compare commits
No commits in common. "7d8a41d4d8d2ac84618ad206f444afb145025fbf" and "f7441b0bd66d89ab81301812e60d45667ea67c0c" have entirely different histories.
7d8a41d4d8
...
f7441b0bd6
@ -59,28 +59,10 @@ STORAGE_ROOT = os.environ.get('STORAGE_ROOT', '/opt/roblox-assets')
|
|||||||
PUBLIC_ASSET_BASE = os.environ.get('PUBLIC_ASSET_BASE', 'https://assets.rublox.pro/roblox')
|
PUBLIC_ASSET_BASE = os.environ.get('PUBLIC_ASSET_BASE', 'https://assets.rublox.pro/roblox')
|
||||||
|
|
||||||
MAX_RBXL_SIZE = 50 * 1024 * 1024 # 50 MB
|
MAX_RBXL_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||||
|
ALLOWED_USER_IDS = [1] # пока только МИН
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
# CORS открыт для всех источников — фронт студии живёт на studio.rublox.pro,
|
CORS(app, resources={r'/*': {'origins': '*'}})
|
||||||
# api-rbxl проксируется через NPM на minecraftia-school.ru/api-rbxl/*.
|
|
||||||
# Поддерживаем preflight (OPTIONS) явно через after_request — иногда
|
|
||||||
# flask-cors не отдавал заголовки для OPTIONS если NPM их перекрывал.
|
|
||||||
CORS(app, resources={r'/*': {'origins': '*'}}, supports_credentials=False)
|
|
||||||
|
|
||||||
|
|
||||||
@app.after_request
|
|
||||||
def _add_cors_headers(resp):
|
|
||||||
resp.headers['Access-Control-Allow-Origin'] = '*'
|
|
||||||
resp.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
|
|
||||||
resp.headers['Access-Control-Allow-Headers'] = 'Content-Type, X-User-Id, X-User-Login'
|
|
||||||
resp.headers['Access-Control-Max-Age'] = '3600'
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/import/rbxl/analyze', methods=['OPTIONS'])
|
|
||||||
@app.route('/import/rbxl/create', methods=['OPTIONS'])
|
|
||||||
def _preflight():
|
|
||||||
return '', 204
|
|
||||||
|
|
||||||
# Devlog для удалённой отладки dev-сессий студии: фронт пушит сюда
|
# Devlog для удалённой отладки dev-сессий студии: фронт пушит сюда
|
||||||
# console.error/warn, failed network requests, неожиданные exceptions.
|
# console.error/warn, failed network requests, неожиданные exceptions.
|
||||||
@ -111,7 +93,8 @@ def auth_check(req) -> int:
|
|||||||
uid = int(user_id_str)
|
uid = int(user_id_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise RuntimeError(f'Bad X-User-Id: {user_id_str!r}')
|
raise RuntimeError(f'Bad X-User-Id: {user_id_str!r}')
|
||||||
# Импорт открыт всем (см. вики «Импорт из Roblox»).
|
if uid not in ALLOWED_USER_IDS:
|
||||||
|
raise RuntimeError(f'User {uid} not allowed (only МИН)')
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -149,7 +149,7 @@ const AI_CONTEXT = `Ты — помощник по написанию скрип
|
|||||||
|
|
||||||
=== СЦЕНА game.scene ===
|
=== СЦЕНА game.scene ===
|
||||||
spawn(type, opts) -> объект. type: 'cube'|'sphere'|'cylinder'|'cone'|'pyramid'|'torus'|'wedge'|'plane' (примитивы), 'model:ID', 'block:ID', 'vehicle:car', 'light:point', 'billboard', 'trigger'.
|
spawn(type, opts) -> объект. type: 'cube'|'sphere'|'cylinder'|'cone'|'pyramid'|'torus'|'wedge'|'plane' (примитивы), 'model:ID', 'block:ID', 'vehicle:car', 'light:point', 'billboard', 'trigger'.
|
||||||
opts: {x,y,z, sx,sy,sz, rotationX,rotationY,rotationZ, color:'#hex', material:'matte'|'neon'|'metal'|'glass'|'studs'|'chrome'|'water'|'iridescent', name, anchored:true(висит)/false(падает), canCollide, visible, mass, lifetime(сек до авто-удаления)}
|
opts: {x,y,z, sx,sy,sz, rotationX,rotationY,rotationZ, color:'#hex', material:'matte'|'neon'|'metal'|'glass'|'studs', name, anchored:true(висит)/false(падает), canCollide, visible, mass, lifetime(сек до авто-удаления)}
|
||||||
delete(ref); move(ref,x,y,z); setRotation(ref,rx,ry,rz); setColor(ref,'#hex'); setMaterial(ref,name); setVisible(ref,bool); setCollide(ref,bool); setOpacity(ref,0..1); setScale(ref,sx,sy,sz)
|
delete(ref); move(ref,x,y,z); setRotation(ref,rx,ry,rz); setColor(ref,'#hex'); setMaterial(ref,name); setVisible(ref,bool); setCollide(ref,bool); setOpacity(ref,0..1); setScale(ref,sx,sy,sz)
|
||||||
setLabel(ref,text,opts); clearLabel(ref); setData(ref,key,val); getData(ref,key)
|
setLabel(ref,text,opts); clearLabel(ref); setData(ref,key,val); getData(ref,key)
|
||||||
find(name)->[...]; findOne(name)->объект|null; all('primitive'|'model'|'block')
|
find(name)->[...]; findOne(name)->объект|null; all('primitive'|'model'|'block')
|
||||||
@ -201,10 +201,6 @@ shake(amp, sec); setFov(deg); focusOn(ref,{distance,height}); cutscene([{x,y,z}.
|
|||||||
=== ОКРУЖЕНИЕ game.environment ===
|
=== ОКРУЖЕНИЕ game.environment ===
|
||||||
setSkyColor('#hex'); setFog({enabled,color,density}); setTimeOfDay(0..24)
|
setSkyColor('#hex'); setFog({enabled,color,density}); setTimeOfDay(0..24)
|
||||||
|
|
||||||
=== ГРАФИКА/ЭФФЕКТЫ game.graphics (по умолч. выкл) ===
|
|
||||||
setPreset('off'|'low'|'medium'|'high'|'ultra'|'cinematic'|'vivid'|'night'|'retro'|'soft')
|
|
||||||
setBloom(bool,{intensity:0..1,threshold:0..1}); setVignette(0..1.5); setColorGrading({contrast,saturation,exposure}); setShadows('off'|'hard'|'soft'|'medium'|'high'); setSSAO(bool); setDepthOfField(bool); setAntialiasing(bool); off()
|
|
||||||
|
|
||||||
=== ИНВЕНТАРЬ / ПРЕДМЕТЫ ===
|
=== ИНВЕНТАРЬ / ПРЕДМЕТЫ ===
|
||||||
game.items.define([{id,name,emoji,rarity:'common'|'rare'|'epic'|'legendary',maxStack,onUseEffect:'heal:50'}])
|
game.items.define([{id,name,emoji,rarity:'common'|'rare'|'epic'|'legendary',maxStack,onUseEffect:'heal:50'}])
|
||||||
game.inventory.give(id,count); .take(id,count); .has(id); .open(); .list()
|
game.inventory.give(id,count); .take(id,count); .has(id); .open(); .list()
|
||||||
@ -227,8 +223,7 @@ game.log(...) — в консоль; game.random(min,max,целое?); game.clam
|
|||||||
- Повороты в РАДИАНАХ (Math.PI/2 = 90°).
|
- Повороты в РАДИАНАХ (Math.PI/2 = 90°).
|
||||||
- Счётчики/общую логику держи в ОДНОМ глобальном скрипте; объекты шлют game.broadcast (скрипты объектов не видят переменные друг друга).
|
- Счётчики/общую логику держи в ОДНОМ глобальном скрипте; объекты шлют game.broadcast (скрипты объектов не видят переменные друг друга).
|
||||||
- spawn примитива: тип без префикса ('cube'), модели — с 'model:', машина — 'vehicle:car'.
|
- spawn примитива: тип без префикса ('cube'), модели — с 'model:', машина — 'vehicle:car'.
|
||||||
- material: matte, neon, metal, glass, studs, chrome (зеркало), water (вода), iridescent (переливы).
|
- material только: matte, neon, metal, glass, studs.
|
||||||
- эффекты включаются через game.graphics.setPreset(...) или в настройках игры; по умолчанию выкл.
|
|
||||||
- Для сбора предмета: game.self.onTouch(()=>{ game.broadcast('coin'); game.self.delete(); }).
|
- Для сбора предмета: game.self.onTouch(()=>{ game.broadcast('coin'); game.self.delete(); }).
|
||||||
- Не используй require() и DOM/window — только game.* и обычный JS.
|
- Не используй require() и DOM/window — только game.* и обычный JS.
|
||||||
|
|
||||||
@ -5602,152 +5597,6 @@ end)`}</Code>}
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════
|
|
||||||
// РАЗДЕЛ — ГРАФИКА И ЭФФЕКТЫ (шейдеры)
|
|
||||||
// ════════════════════════════════════════════════════
|
|
||||||
{
|
|
||||||
id: 'graphics',
|
|
||||||
icon: 'sparkles',
|
|
||||||
title: 'Графика и эффекты',
|
|
||||||
summary: 'Шейдеры-эффекты: свечение, цветокоррекция, тени, красивые материалы (хром, вода, переливы). Через настройки и из скриптов.',
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: 'gfx-what',
|
|
||||||
title: 'GR1. Что такое эффекты (шейдеры)',
|
|
||||||
body: (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
<b>Эффекты (шейдеры)</b> делают картинку игры красивее —
|
|
||||||
как «шейдер-паки» в Майнкрафте. Это:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li><b>Свечение (Bloom)</b> — яркие объекты (неон, лампы,
|
|
||||||
солнце) светятся и переливаются;</li>
|
|
||||||
<li><b>Цветокоррекция</b> — насыщенность и контраст, как
|
|
||||||
в кино;</li>
|
|
||||||
<li><b>Виньетка</b> — мягкое затемнение по краям экрана;</li>
|
|
||||||
<li><b>Тени</b> — мягкие или резкие, контактные тени в углах;</li>
|
|
||||||
<li><b>Глубина резкости</b> — размытие дальнего плана;</li>
|
|
||||||
<li><b>Сглаживание</b> — убирает «лесенки» на краях.</li>
|
|
||||||
</ul>
|
|
||||||
<Note>
|
|
||||||
По умолчанию эффекты <b>выключены</b> — игра выглядит как
|
|
||||||
раньше. Включи их в настройках или из скрипта, когда захочешь
|
|
||||||
«прокачать» картинку. На слабых компах/телефонах тяжёлые
|
|
||||||
эффекты <b>сами</b> упрощаются, чтобы игра не тормозила.
|
|
||||||
</Note>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'gfx-settings',
|
|
||||||
title: 'GR2. Включить эффекты в настройках',
|
|
||||||
body: (
|
|
||||||
<>
|
|
||||||
<Step n={1}>
|
|
||||||
Открой <kbd className="kbd">Настройки</kbd> игры (шестерёнка
|
|
||||||
вверху).
|
|
||||||
</Step>
|
|
||||||
<Step n={2}>
|
|
||||||
Найди раздел <b>«Графика и эффекты»</b> и выбери <b>пресет</b>:
|
|
||||||
</Step>
|
|
||||||
<ul>
|
|
||||||
<li><b>Выключено</b> — без эффектов (по умолчанию);</li>
|
|
||||||
<li><b>Низкое / Среднее / Высокое / Ультра</b> — всё больше
|
|
||||||
красоты (и нагрузки);</li>
|
|
||||||
<li><b>🎬 Кино</b> — контраст + виньетка + глубина резкости;</li>
|
|
||||||
<li><b>🌈 Сочное</b> — яркие насыщенные цвета;</li>
|
|
||||||
<li><b>🌙 Ночь</b> — тёмная атмосфера, сильное свечение;</li>
|
|
||||||
<li><b>📺 Ретро</b> — старый ламповый вид;</li>
|
|
||||||
<li><b>☁️ Мягкое</b> — нежная пастельная картинка.</li>
|
|
||||||
</ul>
|
|
||||||
<Step n={3}>
|
|
||||||
Или настрой вручную галочками (свечение, сглаживание,
|
|
||||||
виньетка, контактные тени) и ползунками (насыщенность,
|
|
||||||
контраст). Нажми <kbd className="kbd">Сохранить</kbd> — эффект
|
|
||||||
появится сразу.
|
|
||||||
</Step>
|
|
||||||
<Try>
|
|
||||||
Поставь несколько неоновых кубов, включи пресет «Ночь» — они
|
|
||||||
будут красиво светиться в темноте.
|
|
||||||
</Try>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'gfx-materials',
|
|
||||||
title: 'GR3. Красивые материалы',
|
|
||||||
body: (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
У каждого примитива есть <b>материал</b> (в свойствах объекта
|
|
||||||
или при создании из скрипта). Кроме обычных есть «шейдерные»:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li><b>neon</b> — светится (works с Bloom особенно красиво);</li>
|
|
||||||
<li><b>metal</b> — металлический блик;</li>
|
|
||||||
<li><b>chrome</b> — зеркальный хром;</li>
|
|
||||||
<li><b>glass</b> — прозрачное стекло;</li>
|
|
||||||
<li><b>water</b> — полупрозрачная вода с бликами;</li>
|
|
||||||
<li><b>iridescent</b> — переливы (бензиновая плёнка, кристалл);</li>
|
|
||||||
<li><b>studs</b> — лего-пупырышки;</li>
|
|
||||||
<li><b>matte</b> — матовый (без блеска, по умолчанию).</li>
|
|
||||||
</ul>
|
|
||||||
<ScriptKind kind="global" />
|
|
||||||
<Code>{`// хромированная сфера
|
|
||||||
game.scene.spawn('sphere', { x: 0, y: 2, z: 0, color: '#dfe6f0', material: 'chrome' });
|
|
||||||
// светящийся неон-куб
|
|
||||||
game.scene.spawn('cube', { x: 3, y: 2, z: 0, color: '#00ffd0', material: 'neon' });
|
|
||||||
// переливающийся кристалл
|
|
||||||
game.scene.spawn('cone', { x: -3, y: 2, z: 0, color: '#a06bff', material: 'iridescent' });
|
|
||||||
|
|
||||||
// поменять материал существующего объекта:
|
|
||||||
const obj = game.scene.findOne('Вода');
|
|
||||||
obj.material = 'water';`}</Code>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'gfx-api',
|
|
||||||
title: 'GR4. Эффекты из скриптов (game.graphics)',
|
|
||||||
body: (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
Эффекты можно включать и менять <b>на ходу</b> из скрипта —
|
|
||||||
например затемнить мир ночью или включить «кино» в катсцене.
|
|
||||||
</p>
|
|
||||||
<ScriptKind kind="global" />
|
|
||||||
<Code>{`// применить готовый пресет
|
|
||||||
game.graphics.setPreset('cinematic');
|
|
||||||
|
|
||||||
// точечная настройка
|
|
||||||
game.graphics.setBloom(true, { intensity: 0.7 }); // свечение
|
|
||||||
game.graphics.setVignette(0.5); // затемнение краёв
|
|
||||||
game.graphics.setColorGrading({ saturation: 1.4, contrast: 1.1 });
|
|
||||||
game.graphics.setShadows('soft'); // off|hard|soft|medium|high
|
|
||||||
game.graphics.setSSAO(true); // контактные тени
|
|
||||||
game.graphics.setDepthOfField(true); // размытие дальнего плана
|
|
||||||
|
|
||||||
// выключить всё
|
|
||||||
game.graphics.off();`}</Code>
|
|
||||||
<p>Пример — плавно «сделать ночь» при входе в пещеру:</p>
|
|
||||||
<Code>{`const cave = game.scene.findOne('ВходВПещеру');
|
|
||||||
cave.onTouch(() => {
|
|
||||||
game.graphics.setPreset('night');
|
|
||||||
game.environment.setTimeOfDay(0);
|
|
||||||
game.ui.showText('Темнеет...', 2);
|
|
||||||
});`}</Code>
|
|
||||||
<Note>
|
|
||||||
Эффекты применяются ко всему экрану. Если игра должна
|
|
||||||
работать на телефонах — не включай разом DoF + SSAO + ультра-
|
|
||||||
тени: движок их урежет, но лучше выбрать пресет полегче.
|
|
||||||
</Note>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -83,7 +83,7 @@ export function highlightCode(text, lang) {
|
|||||||
// Классифицируем
|
// Классифицируем
|
||||||
if (tok.startsWith('--') || tok.startsWith('//') || tok.startsWith('/*')) {
|
if (tok.startsWith('--') || tok.startsWith('//') || tok.startsWith('/*')) {
|
||||||
tokens.push({ type: 'comment', text: tok });
|
tokens.push({ type: 'comment', text: tok });
|
||||||
} else if (/^["'`[]/.test(tok)) {
|
} else if (/^["'`\[]/.test(tok)) {
|
||||||
tokens.push({ type: 'string', text: tok });
|
tokens.push({ type: 'string', text: tok });
|
||||||
} else if (/^\d/.test(tok)) {
|
} else if (/^\d/.test(tok)) {
|
||||||
tokens.push({ type: 'number', text: tok });
|
tokens.push({ type: 'number', text: tok });
|
||||||
|
|||||||
@ -1,440 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
|
||||||
import cl from './GameSettingsModal.module.css';
|
|
||||||
import Icon from './Icon';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GameDecorModal — «оформление» игры: Графика и эффекты, Стартовый экран
|
|
||||||
* (Ken Burns), Экран загрузки. Вынесено из настроек во вкладку «Игра»
|
|
||||||
* (TopRibbon) тремя отдельными кнопками. Открывается с нужной секцией через
|
|
||||||
* проп `section` ('graphics' | 'startscreen' | 'loadingscreen'), вверху —
|
|
||||||
* табы для переключения между ними.
|
|
||||||
*
|
|
||||||
* Props:
|
|
||||||
* open — открыто ли
|
|
||||||
* section — какую секцию показать первой
|
|
||||||
* initial — { loading_screen:{...}, graphics:{...} } (из scene/проекта)
|
|
||||||
* onClose — закрыть без сохранения
|
|
||||||
* onSave(data) — data = { loading_screen, graphics } (как в GameSettingsModal)
|
|
||||||
*/
|
|
||||||
const MAX_IMG_BYTES = 500 * 1024;
|
|
||||||
|
|
||||||
const TABS = [
|
|
||||||
{ id: 'graphics', title: 'Графика', icon: 'sparkles' },
|
|
||||||
{ id: 'startscreen', title: 'Стартовый экран', icon: 'loader' },
|
|
||||||
{ id: 'loadingscreen', title: 'Экран загрузки', icon: 'loader' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const GameDecorModal = ({ open, section = 'graphics', initial, onClose, onSave }) => {
|
|
||||||
const [tab, setTab] = useState(section);
|
|
||||||
|
|
||||||
// Экран загрузки
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState('');
|
|
||||||
const [loadingAccent, setLoadingAccent] = useState('#ffc020');
|
|
||||||
const [loadingSpinner, setLoadingSpinner] = useState(true);
|
|
||||||
const [loadingSkip, setLoadingSkip] = useState(false);
|
|
||||||
// Стартовый Ken-Burns экран
|
|
||||||
const [lsEnabled, setLsEnabled] = useState(true);
|
|
||||||
const [lsBackground, setLsBackground] = useState('');
|
|
||||||
const [lsCover, setLsCover] = useState('');
|
|
||||||
const [lsStyle, setLsStyle] = useState('ken-burns');
|
|
||||||
const [lsPlaceName, setLsPlaceName] = useState('');
|
|
||||||
const [lsStudioName, setLsStudioName] = useState('');
|
|
||||||
const [lsVerified, setLsVerified] = useState(false);
|
|
||||||
const [lsDuration, setLsDuration] = useState(2.5);
|
|
||||||
const [lsProgressBar, setLsProgressBar] = useState(true);
|
|
||||||
// Графика
|
|
||||||
const [gfxPreset, setGfxPreset] = useState('off');
|
|
||||||
const [gfxBloom, setGfxBloom] = useState(false);
|
|
||||||
const [gfxFxaa, setGfxFxaa] = useState(false);
|
|
||||||
const [gfxVignette, setGfxVignette] = useState(false);
|
|
||||||
const [gfxSsao, setGfxSsao] = useState(false);
|
|
||||||
const [gfxSaturation, setGfxSaturation] = useState(1.0);
|
|
||||||
const [gfxContrast, setGfxContrast] = useState(1.0);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const logoInputRef = useRef(null);
|
|
||||||
const lsBgInputRef = useRef(null);
|
|
||||||
const lsCoverInputRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
setTab(section || 'graphics');
|
|
||||||
const ls = initial?.loading_screen || {};
|
|
||||||
setLoadingLogo(ls.logo || '');
|
|
||||||
setLoadingAccent(ls.accentColor || '#ffc020');
|
|
||||||
setLoadingSpinner(ls.defaultSpinner !== false);
|
|
||||||
setLoadingSkip(!!ls.defaultSkipButton);
|
|
||||||
setLsEnabled(ls.enabled !== false);
|
|
||||||
setLsBackground(ls.background || '');
|
|
||||||
setLsCover(ls.cover || '');
|
|
||||||
setLsStyle(ls.style || 'ken-burns');
|
|
||||||
setLsPlaceName(ls.placeName || '');
|
|
||||||
setLsStudioName(ls.studioName || '');
|
|
||||||
setLsVerified(!!ls.verified);
|
|
||||||
setLsDuration(Number.isFinite(ls.duration) && ls.duration > 0 ? ls.duration : 2.5);
|
|
||||||
setLsProgressBar(ls.progressBar !== false);
|
|
||||||
const gx = initial?.graphics || {};
|
|
||||||
setGfxPreset(gx.preset || 'off');
|
|
||||||
setGfxBloom(!!(gx.bloom && gx.bloom.enabled));
|
|
||||||
setGfxFxaa(!!gx.fxaa);
|
|
||||||
setGfxVignette(!!(gx.vignette && gx.vignette.enabled));
|
|
||||||
setGfxSsao(!!gx.ssao);
|
|
||||||
setGfxSaturation((gx.grading && Number.isFinite(gx.grading.saturation)) ? gx.grading.saturation : 1.0);
|
|
||||||
setGfxContrast((gx.grading && Number.isFinite(gx.grading.contrast)) ? gx.grading.contrast : 1.0);
|
|
||||||
setError('');
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open, section]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
const handleLogoSelect = (e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Логотип: только PNG, JPG или WEBP'); return; }
|
|
||||||
if (file.size > MAX_IMG_BYTES) { setError('Логотип слишком большой (макс. 500 КБ)'); return; }
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (ev) => { setLoadingLogo(ev.target.result); setError(''); };
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
};
|
|
||||||
const handleLsImage = (e, setter) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Только PNG, JPG или WEBP'); return; }
|
|
||||||
if (file.size > MAX_IMG_BYTES) { setError('Изображение слишком большое (макс. 500 КБ)'); return; }
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (ev) => { setter(ev.target.result); setError(''); };
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyPresetToToggles = (preset) => {
|
|
||||||
setGfxPreset(preset);
|
|
||||||
const P = {
|
|
||||||
off: { b: false, f: false, v: false, s: false, sat: 1.0, con: 1.0 },
|
|
||||||
low: { b: true, f: true, v: false, s: false, sat: 1.0, con: 1.0 },
|
|
||||||
medium: { b: true, f: true, v: true, s: false, sat: 1.1, con: 1.05 },
|
|
||||||
high: { b: true, f: true, v: true, s: true, sat: 1.2, con: 1.1 },
|
|
||||||
ultra: { b: true, f: true, v: true, s: true, sat: 1.25, con: 1.12 },
|
|
||||||
cinematic: { b: true, f: true, v: true, s: true, sat: 1.05, con: 1.18 },
|
|
||||||
vivid: { b: true, f: true, v: false, s: false, sat: 1.5, con: 1.1 },
|
|
||||||
night: { b: true, f: true, v: true, s: true, sat: 0.85, con: 1.2 },
|
|
||||||
retro: { b: false, f: false, v: true, s: false, sat: 0.7, con: 1.3 },
|
|
||||||
soft: { b: true, f: true, v: true, s: false, sat: 1.05, con: 0.95 },
|
|
||||||
}[preset];
|
|
||||||
if (!P) return;
|
|
||||||
setGfxBloom(P.b); setGfxFxaa(P.f); setGfxVignette(P.v);
|
|
||||||
setGfxSsao(P.s); setGfxSaturation(P.sat); setGfxContrast(P.con);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSave({
|
|
||||||
loading_screen: {
|
|
||||||
logo: loadingLogo || null,
|
|
||||||
accentColor: loadingAccent || '#ffc020',
|
|
||||||
defaultSpinner: loadingSpinner,
|
|
||||||
defaultSkipButton: loadingSkip,
|
|
||||||
enabled: lsEnabled,
|
|
||||||
background: lsBackground || null,
|
|
||||||
cover: lsCover || null,
|
|
||||||
style: lsStyle || 'ken-burns',
|
|
||||||
placeName: lsPlaceName.trim(),
|
|
||||||
studioName: lsStudioName.trim(),
|
|
||||||
verified: lsVerified,
|
|
||||||
duration: Math.max(1, Math.min(10, Number(lsDuration) || 2.5)),
|
|
||||||
progressBar: lsProgressBar,
|
|
||||||
},
|
|
||||||
graphics: {
|
|
||||||
preset: gfxPreset,
|
|
||||||
bloom: { enabled: gfxBloom },
|
|
||||||
fxaa: gfxFxaa,
|
|
||||||
vignette: { enabled: gfxVignette },
|
|
||||||
ssao: gfxSsao,
|
|
||||||
grading: {
|
|
||||||
enabled: (gfxSaturation !== 1.0 || gfxContrast !== 1.0),
|
|
||||||
saturation: Number(gfxSaturation) || 1.0,
|
|
||||||
contrast: Number(gfxContrast) || 1.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cl.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
|
||||||
<div className={cl.modal}>
|
|
||||||
<div className={cl.header}>
|
|
||||||
<div className={cl.title}><Icon name="sparkles" size={16} /> Оформление игры</div>
|
|
||||||
<button className={cl.closeBtn} onClick={onClose}><Icon name="close" size={14} /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Табы разделов */}
|
|
||||||
<div style={{ display: 'flex', gap: 6, padding: '0 18px', marginTop: 6, flexWrap: 'wrap' }}>
|
|
||||||
{TABS.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTab(t.id)}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
padding: '8px 14px', borderRadius: 8, cursor: 'pointer',
|
|
||||||
border: '1px solid ' + (tab === t.id ? 'rgba(120,150,255,0.6)' : 'rgba(255,255,255,0.10)'),
|
|
||||||
background: tab === t.id ? 'rgba(90,120,255,0.18)' : 'transparent',
|
|
||||||
color: tab === t.id ? '#dfe6ff' : '#aab', fontSize: 13, fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name={t.icon} size={13} /> {t.title}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className={cl.body}>
|
|
||||||
|
|
||||||
{/* === ГРАФИКА === */}
|
|
||||||
{tab === 'graphics' && (
|
|
||||||
<div className={cl.field}>
|
|
||||||
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
|
|
||||||
Свечение, цветокоррекция, тени и сглаживание (как шейдеры).
|
|
||||||
По умолчанию выключено. На слабых устройствах тяжёлые эффекты
|
|
||||||
урезаются автоматически. Также управляется из скриптов (game.graphics).
|
|
||||||
</div>
|
|
||||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 12 }}>
|
|
||||||
<span style={{ fontSize: 12, color: '#aab' }}>Пресет</span>
|
|
||||||
<select className={cl.select} value={gfxPreset}
|
|
||||||
onChange={(e) => applyPresetToToggles(e.target.value)}>
|
|
||||||
<option value="off">Выключено</option>
|
|
||||||
<option value="low">Низкое (свечение)</option>
|
|
||||||
<option value="medium">Среднее</option>
|
|
||||||
<option value="high">Высокое</option>
|
|
||||||
<option value="ultra">Ультра (с глубиной резкости)</option>
|
|
||||||
<option value="cinematic">🎬 Кино</option>
|
|
||||||
<option value="vivid">🌈 Сочное</option>
|
|
||||||
<option value="night">🌙 Ночь</option>
|
|
||||||
<option value="retro">📺 Ретро</option>
|
|
||||||
<option value="soft">☁️ Мягкое</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<div className={cl.togglesRow}>
|
|
||||||
<label className={cl.toggleRow}>
|
|
||||||
<input type="checkbox" className={cl.toggle}
|
|
||||||
checked={gfxBloom} onChange={(e) => { setGfxBloom(e.target.checked); setGfxPreset('custom'); }} />
|
|
||||||
<div className={cl.toggleText}>
|
|
||||||
<div className={cl.toggleTitle}>Свечение (Bloom)</div>
|
|
||||||
<div className={cl.toggleHint}><span>Яркие объекты светятся</span></div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label className={cl.toggleRow}>
|
|
||||||
<input type="checkbox" className={cl.toggle}
|
|
||||||
checked={gfxFxaa} onChange={(e) => { setGfxFxaa(e.target.checked); setGfxPreset('custom'); }} />
|
|
||||||
<div className={cl.toggleText}>
|
|
||||||
<div className={cl.toggleTitle}>Сглаживание</div>
|
|
||||||
<div className={cl.toggleHint}><span>Убирает «лесенки» на краях</span></div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label className={cl.toggleRow}>
|
|
||||||
<input type="checkbox" className={cl.toggle}
|
|
||||||
checked={gfxVignette} onChange={(e) => { setGfxVignette(e.target.checked); setGfxPreset('custom'); }} />
|
|
||||||
<div className={cl.toggleText}>
|
|
||||||
<div className={cl.toggleTitle}>Виньетка</div>
|
|
||||||
<div className={cl.toggleHint}><span>Мягкое затемнение по краям</span></div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label className={cl.toggleRow}>
|
|
||||||
<input type="checkbox" className={cl.toggle}
|
|
||||||
checked={gfxSsao} onChange={(e) => { setGfxSsao(e.target.checked); setGfxPreset('custom'); }} />
|
|
||||||
<div className={cl.toggleText}>
|
|
||||||
<div className={cl.toggleTitle}>Контактные тени (SSAO)</div>
|
|
||||||
<div className={cl.toggleHint}><span>Затемнение в углах и стыках</span></div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 16, marginTop: 12, flexWrap: 'wrap' }}>
|
|
||||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: 1, minWidth: 160 }}>
|
|
||||||
<span style={{ fontSize: 12, color: '#aab' }}>Насыщенность: {gfxSaturation.toFixed(2)}</span>
|
|
||||||
<input type="range" min="0.5" max="2" step="0.05" value={gfxSaturation}
|
|
||||||
onChange={(e) => { setGfxSaturation(Number(e.target.value)); setGfxPreset('custom'); }} />
|
|
||||||
</label>
|
|
||||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: 1, minWidth: 160 }}>
|
|
||||||
<span style={{ fontSize: 12, color: '#aab' }}>Контраст: {gfxContrast.toFixed(2)}</span>
|
|
||||||
<input type="range" min="0.5" max="1.6" step="0.05" value={gfxContrast}
|
|
||||||
onChange={(e) => { setGfxContrast(Number(e.target.value)); setGfxPreset('custom'); }} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* === СТАРТОВЫЙ ЭКРАН (Ken Burns) === */}
|
|
||||||
{tab === 'startscreen' && (
|
|
||||||
<div className={cl.field}>
|
|
||||||
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
|
|
||||||
Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор.
|
|
||||||
</div>
|
|
||||||
<label className={cl.toggleRow} style={{ marginBottom: 10 }}>
|
|
||||||
<input type="checkbox" className={cl.toggle}
|
|
||||||
checked={lsEnabled} onChange={(e) => setLsEnabled(e.target.checked)} />
|
|
||||||
<div className={cl.toggleText}>
|
|
||||||
<div className={cl.toggleTitle}>Показывать стартовый экран</div>
|
|
||||||
<div className={cl.toggleHint}><span>Если выключено — игрок сразу попадает в 3D-сцену</span></div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
{lsEnabled && (
|
|
||||||
<>
|
|
||||||
<div style={{ display: 'flex', gap: 14, marginBottom: 12, flexWrap: 'wrap' }}>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
<div style={{
|
|
||||||
width: 130, height: 74, borderRadius: 8, background: '#15192a',
|
|
||||||
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
backgroundImage: lsBackground ? `url(${lsBackground})` : 'none',
|
|
||||||
backgroundSize: 'cover', backgroundPosition: 'center',
|
|
||||||
}}>
|
|
||||||
{!lsBackground && <span style={{ color: '#5a6178', fontSize: 11 }}>фон (размытый)</span>}
|
|
||||||
</div>
|
|
||||||
<button type="button" className={cl.actionBtn} onClick={() => lsBgInputRef.current?.click()}>
|
|
||||||
<Icon name="folder" size={14} /> Фон
|
|
||||||
</button>
|
|
||||||
{lsBackground && (
|
|
||||||
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsBackground('')}>
|
|
||||||
<Icon name="close" size={13} /> Убрать
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<input ref={lsBgInputRef} type="file" accept="image/png,image/jpeg,image/webp"
|
|
||||||
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsBackground)} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
<div style={{
|
|
||||||
width: 74, height: 74, borderRadius: 12, background: '#15192a',
|
|
||||||
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
backgroundImage: lsCover ? `url(${lsCover})` : 'none',
|
|
||||||
backgroundSize: 'cover', backgroundPosition: 'center',
|
|
||||||
}}>
|
|
||||||
{!lsCover && <span style={{ color: '#5a6178', fontSize: 10, textAlign: 'center' }}>карточка</span>}
|
|
||||||
</div>
|
|
||||||
<button type="button" className={cl.actionBtn} onClick={() => lsCoverInputRef.current?.click()}>
|
|
||||||
<Icon name="folder" size={14} /> Карточка
|
|
||||||
</button>
|
|
||||||
{lsCover && (
|
|
||||||
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsCover('')}>
|
|
||||||
<Icon name="close" size={13} /> Убрать
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<input ref={lsCoverInputRef} type="file" accept="image/png,image/jpeg,image/webp"
|
|
||||||
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsCover)} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1, minWidth: 180 }}>
|
|
||||||
<input type="text" className={cl.input} placeholder="Название места (по умолчанию = название игры)"
|
|
||||||
value={lsPlaceName} maxLength={40}
|
|
||||||
onChange={(e) => setLsPlaceName(e.target.value)} />
|
|
||||||
<input type="text" className={cl.input} placeholder="Имя автора"
|
|
||||||
value={lsStudioName} maxLength={40}
|
|
||||||
onChange={(e) => setLsStudioName(e.target.value)} />
|
|
||||||
<label className={cl.toggleRow}>
|
|
||||||
<input type="checkbox" className={cl.toggle}
|
|
||||||
checked={lsVerified} onChange={(e) => setLsVerified(e.target.checked)} />
|
|
||||||
<div className={cl.toggleText}>
|
|
||||||
<div className={cl.toggleTitle}>Галочка verified</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
|
||||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
<span style={{ fontSize: 12, color: '#aab' }}>Стиль анимации</span>
|
|
||||||
<select className={cl.input} value={lsStyle} onChange={(e) => setLsStyle(e.target.value)}>
|
|
||||||
<option value="ken-burns">Ken Burns (плавный pan+zoom)</option>
|
|
||||||
<option value="static">Статичный фон</option>
|
|
||||||
<option value="parallax">Параллакс (по мыши)</option>
|
|
||||||
<option value="particles">Частицы (искры)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
<span style={{ fontSize: 12, color: '#aab' }}>Длительность: {Number(lsDuration).toFixed(1)} с</span>
|
|
||||||
<input type="range" min="1" max="10" step="0.5" value={lsDuration}
|
|
||||||
onChange={(e) => setLsDuration(Number(e.target.value))}
|
|
||||||
style={{ width: 160 }} />
|
|
||||||
</label>
|
|
||||||
<label className={cl.toggleRow}>
|
|
||||||
<input type="checkbox" className={cl.toggle}
|
|
||||||
checked={lsProgressBar} onChange={(e) => setLsProgressBar(e.target.checked)} />
|
|
||||||
<div className={cl.toggleText}>
|
|
||||||
<div className={cl.toggleTitle}>Прогресс-бар</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* === ЭКРАН ЗАГРУЗКИ === */}
|
|
||||||
{tab === 'loadingscreen' && (
|
|
||||||
<div className={cl.field}>
|
|
||||||
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
|
|
||||||
Логотип и цвет акцента для экранов загрузки между мирами (game.loading).
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 12 }}>
|
|
||||||
<div style={{
|
|
||||||
width: 96, height: 54, borderRadius: 8, background: '#15192a',
|
|
||||||
border: '1px solid rgba(255,255,255,0.12)', display: 'flex',
|
|
||||||
alignItems: 'center', justifyContent: 'center', overflow: 'hidden', flex: '0 0 auto',
|
|
||||||
}}>
|
|
||||||
{loadingLogo
|
|
||||||
? <img src={loadingLogo} alt="Логотип" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
|
|
||||||
: <span style={{ color: '#5a6178', fontSize: 11 }}>лого = обложка</span>}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
<button type="button" className={cl.actionBtn} onClick={() => logoInputRef.current?.click()}>
|
|
||||||
<Icon name="folder" size={14} /> Логотип игры
|
|
||||||
</button>
|
|
||||||
{loadingLogo && (
|
|
||||||
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLoadingLogo('')}>
|
|
||||||
<Icon name="close" size={13} /> Убрать
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<input ref={logoInputRef} type="file" accept="image/png,image/jpeg,image/webp"
|
|
||||||
style={{ display: 'none' }} onChange={handleLogoSelect} />
|
|
||||||
</div>
|
|
||||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, marginLeft: 'auto' }}>
|
|
||||||
<span style={{ fontSize: 12, color: '#aab' }}>Цвет акцента</span>
|
|
||||||
<input type="color" value={loadingAccent}
|
|
||||||
onChange={(e) => setLoadingAccent(e.target.value)}
|
|
||||||
style={{ width: 48, height: 32, border: 'none', background: 'none', cursor: 'pointer' }} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className={cl.togglesRow}>
|
|
||||||
<label className={cl.toggleRow}>
|
|
||||||
<input type="checkbox" className={cl.toggle}
|
|
||||||
checked={loadingSpinner} onChange={(e) => setLoadingSpinner(e.target.checked)} />
|
|
||||||
<div className={cl.toggleText}>
|
|
||||||
<div className={cl.toggleTitle}>Спиннер</div>
|
|
||||||
<div className={cl.toggleHint}><span>Показывать «ЗАГРУЗКА» по умолчанию</span></div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label className={cl.toggleRow}>
|
|
||||||
<input type="checkbox" className={cl.toggle}
|
|
||||||
checked={loadingSkip} onChange={(e) => setLoadingSkip(e.target.checked)} />
|
|
||||||
<div className={cl.toggleText}>
|
|
||||||
<div className={cl.toggleTitle}>Кнопка «Пропустить»</div>
|
|
||||||
<div className={cl.toggleHint}><span>Показывать по умолчанию</span></div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <div className={cl.error}><Icon name="warning" size={14} /> {error}</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={cl.footer}>
|
|
||||||
<button type="button" className={cl.secondaryBtn} onClick={onClose}>Отмена</button>
|
|
||||||
<button type="submit" className={cl.primaryBtn}>
|
|
||||||
<Icon name="save" size={13} /> Сохранить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default GameDecorModal;
|
|
||||||
@ -45,8 +45,26 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
|
|||||||
const [multiplayer, setMultiplayer] = useState(false);
|
const [multiplayer, setMultiplayer] = useState(false);
|
||||||
const [maxPlayers, setMaxPlayers] = useState(10);
|
const [maxPlayers, setMaxPlayers] = useState(10);
|
||||||
const [isTest, setIsTest] = useState(false);
|
const [isTest, setIsTest] = useState(false);
|
||||||
|
// Задача 12: экран загрузки
|
||||||
|
const [loadingLogo, setLoadingLogo] = useState('');
|
||||||
|
const [loadingAccent, setLoadingAccent] = useState('#ffc020');
|
||||||
|
const [loadingSpinner, setLoadingSpinner] = useState(true);
|
||||||
|
const [loadingSkip, setLoadingSkip] = useState(false);
|
||||||
|
// Задача 05: стартовый Ken-Burns экран
|
||||||
|
const [lsEnabled, setLsEnabled] = useState(true);
|
||||||
|
const [lsBackground, setLsBackground] = useState('');
|
||||||
|
const [lsCover, setLsCover] = useState('');
|
||||||
|
const [lsStyle, setLsStyle] = useState('ken-burns');
|
||||||
|
const [lsPlaceName, setLsPlaceName] = useState('');
|
||||||
|
const [lsStudioName, setLsStudioName] = useState('');
|
||||||
|
const [lsVerified, setLsVerified] = useState(false);
|
||||||
|
const [lsDuration, setLsDuration] = useState(2.5);
|
||||||
|
const [lsProgressBar, setLsProgressBar] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
const logoInputRef = useRef(null);
|
||||||
|
const lsBgInputRef = useRef(null);
|
||||||
|
const lsCoverInputRef = useRef(null);
|
||||||
|
|
||||||
// Заполняем поля ОДИН РАЗ при открытии модала.
|
// Заполняем поля ОДИН РАЗ при открытии модала.
|
||||||
// Не зависим от `initial` — родитель часто передаёт литерал-объект,
|
// Не зависим от `initial` — родитель часто передаёт литерал-объект,
|
||||||
@ -60,6 +78,21 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
|
|||||||
setIsPublic(!!initial?.is_public);
|
setIsPublic(!!initial?.is_public);
|
||||||
setMultiplayer(!!initial?.multiplayer);
|
setMultiplayer(!!initial?.multiplayer);
|
||||||
setIsTest(!!initial?.is_test);
|
setIsTest(!!initial?.is_test);
|
||||||
|
const ls = initial?.loading_screen || {};
|
||||||
|
setLoadingLogo(ls.logo || '');
|
||||||
|
setLoadingAccent(ls.accentColor || '#ffc020');
|
||||||
|
setLoadingSpinner(ls.defaultSpinner !== false);
|
||||||
|
setLoadingSkip(!!ls.defaultSkipButton);
|
||||||
|
// Задача 05:
|
||||||
|
setLsEnabled(ls.enabled !== false);
|
||||||
|
setLsBackground(ls.background || '');
|
||||||
|
setLsCover(ls.cover || '');
|
||||||
|
setLsStyle(ls.style || 'ken-burns');
|
||||||
|
setLsPlaceName(ls.placeName || '');
|
||||||
|
setLsStudioName(ls.studioName || '');
|
||||||
|
setLsVerified(!!ls.verified);
|
||||||
|
setLsDuration(Number.isFinite(ls.duration) && ls.duration > 0 ? ls.duration : 2.5);
|
||||||
|
setLsProgressBar(ls.progressBar !== false);
|
||||||
setMaxPlayers(
|
setMaxPlayers(
|
||||||
typeof initial?.max_players === 'number'
|
typeof initial?.max_players === 'number'
|
||||||
? Math.max(2, Math.min(50, initial.max_players))
|
? Math.max(2, Math.min(50, initial.max_players))
|
||||||
@ -96,6 +129,27 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLogoSelect = (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Логотип: только PNG, JPG или WEBP'); return; }
|
||||||
|
if (file.size > MAX_THUMBNAIL_BYTES) { setError('Логотип слишком большой (макс. 500 КБ)'); return; }
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => { setLoadingLogo(ev.target.result); setError(''); };
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Задача 05: универсальный загрузчик изображения (фон / cover-карточка).
|
||||||
|
const handleLsImage = (e, setter) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Только PNG, JPG или WEBP'); return; }
|
||||||
|
if (file.size > MAX_THUMBNAIL_BYTES) { setError('Изображение слишком большое (макс. 500 КБ)'); return; }
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => { setter(ev.target.result); setError(''); };
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmedTitle = title.trim();
|
const trimmedTitle = title.trim();
|
||||||
@ -120,6 +174,22 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
|
|||||||
multiplayer,
|
multiplayer,
|
||||||
max_players: Math.max(2, Math.min(50, Number(maxPlayers) || 10)),
|
max_players: Math.max(2, Math.min(50, Number(maxPlayers) || 10)),
|
||||||
is_test: isTest,
|
is_test: isTest,
|
||||||
|
loading_screen: {
|
||||||
|
logo: loadingLogo || null,
|
||||||
|
accentColor: loadingAccent || '#ffc020',
|
||||||
|
defaultSpinner: loadingSpinner,
|
||||||
|
defaultSkipButton: loadingSkip,
|
||||||
|
// Задача 05:
|
||||||
|
enabled: lsEnabled,
|
||||||
|
background: lsBackground || null,
|
||||||
|
cover: lsCover || null,
|
||||||
|
style: lsStyle || 'ken-burns',
|
||||||
|
placeName: lsPlaceName.trim(),
|
||||||
|
studioName: lsStudioName.trim(),
|
||||||
|
verified: lsVerified,
|
||||||
|
duration: Math.max(1, Math.min(10, Number(lsDuration) || 2.5)),
|
||||||
|
progressBar: lsProgressBar,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -300,6 +370,172 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Экран загрузки (задача 12) */}
|
||||||
|
<div className={cl.field} style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 14, marginTop: 4 }}>
|
||||||
|
<div className={cl.fieldLabel} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Icon name="loader" size={13} /> Экран загрузки
|
||||||
|
</div>
|
||||||
|
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
|
||||||
|
Логотип и цвет акцента для экранов загрузки между мирами (game.loading).
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 96, height: 54, borderRadius: 8, background: '#15192a',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', overflow: 'hidden', flex: '0 0 auto',
|
||||||
|
}}>
|
||||||
|
{loadingLogo
|
||||||
|
? <img src={loadingLogo} alt="Логотип" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
|
||||||
|
: <span style={{ color: '#5a6178', fontSize: 11 }}>лого = обложка</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<button type="button" className={cl.actionBtn} onClick={() => logoInputRef.current?.click()}>
|
||||||
|
<Icon name="folder" size={14} /> Логотип игры
|
||||||
|
</button>
|
||||||
|
{loadingLogo && (
|
||||||
|
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLoadingLogo('')}>
|
||||||
|
<Icon name="close" size={13} /> Убрать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input ref={logoInputRef} type="file" accept="image/png,image/jpeg,image/webp"
|
||||||
|
style={{ display: 'none' }} onChange={handleLogoSelect} />
|
||||||
|
</div>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, marginLeft: 'auto' }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#aab' }}>Цвет акцента</span>
|
||||||
|
<input type="color" value={loadingAccent}
|
||||||
|
onChange={(e) => setLoadingAccent(e.target.value)}
|
||||||
|
style={{ width: 48, height: 32, border: 'none', background: 'none', cursor: 'pointer' }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={cl.togglesRow}>
|
||||||
|
<label className={cl.toggleRow}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={loadingSpinner} onChange={(e) => setLoadingSpinner(e.target.checked)} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Спиннер</div>
|
||||||
|
<div className={cl.toggleHint}><span>Показывать «ЗАГРУЗКА» по умолчанию</span></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className={cl.toggleRow}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={loadingSkip} onChange={(e) => setLoadingSkip(e.target.checked)} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Кнопка «Пропустить»</div>
|
||||||
|
<div className={cl.toggleHint}><span>Показывать по умолчанию</span></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Стартовый экран — Ken Burns + название места (задача 05) */}
|
||||||
|
<div className={cl.field} style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 14, marginTop: 4 }}>
|
||||||
|
<div className={cl.fieldLabel} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Icon name="loader" size={13} /> Стартовый экран входа (Ken Burns)
|
||||||
|
</div>
|
||||||
|
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
|
||||||
|
Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор.
|
||||||
|
</div>
|
||||||
|
<label className={cl.toggleRow} style={{ marginBottom: 10 }}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={lsEnabled} onChange={(e) => setLsEnabled(e.target.checked)} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Показывать стартовый экран</div>
|
||||||
|
<div className={cl.toggleHint}><span>Если выключено — игрок сразу попадает в 3D-сцену</span></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{lsEnabled && (
|
||||||
|
<>
|
||||||
|
{/* Фон + карточка */}
|
||||||
|
<div style={{ display: 'flex', gap: 14, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 130, height: 74, borderRadius: 8, background: '#15192a',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
backgroundImage: lsBackground ? `url(${lsBackground})` : 'none',
|
||||||
|
backgroundSize: 'cover', backgroundPosition: 'center',
|
||||||
|
}}>
|
||||||
|
{!lsBackground && <span style={{ color: '#5a6178', fontSize: 11 }}>фон (размытый)</span>}
|
||||||
|
</div>
|
||||||
|
<button type="button" className={cl.actionBtn} onClick={() => lsBgInputRef.current?.click()}>
|
||||||
|
<Icon name="folder" size={14} /> Фон
|
||||||
|
</button>
|
||||||
|
{lsBackground && (
|
||||||
|
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsBackground('')}>
|
||||||
|
<Icon name="close" size={13} /> Убрать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input ref={lsBgInputRef} type="file" accept="image/png,image/jpeg,image/webp"
|
||||||
|
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsBackground)} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 74, height: 74, borderRadius: 12, background: '#15192a',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
backgroundImage: lsCover ? `url(${lsCover})` : 'none',
|
||||||
|
backgroundSize: 'cover', backgroundPosition: 'center',
|
||||||
|
}}>
|
||||||
|
{!lsCover && <span style={{ color: '#5a6178', fontSize: 10, textAlign: 'center' }}>карточка</span>}
|
||||||
|
</div>
|
||||||
|
<button type="button" className={cl.actionBtn} onClick={() => lsCoverInputRef.current?.click()}>
|
||||||
|
<Icon name="folder" size={14} /> Карточка
|
||||||
|
</button>
|
||||||
|
{lsCover && (
|
||||||
|
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsCover('')}>
|
||||||
|
<Icon name="close" size={13} /> Убрать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input ref={lsCoverInputRef} type="file" accept="image/png,image/jpeg,image/webp"
|
||||||
|
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsCover)} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1, minWidth: 180 }}>
|
||||||
|
<input type="text" className={cl.input} placeholder="Название места (по умолчанию = название игры)"
|
||||||
|
value={lsPlaceName} maxLength={40}
|
||||||
|
onChange={(e) => setLsPlaceName(e.target.value)} />
|
||||||
|
<input type="text" className={cl.input} placeholder="Имя автора"
|
||||||
|
value={lsStudioName} maxLength={40}
|
||||||
|
onChange={(e) => setLsStudioName(e.target.value)} />
|
||||||
|
<label className={cl.toggleRow}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={lsVerified} onChange={(e) => setLsVerified(e.target.checked)} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Галочка verified</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Стиль + длительность + прогресс */}
|
||||||
|
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#aab' }}>Стиль анимации</span>
|
||||||
|
<select className={cl.input} value={lsStyle} onChange={(e) => setLsStyle(e.target.value)}>
|
||||||
|
<option value="ken-burns">Ken Burns (плавный pan+zoom)</option>
|
||||||
|
<option value="static">Статичный фон</option>
|
||||||
|
<option value="parallax">Параллакс (по мыши)</option>
|
||||||
|
<option value="particles">Частицы (искры)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#aab' }}>Длительность: {Number(lsDuration).toFixed(1)} с</span>
|
||||||
|
<input type="range" min="1" max="10" step="0.5" value={lsDuration}
|
||||||
|
onChange={(e) => setLsDuration(Number(e.target.value))}
|
||||||
|
style={{ width: 160 }} />
|
||||||
|
</label>
|
||||||
|
<label className={cl.toggleRow}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={lsProgressBar} onChange={(e) => setLsProgressBar(e.target.checked)} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Прогресс-бар</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className={cl.error}><Icon name="warning" size={14} /> {error}</div>}
|
{error && <div className={cl.error}><Icon name="warning" size={14} /> {error}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import { getModelThumbnail } from './engine/ModelThumbnails';
|
|||||||
import * as Kubikon3DApi from '../api/Kubikon3DService';
|
import * as Kubikon3DApi from '../api/Kubikon3DService';
|
||||||
import { REALTIME_HTTP } from '../api/API';
|
import { REALTIME_HTTP } from '../api/API';
|
||||||
import GameSettingsModal from './GameSettingsModal';
|
import GameSettingsModal from './GameSettingsModal';
|
||||||
import GameDecorModal from './GameDecorModal';
|
|
||||||
import SkinManagerModal from './SkinManagerModal';
|
import SkinManagerModal from './SkinManagerModal';
|
||||||
import PublishModal from './PublishModal';
|
import PublishModal from './PublishModal';
|
||||||
import EmailConfirmNotice from '../components/EmailConfirmNotice/EmailConfirmNotice';
|
import EmailConfirmNotice from '../components/EmailConfirmNotice/EmailConfirmNotice';
|
||||||
@ -935,9 +934,6 @@ const KubikonEditor = () => {
|
|||||||
// settingsModalOpen — настройки игры (Roblox Game Settings)
|
// settingsModalOpen — настройки игры (Roblox Game Settings)
|
||||||
// initialModalOpen — инициальный диалог при создании новой игры
|
// initialModalOpen — инициальный диалог при создании новой игры
|
||||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||||
// Модал «Оформление» (графика/стартовый экран/экран загрузки) — из вкладки «Игра».
|
|
||||||
const [decorModalOpen, setDecorModalOpen] = useState(false);
|
|
||||||
const [decorSection, setDecorSection] = useState('graphics');
|
|
||||||
// Задача 07: модал управления скинами проекта + список всех скинов (манифест).
|
// Задача 07: модал управления скинами проекта + список всех скинов (манифест).
|
||||||
const [skinManagerOpen, setSkinManagerOpen] = useState(false);
|
const [skinManagerOpen, setSkinManagerOpen] = useState(false);
|
||||||
const [allSkinsList, setAllSkinsList] = useState([]);
|
const [allSkinsList, setAllSkinsList] = useState([]);
|
||||||
@ -1689,13 +1685,6 @@ const KubikonEditor = () => {
|
|||||||
return pd?.scene?.loadingScreen || null;
|
return pd?.scene?.loadingScreen || null;
|
||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
})()) || null,
|
})()) || null,
|
||||||
// Графика/эффекты из scene-JSON (для модала настроек).
|
|
||||||
graphics: (data.project_data && (() => {
|
|
||||||
try {
|
|
||||||
const pd = typeof data.project_data === 'string' ? JSON.parse(data.project_data) : data.project_data;
|
|
||||||
return pd?.scene?.graphics || null;
|
|
||||||
} catch { return null; }
|
|
||||||
})()) || null,
|
|
||||||
};
|
};
|
||||||
// Состояние публикации (этап 3)
|
// Состояние публикации (этап 3)
|
||||||
setProjectStatus({
|
setProjectStatus({
|
||||||
@ -1991,6 +1980,11 @@ const KubikonEditor = () => {
|
|||||||
const handleSettingsSave = (data) => {
|
const handleSettingsSave = (data) => {
|
||||||
metaRef.current = { ...metaRef.current, ...data };
|
metaRef.current = { ...metaRef.current, ...data };
|
||||||
setProjectName(data.title);
|
setProjectName(data.title);
|
||||||
|
// Задача 12: конфиг экрана загрузки → в сцену (попадёт в project_data.scene
|
||||||
|
// через toJSON). Логотип-дефолт = обложка проекта.
|
||||||
|
try {
|
||||||
|
sceneRef.current?.setLoadingConfig?.(data.loading_screen || null, data.thumbnail);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
setSettingsModalOpen(false);
|
setSettingsModalOpen(false);
|
||||||
setInitialModalOpen(false);
|
setInitialModalOpen(false);
|
||||||
if (autoSaveTimerRef.current) {
|
if (autoSaveTimerRef.current) {
|
||||||
@ -2001,28 +1995,6 @@ const KubikonEditor = () => {
|
|||||||
doSave();
|
doSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Сохранить «Оформление» (графика / стартовый экран / экран загрузки) из
|
|
||||||
// модала, открытого во вкладке «Игра». Применяем сразу (превью) + в сцену.
|
|
||||||
const handleDecorSave = (data) => {
|
|
||||||
try {
|
|
||||||
sceneRef.current?.setLoadingConfig?.(
|
|
||||||
data.loading_screen || null, metaRef.current?.thumbnail);
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
try {
|
|
||||||
if (data.graphics) sceneRef.current?.setGraphics?.(data.graphics);
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
// запомним в metaRef, чтобы модал открылся с актуальными значениями
|
|
||||||
metaRef.current = {
|
|
||||||
...metaRef.current,
|
|
||||||
loading_screen: data.loading_screen,
|
|
||||||
graphics: data.graphics,
|
|
||||||
};
|
|
||||||
setDecorModalOpen(false);
|
|
||||||
dirtyRef.current = true;
|
|
||||||
doSave();
|
|
||||||
};
|
|
||||||
const openDecor = (sec) => { setDecorSection(sec); setDecorModalOpen(true); };
|
|
||||||
|
|
||||||
// Закрыть инициальный диалог: если пользователь не сохранил — возвращаемся в Studio.
|
// Закрыть инициальный диалог: если пользователь не сохранил — возвращаемся в Studio.
|
||||||
const handleInitialClose = () => {
|
const handleInitialClose = () => {
|
||||||
setInitialModalOpen(false);
|
setInitialModalOpen(false);
|
||||||
@ -2359,9 +2331,6 @@ const KubikonEditor = () => {
|
|||||||
}}
|
}}
|
||||||
onSkins={() => setSkinManagerOpen(true)}
|
onSkins={() => setSkinManagerOpen(true)}
|
||||||
onInvite={handleInvite}
|
onInvite={handleInvite}
|
||||||
onGraphics={() => openDecor('graphics')}
|
|
||||||
onStartScreen={() => openDecor('startscreen')}
|
|
||||||
onLoadingScreen={() => openDecor('loadingscreen')}
|
|
||||||
collabActive={collabActive}
|
collabActive={collabActive}
|
||||||
collabPeers={collabPeers}
|
collabPeers={collabPeers}
|
||||||
hasSelection={!!selection}
|
hasSelection={!!selection}
|
||||||
@ -3819,14 +3788,6 @@ const KubikonEditor = () => {
|
|||||||
onSave={handleSettingsSave}
|
onSave={handleSettingsSave}
|
||||||
onCaptureScreenshot={captureSceneScreenshot}
|
onCaptureScreenshot={captureSceneScreenshot}
|
||||||
/>
|
/>
|
||||||
{/* Оформление: графика / стартовый экран / экран загрузки (вкладка «Игра») */}
|
|
||||||
<GameDecorModal
|
|
||||||
open={decorModalOpen}
|
|
||||||
section={decorSection}
|
|
||||||
initial={metaRef.current}
|
|
||||||
onClose={() => setDecorModalOpen(false)}
|
|
||||||
onSave={handleDecorSave}
|
|
||||||
/>
|
|
||||||
{/* Задача 07: управление скинами проекта (стартовый, магазин, рублики, кастомные .glb) */}
|
{/* Задача 07: управление скинами проекта (стартовый, магазин, рублики, кастомные .glb) */}
|
||||||
<SkinManagerModal
|
<SkinManagerModal
|
||||||
open={skinManagerOpen}
|
open={skinManagerOpen}
|
||||||
|
|||||||
@ -234,7 +234,6 @@ const TopRibbon = (props) => {
|
|||||||
activeTool, onToolChange,
|
activeTool, onToolChange,
|
||||||
isPlaying, onPlayToggle, onSetSpawn,
|
isPlaying, onPlayToggle, onSetSpawn,
|
||||||
onSkins, onInvite, collabActive, collabPeers,
|
onSkins, onInvite, collabActive, collabPeers,
|
||||||
onGraphics, onStartScreen, onLoadingScreen,
|
|
||||||
hasSelection,
|
hasSelection,
|
||||||
onDuplicate, onAlignToFloor, onDelete,
|
onDuplicate, onAlignToFloor, onDelete,
|
||||||
onClearScene,
|
onClearScene,
|
||||||
@ -451,25 +450,6 @@ const TopRibbon = (props) => {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Оформление — графика/эффекты, стартовый экран, экран загрузки. */}
|
|
||||||
<Group title="Оформление">
|
|
||||||
<RibbonBtn
|
|
||||||
iconName="sparkles" label="Графика"
|
|
||||||
onClick={onGraphics}
|
|
||||||
title="Графика и эффекты: свечение, цвет, тени (шейдеры)"
|
|
||||||
/>
|
|
||||||
<RibbonBtn
|
|
||||||
iconName="loader" label="Стартовый экран"
|
|
||||||
onClick={onStartScreen}
|
|
||||||
title="Стартовый экран входа (Ken Burns): фон, карточка, название"
|
|
||||||
/>
|
|
||||||
<RibbonBtn
|
|
||||||
iconName="loader" label="Экран загрузки"
|
|
||||||
onClick={onLoadingScreen}
|
|
||||||
title="Экран загрузки между мирами: логотип, цвет, спиннер"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{/* «Окружение» (время суток / амбиент / музыка) и
|
{/* «Окружение» (время суток / амбиент / музыка) и
|
||||||
«Скин игрока» переехали в иерархию объектов сцены:
|
«Скин игрока» переехали в иерархию объектов сцены:
|
||||||
🌞 Освещение / 🎵 Звук / 👤 Игрок. */}
|
🌞 Освещение / 🎵 Звук / 👤 Игрок. */}
|
||||||
|
|||||||
@ -98,7 +98,6 @@ 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';
|
||||||
@ -1801,47 +1800,6 @@ export class BabylonScene {
|
|||||||
|
|
||||||
getShadowQuality() { return this._shadowQuality || 'soft'; }
|
getShadowQuality() { return this._shadowQuality || 'soft'; }
|
||||||
|
|
||||||
/**
|
|
||||||
* Система графики/эффектов («шейдеры»). Лениво создаём GraphicsManager
|
|
||||||
* (постобработка: bloom/FXAA/виньетка/цветокор/DoF + управление тенями/SSAO).
|
|
||||||
* По умолчанию ВЫКЛЮЧЕНО — вызывается только когда автор настроил графику.
|
|
||||||
*/
|
|
||||||
_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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Применить настройки графики. settings: {preset} и/или секции
|
|
||||||
* (bloom/vignette/grading/dof/ssao/fxaa/shadows). */
|
|
||||||
setGraphics(settings) {
|
|
||||||
const g = this._ensureGraphics();
|
|
||||||
if (!g) return null;
|
|
||||||
const cfg = g.apply(settings || {});
|
|
||||||
this._graphicsConfig = cfg;
|
|
||||||
return cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Текущая конфигурация графики (для serialize). */
|
|
||||||
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). v2 требует
|
* Используем SSAORenderingPipeline v1 (не v2). v2 требует
|
||||||
@ -7847,7 +7805,6 @@ export class BabylonScene {
|
|||||||
crosshair: this._crosshair || 'dot',
|
crosshair: this._crosshair || 'dot',
|
||||||
shadowQuality: this._shadowQuality || 'soft',
|
shadowQuality: this._shadowQuality || 'soft',
|
||||||
environment: this.environment ? this.environment.serialize() : null,
|
environment: this.environment ? this.environment.serialize() : null,
|
||||||
graphics: this.getGraphicsState(),
|
|
||||||
// Кастомные настройки света — слайдеры из «Свет и атмосфера»
|
// Кастомные настройки света — слайдеры из «Свет и атмосфера»
|
||||||
lighting: {
|
lighting: {
|
||||||
sunIntensity: this._sunIntensity ?? this._sunLight?.intensity ?? 0.8,
|
sunIntensity: this._sunIntensity ?? this._sunLight?.intensity ?? 0.8,
|
||||||
@ -8341,12 +8298,6 @@ 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 */ }
|
|
||||||
}
|
|
||||||
// Кастомные настройки света/цветокоррекции — применяем через
|
// Кастомные настройки света/цветокоррекции — применяем через
|
||||||
// setLightingProps (он сам подхватит default-ы если значения нет).
|
// setLightingProps (он сам подхватит default-ы если значения нет).
|
||||||
if (state.scene.lighting) {
|
if (state.scene.lighting) {
|
||||||
|
|||||||
@ -3252,10 +3252,6 @@ export class GameRuntime {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (cmd === 'graphics.set') {
|
|
||||||
try { this.scene3d?.setGraphics?.(payload || {}); } catch (e) {}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cmd === 'player.setCrouch') {
|
if (cmd === 'player.setCrouch') {
|
||||||
const player = this.scene3d?.player;
|
const player = this.scene3d?.player;
|
||||||
if (player) {
|
if (player) {
|
||||||
|
|||||||
@ -1,328 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,7 +6,7 @@
|
|||||||
* - позиция (x, y, z)
|
* - позиция (x, y, z)
|
||||||
* - размер (sx, sy, sz)
|
* - размер (sx, sy, sz)
|
||||||
* - цвет (#hex)
|
* - цвет (#hex)
|
||||||
* - материал ('matte'|'metal'|'glass'|'neon'|'studs'|'chrome'|'water'|'iridescent')
|
* - материал ('matte' | 'metal' | 'glass' | 'neon')
|
||||||
* - canCollide (bool) — участвует ли в физике коллизий
|
* - canCollide (bool) — участвует ли в физике коллизий
|
||||||
* - visible (bool) — рисуется ли (anchored — пока заготовка)
|
* - visible (bool) — рисуется ли (anchored — пока заготовка)
|
||||||
*
|
*
|
||||||
@ -538,9 +538,7 @@ export class PrimitiveManager {
|
|||||||
break;
|
break;
|
||||||
case 'glass':
|
case 'glass':
|
||||||
mat.alpha = 0.4;
|
mat.alpha = 0.4;
|
||||||
mat.specularColor = new Color3(0.8, 0.85, 0.9);
|
mat.specularColor = new Color3(0.5, 0.5, 0.5);
|
||||||
mat.specularPower = 96; // более чёткий блик на стекле
|
|
||||||
mat.backFaceCulling = false; // видно «толщину» — глубже эффект
|
|
||||||
break;
|
break;
|
||||||
case 'neon':
|
case 'neon':
|
||||||
mat.emissiveColor = Color3.FromHexString(color || '#888888');
|
mat.emissiveColor = Color3.FromHexString(color || '#888888');
|
||||||
@ -573,39 +571,6 @@ export class PrimitiveManager {
|
|||||||
mat.specularColor = new Color3(0, 0, 0);
|
mat.specularColor = new Color3(0, 0, 0);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'chrome': {
|
|
||||||
// Хром/зеркало: яркий узкий блик + лёгкое самосвечение цвета,
|
|
||||||
// чтобы поверхность «играла» даже без cubemap-отражений (их не
|
|
||||||
// грузим — экономим ассеты и FPS на детских машинах).
|
|
||||||
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': {
|
|
||||||
// Вода: полупрозрачный голубой с бликами. Анимацию ряби делает
|
|
||||||
// GraphicsManager/Environment по флагу mesh._isWater (опционально).
|
|
||||||
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 'matte':
|
case 'matte':
|
||||||
mat.specularColor = new Color3(0, 0, 0);
|
mat.specularColor = new Color3(0, 0, 0);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -3890,53 +3890,6 @@ const game = {
|
|||||||
_send('environment.setTimeOfDay', { hours: h });
|
_send('environment.setTimeOfDay', { hours: h });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* graphics — визуальные эффекты («шейдеры»): постобработка, свечение,
|
|
||||||
* цветокоррекция, тени. По умолчанию всё выключено.
|
|
||||||
*/
|
|
||||||
graphics: {
|
|
||||||
/** Применить пресет: 'off'|'low'|'medium'|'high'|'ultra'|
|
|
||||||
* 'cinematic'|'vivid'|'night'|'retro'|'soft'. */
|
|
||||||
setPreset(preset) {
|
|
||||||
if (typeof preset !== 'string') return;
|
|
||||||
_send('graphics.set', { preset });
|
|
||||||
},
|
|
||||||
/** Тонкая настройка (поверх текущего): передай любые секции
|
|
||||||
* {bloom, vignette, grading, dof, ssao, fxaa, shadows}. */
|
|
||||||
set(settings) {
|
|
||||||
if (typeof settings !== 'object' || !settings) return;
|
|
||||||
_send('graphics.set', settings);
|
|
||||||
},
|
|
||||||
/** Свечение. on:bool, opts:{intensity:0..1, threshold:0..1}. */
|
|
||||||
setBloom(on, opts) {
|
|
||||||
_send('graphics.set', { bloom: { enabled: !!on, ...(opts || {}) } });
|
|
||||||
},
|
|
||||||
/** Виньетка (затемнение углов). weight: 0..1.5, 0 = выкл. */
|
|
||||||
setVignette(weight) {
|
|
||||||
const w = Number(weight) || 0;
|
|
||||||
_send('graphics.set', { vignette: { enabled: w > 0, weight: w } });
|
|
||||||
},
|
|
||||||
/** Цветокоррекция: {contrast, saturation, exposure} (1.0 = норма). */
|
|
||||||
setColorGrading(opts) {
|
|
||||||
if (typeof opts !== 'object' || !opts) return;
|
|
||||||
_send('graphics.set', { grading: { enabled: true, ...opts } });
|
|
||||||
},
|
|
||||||
/** Сглаживание (FXAA). */
|
|
||||||
setAntialiasing(on) { _send('graphics.set', { fxaa: !!on }); },
|
|
||||||
/** Глубина резкости: on:bool, opts:{focusDistance, focalLength, aperture}. */
|
|
||||||
setDepthOfField(on, opts) {
|
|
||||||
_send('graphics.set', { dof: { enabled: !!on, ...(opts || {}) } });
|
|
||||||
},
|
|
||||||
/** Качество теней: 'off'|'hard'|'soft'|'medium'|'high'. */
|
|
||||||
setShadows(quality) {
|
|
||||||
if (typeof quality !== 'string') return;
|
|
||||||
_send('graphics.set', { shadows: quality });
|
|
||||||
},
|
|
||||||
/** Контактные тени (SSAO). */
|
|
||||||
setSSAO(on) { _send('graphics.set', { ssao: !!on }); },
|
|
||||||
/** Полностью выключить эффекты. */
|
|
||||||
off() { _send('graphics.set', { preset: 'off' }); },
|
|
||||||
},
|
|
||||||
save: {
|
save: {
|
||||||
/** Прочитать namespace. fn(data) — data это сохранённый объект или null. */
|
/** Прочитать namespace. fn(data) — data это сохранённый объект или null. */
|
||||||
get(namespace, fn) {
|
get(namespace, fn) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user