From 8ccea76dc0c73c3838ec9912839a3729bd624ad6 Mon Sep 17 00:00:00 2001 From: min Date: Wed, 10 Jun 2026 01:24:30 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(studio):=20=D1=81=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B5=D0=BC=D0=B0=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?/=D1=8D=D1=84=D1=84=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20(=D1=88?= =?UTF-8?q?=D0=B5=D0=B9=D0=B4=D0=B5=D1=80=D1=8B)=20+=20=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=D0=B0=D0=BB=D1=8B=20+=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=D1=81=20=D0=9E=D1=84=D0=BE=D1=80=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GraphicsManager: постобработка (bloom/FXAA/виньетка/цветокор/DoF) + тени/SSAO, 10 пресетов, mobile-safe, выкл по умолчанию. Новые материалы примитивов (chrome/water/iridescent + улучшены glass/neon). API game.graphics.*. Графика + Стартовый экран (Ken Burns) + Экран загрузки вынесены из Настроек в новый GameDecorModal, открываются из вкладки «Игра» (группа «Оформление»). Вики-раздел «Графика и эффекты» (GR1-GR4) + AI-контекст обновлён. Co-Authored-By: Claude Opus 4.8 --- src/community/docsData.jsx | 155 +++++++- src/editor/GameDecorModal.jsx | 440 +++++++++++++++++++++++ src/editor/GameSettingsModal.jsx | 236 ------------ src/editor/KubikonEditor.jsx | 49 ++- src/editor/TopRibbon.jsx | 20 ++ src/editor/engine/BabylonScene.js | 49 +++ src/editor/engine/GameRuntime.js | 4 + src/editor/engine/GraphicsManager.js | 328 +++++++++++++++++ src/editor/engine/PrimitiveManager.js | 39 +- src/editor/engine/ScriptSandboxWorker.js | 47 +++ 10 files changed, 1122 insertions(+), 245 deletions(-) create mode 100644 src/editor/GameDecorModal.jsx create mode 100644 src/editor/engine/GraphicsManager.js diff --git a/src/community/docsData.jsx b/src/community/docsData.jsx index ae4fcc7..e237717 100644 --- a/src/community/docsData.jsx +++ b/src/community/docsData.jsx @@ -134,7 +134,7 @@ const AI_CONTEXT = `Ты — помощник по написанию скрип === СЦЕНА game.scene === 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', 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'|'chrome'|'water'|'iridescent', 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) setLabel(ref,text,opts); clearLabel(ref); setData(ref,key,val); getData(ref,key) find(name)->[...]; findOne(name)->объект|null; all('primitive'|'model'|'block') @@ -186,6 +186,10 @@ shake(amp, sec); setFov(deg); focusOn(ref,{distance,height}); cutscene([{x,y,z}. === ОКРУЖЕНИЕ game.environment === 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.inventory.give(id,count); .take(id,count); .has(id); .open(); .list() @@ -208,7 +212,8 @@ game.log(...) — в консоль; game.random(min,max,целое?); game.clam - Повороты в РАДИАНАХ (Math.PI/2 = 90°). - Счётчики/общую логику держи в ОДНОМ глобальном скрипте; объекты шлют game.broadcast (скрипты объектов не видят переменные друг друга). - spawn примитива: тип без префикса ('cube'), модели — с 'model:', машина — 'vehicle:car'. -- material только: matte, neon, metal, glass, studs. +- material: matte, neon, metal, glass, studs, chrome (зеркало), water (вода), iridescent (переливы). +- эффекты включаются через game.graphics.setPreset(...) или в настройках игры; по умолчанию выкл. - Для сбора предмета: game.self.onTouch(()=>{ game.broadcast('coin'); game.self.delete(); }). - Не используй require() и DOM/window — только game.* и обычный JS. @@ -3302,6 +3307,152 @@ game.save.get('progress', (data) => { }, ], }, + + // ════════════════════════════════════════════════════ + // РАЗДЕЛ — ГРАФИКА И ЭФФЕКТЫ (шейдеры) + // ════════════════════════════════════════════════════ + { + id: 'graphics', + icon: 'sparkles', + title: 'Графика и эффекты', + summary: 'Шейдеры-эффекты: свечение, цветокоррекция, тени, красивые материалы (хром, вода, переливы). Через настройки и из скриптов.', + sections: [ + { + id: 'gfx-what', + title: 'GR1. Что такое эффекты (шейдеры)', + body: ( + <> +

+ Эффекты (шейдеры) делают картинку игры красивее — + как «шейдер-паки» в Майнкрафте. Это: +

+ + + По умолчанию эффекты выключены — игра выглядит как + раньше. Включи их в настройках или из скрипта, когда захочешь + «прокачать» картинку. На слабых компах/телефонах тяжёлые + эффекты сами упрощаются, чтобы игра не тормозила. + + + ), + }, + { + id: 'gfx-settings', + title: 'GR2. Включить эффекты в настройках', + body: ( + <> + + Открой Настройки игры (шестерёнка + вверху). + + + Найди раздел «Графика и эффекты» и выбери пресет: + + + + Или настрой вручную галочками (свечение, сглаживание, + виньетка, контактные тени) и ползунками (насыщенность, + контраст). Нажми Сохранить — эффект + появится сразу. + + + Поставь несколько неоновых кубов, включи пресет «Ночь» — они + будут красиво светиться в темноте. + + + ), + }, + { + id: 'gfx-materials', + title: 'GR3. Красивые материалы', + body: ( + <> +

+ У каждого примитива есть материал (в свойствах объекта + или при создании из скрипта). Кроме обычных есть «шейдерные»: +

+ + + {`// хромированная сфера +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';`} + + ), + }, + { + id: 'gfx-api', + title: 'GR4. Эффекты из скриптов (game.graphics)', + body: ( + <> +

+ Эффекты можно включать и менять на ходу из скрипта — + например затемнить мир ночью или включить «кино» в катсцене. +

+ + {`// применить готовый пресет +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();`} +

Пример — плавно «сделать ночь» при входе в пещеру:

+ {`const cave = game.scene.findOne('ВходВПещеру'); +cave.onTouch(() => { + game.graphics.setPreset('night'); + game.environment.setTimeOfDay(0); + game.ui.showText('Темнеет...', 2); +});`} + + Эффекты применяются ко всему экрану. Если игра должна + работать на телефонах — не включай разом DoF + SSAO + ультра- + тени: движок их урежет, но лучше выбрать пресет полегче. + + + ), + }, + ], + }, ]; diff --git a/src/editor/GameDecorModal.jsx b/src/editor/GameDecorModal.jsx new file mode 100644 index 0000000..a353810 --- /dev/null +++ b/src/editor/GameDecorModal.jsx @@ -0,0 +1,440 @@ +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 ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+
+
Оформление игры
+ +
+ + {/* Табы разделов */} +
+ {TABS.map((t) => ( + + ))} +
+ +
+
+ + {/* === ГРАФИКА === */} + {tab === 'graphics' && ( +
+
+ Свечение, цветокоррекция, тени и сглаживание (как шейдеры). + По умолчанию выключено. На слабых устройствах тяжёлые эффекты + урезаются автоматически. Также управляется из скриптов (game.graphics). +
+ +
+ + + + +
+
+ + +
+
+ )} + + {/* === СТАРТОВЫЙ ЭКРАН (Ken Burns) === */} + {tab === 'startscreen' && ( +
+
+ Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор. +
+ + {lsEnabled && ( + <> +
+
+
+ {!lsBackground && фон (размытый)} +
+ + {lsBackground && ( + + )} + handleLsImage(e, setLsBackground)} /> +
+
+
+ {!lsCover && карточка} +
+ + {lsCover && ( + + )} + handleLsImage(e, setLsCover)} /> +
+
+ setLsPlaceName(e.target.value)} /> + setLsStudioName(e.target.value)} /> + +
+
+
+ + + +
+ + )} +
+ )} + + {/* === ЭКРАН ЗАГРУЗКИ === */} + {tab === 'loadingscreen' && ( +
+
+ Логотип и цвет акцента для экранов загрузки между мирами (game.loading). +
+
+
+ {loadingLogo + ? Логотип + : лого = обложка} +
+
+ + {loadingLogo && ( + + )} + +
+ +
+
+ + +
+
+ )} + + {error &&
{error}
} +
+ +
+ + +
+
+
+
+ ); +}; + +export default GameDecorModal; diff --git a/src/editor/GameSettingsModal.jsx b/src/editor/GameSettingsModal.jsx index 4b1ea9b..fdfa26c 100644 --- a/src/editor/GameSettingsModal.jsx +++ b/src/editor/GameSettingsModal.jsx @@ -45,26 +45,8 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot const [multiplayer, setMultiplayer] = useState(false); const [maxPlayers, setMaxPlayers] = useState(10); 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 fileInputRef = useRef(null); - const logoInputRef = useRef(null); - const lsBgInputRef = useRef(null); - const lsCoverInputRef = useRef(null); // Заполняем поля ОДИН РАЗ при открытии модала. // Не зависим от `initial` — родитель часто передаёт литерал-объект, @@ -78,21 +60,6 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot setIsPublic(!!initial?.is_public); setMultiplayer(!!initial?.multiplayer); 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( typeof initial?.max_players === 'number' ? Math.max(2, Math.min(50, initial.max_players)) @@ -129,27 +96,6 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot 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) => { e.preventDefault(); const trimmedTitle = title.trim(); @@ -174,22 +120,6 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot multiplayer, max_players: Math.max(2, Math.min(50, Number(maxPlayers) || 10)), 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, - }, }); }; @@ -370,172 +300,6 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot )} - {/* Экран загрузки (задача 12) */} -
-
- Экран загрузки -
-
- Логотип и цвет акцента для экранов загрузки между мирами (game.loading). -
-
-
- {loadingLogo - ? Логотип - : лого = обложка} -
-
- - {loadingLogo && ( - - )} - -
- -
-
- - -
-
- - {/* Стартовый экран — Ken Burns + название места (задача 05) */} -
-
- Стартовый экран входа (Ken Burns) -
-
- Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор. -
- - - {lsEnabled && ( - <> - {/* Фон + карточка */} -
-
-
- {!lsBackground && фон (размытый)} -
- - {lsBackground && ( - - )} - handleLsImage(e, setLsBackground)} /> -
-
-
- {!lsCover && карточка} -
- - {lsCover && ( - - )} - handleLsImage(e, setLsCover)} /> -
-
- setLsPlaceName(e.target.value)} /> - setLsStudioName(e.target.value)} /> - -
-
- - {/* Стиль + длительность + прогресс */} -
- - - -
- - )} -
- {error &&
{error}
} diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index ccebae2..b850c97 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -14,6 +14,7 @@ import { getModelThumbnail } from './engine/ModelThumbnails'; import * as Kubikon3DApi from '../api/Kubikon3DService'; import { REALTIME_HTTP } from '../api/API'; import GameSettingsModal from './GameSettingsModal'; +import GameDecorModal from './GameDecorModal'; import SkinManagerModal from './SkinManagerModal'; import PublishModal from './PublishModal'; import EmailConfirmNotice from '../components/EmailConfirmNotice/EmailConfirmNotice'; @@ -931,6 +932,9 @@ const KubikonEditor = () => { // settingsModalOpen — настройки игры (Roblox Game Settings) // initialModalOpen — инициальный диалог при создании новой игры const [settingsModalOpen, setSettingsModalOpen] = useState(false); + // Модал «Оформление» (графика/стартовый экран/экран загрузки) — из вкладки «Игра». + const [decorModalOpen, setDecorModalOpen] = useState(false); + const [decorSection, setDecorSection] = useState('graphics'); // Задача 07: модал управления скинами проекта + список всех скинов (манифест). const [skinManagerOpen, setSkinManagerOpen] = useState(false); const [allSkinsList, setAllSkinsList] = useState([]); @@ -1682,6 +1686,13 @@ const KubikonEditor = () => { return pd?.scene?.loadingScreen || null; } catch { return 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) setProjectStatus({ @@ -1977,11 +1988,6 @@ const KubikonEditor = () => { const handleSettingsSave = (data) => { metaRef.current = { ...metaRef.current, ...data }; setProjectName(data.title); - // Задача 12: конфиг экрана загрузки → в сцену (попадёт в project_data.scene - // через toJSON). Логотип-дефолт = обложка проекта. - try { - sceneRef.current?.setLoadingConfig?.(data.loading_screen || null, data.thumbnail); - } catch (e) { /* ignore */ } setSettingsModalOpen(false); setInitialModalOpen(false); if (autoSaveTimerRef.current) { @@ -1992,6 +1998,28 @@ const KubikonEditor = () => { 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. const handleInitialClose = () => { setInitialModalOpen(false); @@ -2322,6 +2350,9 @@ const KubikonEditor = () => { }} onSkins={() => setSkinManagerOpen(true)} onInvite={handleInvite} + onGraphics={() => openDecor('graphics')} + onStartScreen={() => openDecor('startscreen')} + onLoadingScreen={() => openDecor('loadingscreen')} collabActive={collabActive} collabPeers={collabPeers} hasSelection={!!selection} @@ -3746,6 +3777,14 @@ const KubikonEditor = () => { onSave={handleSettingsSave} onCaptureScreenshot={captureSceneScreenshot} /> + {/* Оформление: графика / стартовый экран / экран загрузки (вкладка «Игра») */} + setDecorModalOpen(false)} + onSave={handleDecorSave} + /> {/* Задача 07: управление скинами проекта (стартовый, магазин, рублики, кастомные .glb) */} { activeTool, onToolChange, isPlaying, onPlayToggle, onSetSpawn, onSkins, onInvite, collabActive, collabPeers, + onGraphics, onStartScreen, onLoadingScreen, hasSelection, onDuplicate, onAlignToFloor, onDelete, onClearScene, @@ -450,6 +451,25 @@ const TopRibbon = (props) => { /> + {/* Оформление — графика/эффекты, стартовый экран, экран загрузки. */} + + + + + + {/* «Окружение» (время суток / амбиент / музыка) и «Скин игрока» переехали в иерархию объектов сцены: 🌞 Освещение / 🎵 Звук / 👤 Игрок. */} diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index e1feeeb..35dad64 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -97,6 +97,7 @@ import { GdForest } from './GdForest'; import { GdPlayerCube } from './GdPlayerCube'; import { GdPlayerTrail } from './GdPlayerTrail'; import { GdPostFx } from './GdPostFx'; +import { GraphicsManager } from './GraphicsManager'; import { PhysicsAABB } from './PhysicsAABB'; import { PlayerController } from './PlayerController'; import { SelectionManager } from './SelectionManager'; @@ -1799,6 +1800,47 @@ export class BabylonScene { 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 пост-эффект (контактные тени). * * Используем SSAORenderingPipeline v1 (не v2). v2 требует @@ -7717,6 +7759,7 @@ export class BabylonScene { crosshair: this._crosshair || 'dot', shadowQuality: this._shadowQuality || 'soft', environment: this.environment ? this.environment.serialize() : null, + graphics: this.getGraphicsState(), skybox: this.skybox ? this.skybox.serialize() : null, leaderstats: this.leaderstats ? this.leaderstats.serialize() : null, achievements: this.achievements ? this.achievements.serialize() : null, @@ -8199,6 +8242,12 @@ export class BabylonScene { if (state.scene.environment && this.environment) { this.environment.load(state.scene.environment); } + // Графика/эффекты (шейдеры). Применяем только если автор что-то настроил + // и это не 'off' — иначе не трогаем рендер (чистая картинка, дефолт). + if (state.scene.graphics && state.scene.graphics.preset + && state.scene.graphics.preset !== 'off') { + try { this.setGraphics(state.scene.graphics); } catch (e) { /* ignore */ } + } // Кастомное небо (задача 16) if (state.scene.skybox && this.skybox) { this.skybox.load(state.scene.skybox); diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 16dfde9..175d0b1 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -2812,6 +2812,10 @@ export class GameRuntime { } catch (e) {} return; } + if (cmd === 'graphics.set') { + try { this.scene3d?.setGraphics?.(payload || {}); } catch (e) {} + return; + } if (cmd === 'player.setCrouch') { const player = this.scene3d?.player; if (player) { diff --git a/src/editor/engine/GraphicsManager.js b/src/editor/engine/GraphicsManager.js new file mode 100644 index 0000000..fcb156b --- /dev/null +++ b/src/editor/engine/GraphicsManager.js @@ -0,0 +1,328 @@ +/** + * GraphicsManager — система визуальных эффектов («шейдеры») для игр Рублокса. + * + * Управляет: + * - постобработкой экрана через Babylon DefaultRenderingPipeline: + * bloom (свечение), FXAA (сглаживание), виньетка, цветокоррекция + * (контраст/насыщенность/экспозиция), тонмаппинг, глубина резкости (DoF); + * - качеством теней (через scene3d.setShadowQuality); + * - контактными тенями SSAO (через scene3d.setSsaoEnabled). + * + * Управляется И из настроек игры (вкладка «Графика»), И из скриптов + * (game.graphics.*). По умолчанию ВСЁ ВЫКЛЮЧЕНО (preset 'off') — старые игры + * не меняются, FPS не страдает. Автор включает осознанно. + * + * Mobile-safe: на слабых устройствах тяжёлые эффекты (DoF, SSAO, ultra-тени, + * HDR-bloom) автоматически урезаются, даже если в пресете включены. + * + * Один и тот же класс используется в студии и плеере (фича-парность). + * + * Использование: + * const gfx = new GraphicsManager(scene, camera, scene3d, { mobile }); + * gfx.apply({ preset: 'cinematic' }); + * gfx.apply({ bloom: { enabled: true, intensity: 0.7 } }); + * gfx.dispose(); + */ +import { + DefaultRenderingPipeline, Color4, ImageProcessingConfiguration, +} from '@babylonjs/core'; + +/** + * Именованные пресеты. Каждый — полный набор настроек. 'off' = чистая картинка + * (pipeline не создаётся вовсе). Значения подобраны так, чтобы быть заметными, + * но не «кислотными». + */ +export const GRAPHICS_PRESETS = { + off: { + bloom: { enabled: false }, + fxaa: false, + vignette: { enabled: false }, + grading: { enabled: false }, + dof: { enabled: false }, + ssao: false, + shadows: null, // null = не трогаем текущее качество теней + }, + // Лёгкий: только мягкое свечение + сглаживание. Дёшево, годится почти везде. + low: { + bloom: { enabled: true, intensity: 0.3, threshold: 0.9 }, + fxaa: true, + vignette: { enabled: false }, + grading: { enabled: false }, + dof: { enabled: false }, + ssao: false, + shadows: 'hard', + }, + // Средний: свечение + лёгкая виньетка + чуть насыщенности. + medium: { + bloom: { enabled: true, intensity: 0.45, threshold: 0.85 }, + fxaa: true, + vignette: { enabled: true, weight: 0.5 }, + grading: { enabled: true, contrast: 1.05, saturation: 1.1, exposure: 1.0 }, + dof: { enabled: false }, + ssao: false, + shadows: 'soft', + }, + // Высокий: всё кроме DoF, SSAO включён. + high: { + bloom: { enabled: true, intensity: 0.6, threshold: 0.82 }, + fxaa: true, + vignette: { enabled: true, weight: 0.6 }, + grading: { enabled: true, contrast: 1.1, saturation: 1.2, exposure: 1.05 }, + dof: { enabled: false }, + ssao: true, + shadows: 'soft', + }, + // Ультра: + глубина резкости + мягкие каскадные тени. + ultra: { + bloom: { enabled: true, intensity: 0.7, threshold: 0.8 }, + fxaa: true, + vignette: { enabled: true, weight: 0.65 }, + grading: { enabled: true, contrast: 1.12, saturation: 1.25, exposure: 1.05 }, + dof: { enabled: true, focusDistance: 18, focalLength: 50, aperture: 1.2 }, + ssao: true, + shadows: 'high', + }, + // === Стилевые пресеты (художественные) === + cinematic: { + bloom: { enabled: true, intensity: 0.55, threshold: 0.8 }, + fxaa: true, + vignette: { enabled: true, weight: 0.85 }, + grading: { enabled: true, contrast: 1.18, saturation: 1.05, exposure: 1.0 }, + dof: { enabled: true, focusDistance: 22, focalLength: 60, aperture: 1.0 }, + ssao: true, + shadows: 'soft', + }, + vivid: { + bloom: { enabled: true, intensity: 0.65, threshold: 0.78 }, + fxaa: true, + vignette: { enabled: false }, + grading: { enabled: true, contrast: 1.1, saturation: 1.5, exposure: 1.1 }, + dof: { enabled: false }, + ssao: false, + shadows: 'soft', + }, + night: { + bloom: { enabled: true, intensity: 0.8, threshold: 0.7 }, + fxaa: true, + vignette: { enabled: true, weight: 1.0 }, + grading: { enabled: true, contrast: 1.2, saturation: 0.85, exposure: 0.8 }, + dof: { enabled: false }, + ssao: true, + shadows: 'soft', + }, + retro: { + bloom: { enabled: false }, + fxaa: false, // намеренно «пиксельно» + vignette: { enabled: true, weight: 1.2 }, + grading: { enabled: true, contrast: 1.3, saturation: 0.7, exposure: 0.95 }, + dof: { enabled: false }, + ssao: false, + shadows: 'hard', + }, + soft: { + bloom: { enabled: true, intensity: 0.4, threshold: 0.88 }, + fxaa: true, + vignette: { enabled: true, weight: 0.4 }, + grading: { enabled: true, contrast: 0.95, saturation: 1.05, exposure: 1.05 }, + dof: { enabled: false }, + ssao: false, + shadows: 'soft', + }, +}; + +// Глубокое слияние пресета и пользовательских оверрайдов. +function _mergeConfig(base, over) { + const out = JSON.parse(JSON.stringify(base || {})); + if (!over) return out; + for (const k of Object.keys(over)) { + const v = over[k]; + if (v && typeof v === 'object' && !Array.isArray(v)) { + out[k] = { ...(out[k] || {}), ...v }; + } else { + out[k] = v; + } + } + return out; +} + +export class GraphicsManager { + /** + * @param scene Babylon Scene + * @param camera активная камера (для pipeline) + * @param scene3d ссылка на BabylonScene (для setShadowQuality / setSsaoEnabled / света) + * @param opts { mobile:boolean } + */ + constructor(scene, camera, scene3d, opts = {}) { + this.scene = scene; + this.camera = camera; + this.scene3d = scene3d; + this.mobile = !!opts.mobile; + this._pipeline = null; + // Текущая активная конфигурация (после merge + mobile-clamp). + this.config = _mergeConfig(GRAPHICS_PRESETS.off, null); + this.config.preset = 'off'; + this.enabled = false; + } + + /** Сменить камеру (например после смены режима камеры) — пересобрать pipeline. */ + setCamera(camera) { + if (camera === this.camera) return; + this.camera = camera; + if (this.enabled) this._rebuildPipeline(); + } + + /** + * Применить настройки графики. Принимает либо {preset}, либо отдельные + * секции (bloom/vignette/grading/dof/ssao/fxaa/shadows), либо и то и другое + * (оверрайды поверх пресета). Сохраняет состояние в this.config. + */ + apply(settings = {}) { + let cfg; + if (settings.preset && GRAPHICS_PRESETS[settings.preset]) { + cfg = _mergeConfig(GRAPHICS_PRESETS[settings.preset], settings); + cfg.preset = settings.preset; + } else { + // частичный апдейт поверх текущего + cfg = _mergeConfig(this.config, settings); + cfg.preset = settings.preset || this.config.preset || 'custom'; + } + this.config = this._clampForMobile(cfg); + this._applyConfig(); + return this.config; + } + + /** Полностью выключить эффекты (как preset 'off'). */ + disableAll() { + return this.apply({ preset: 'off' }); + } + + /** Текущая конфигурация (для serialize). */ + serialize() { + // Храним «как просили» (preset + явные оверрайды). Для простоты — весь cfg. + return JSON.parse(JSON.stringify(this.config)); + } + + // --- внутреннее --- + + /** На слабых устройствах гасим самое дорогое, что бы ни просили. */ + _clampForMobile(cfg) { + if (!this.mobile) return cfg; + const c = JSON.parse(JSON.stringify(cfg)); + if (c.dof) c.dof.enabled = false; // DoF дорогой + c.ssao = false; // SSAO дорогой + if (c.shadows === 'high' || c.shadows === 'medium') c.shadows = 'hard'; + // bloom оставляем, но без HDR (решается в _rebuildPipeline) + c._mobileClamped = true; + return c; + } + + _applyConfig() { + const c = this.config; + const anyPipelineFx = (c.bloom && c.bloom.enabled) || c.fxaa + || (c.vignette && c.vignette.enabled) || (c.grading && c.grading.enabled) + || (c.dof && c.dof.enabled); + + // Тени и SSAO — через scene3d (они вне pipeline). + try { + if (c.shadows && this.scene3d?.setShadowQuality) { + this.scene3d.setShadowQuality(c.shadows); + } + } catch (e) { /* ignore */ } + try { + if (this.scene3d?.setSsaoEnabled) this.scene3d.setSsaoEnabled(!!c.ssao); + } catch (e) { /* ignore */ } + + if (!anyPipelineFx) { + this.enabled = false; + this._disposePipeline(); + return; + } + this.enabled = true; + this._rebuildPipeline(); + } + + _rebuildPipeline() { + this._disposePipeline(); + if (!this.scene || !this.camera) return; + const c = this.config; + const useHdr = (c.bloom && c.bloom.enabled) && !this.mobile; + + const p = new DefaultRenderingPipeline('rbx_graphics', useHdr, this.scene, [this.camera]); + + // Bloom + p.bloomEnabled = !!(c.bloom && c.bloom.enabled); + if (p.bloomEnabled) { + p.bloomThreshold = c.bloom.threshold ?? 0.85; + p.bloomWeight = c.bloom.intensity ?? 0.5; + p.bloomKernel = this.mobile ? 32 : 64; + p.bloomScale = 0.5; + } + + // FXAA + p.fxaaEnabled = !!c.fxaa; + p.samples = this.mobile ? 1 : 4; + + // Image processing: виньетка + цветокоррекция + (опц.) тонмаппинг + const ip = p.imageProcessing; + if (ip) { + p.imageProcessingEnabled = true; + ip.toneMappingEnabled = false; // как в GdPostFx — иначе картинка темнеет + // экспозиция/контраст из grading + if (c.grading && c.grading.enabled) { + ip.exposure = c.grading.exposure ?? 1.0; + ip.contrast = c.grading.contrast ?? 1.0; + ip.colorCurvesEnabled = true; + try { + const curves = ip.colorCurves; + if (curves) { + // saturation: 1.0 = норма → curves в диапазоне примерно -100..100 + const sat = c.grading.saturation ?? 1.0; + curves.globalSaturation = Math.round((sat - 1.0) * 60); + } + } catch (e) { /* ignore */ } + } else { + ip.exposure = 1.0; ip.contrast = 1.0; + } + // виньетка + if (c.vignette && c.vignette.enabled) { + ip.vignetteEnabled = true; + ip.vignetteWeight = c.vignette.weight ?? 0.6; + ip.vignetteColor = new Color4(0, 0, 0, 0); + ip.vignetteStretch = 0.3; + ip.vignetteCameraFov = 0.5; + ip.vignetteBlendMode = ImageProcessingConfiguration.VIGNETTEMODE_MULTIPLY; + } else { + ip.vignetteEnabled = false; + } + } + + // Depth of Field (глубина резкости) — только desktop + if (c.dof && c.dof.enabled && !this.mobile) { + p.depthOfFieldEnabled = true; + try { + p.depthOfFieldBlurLevel = 1; // 0..2 + p.depthOfField.focusDistance = (c.dof.focusDistance ?? 18) * 1000; // мм + p.depthOfField.focalLength = c.dof.focalLength ?? 50; + p.depthOfField.fStop = c.dof.aperture ?? 1.2; + } catch (e) { /* ignore */ } + } else { + p.depthOfFieldEnabled = false; + } + + this._pipeline = p; + } + + _disposePipeline() { + if (this._pipeline) { + try { this._pipeline.dispose(); } catch (e) { /* ignore */ } + this._pipeline = null; + } + } + + dispose() { + this._disposePipeline(); + this.scene = null; + this.camera = null; + this.scene3d = null; + } +} diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index 8a50c0c..ed6e386 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -6,7 +6,7 @@ * - позиция (x, y, z) * - размер (sx, sy, sz) * - цвет (#hex) - * - материал ('matte' | 'metal' | 'glass' | 'neon') + * - материал ('matte'|'metal'|'glass'|'neon'|'studs'|'chrome'|'water'|'iridescent') * - canCollide (bool) — участвует ли в физике коллизий * - visible (bool) — рисуется ли (anchored — пока заготовка) * @@ -533,7 +533,9 @@ export class PrimitiveManager { break; case 'glass': mat.alpha = 0.4; - mat.specularColor = new Color3(0.5, 0.5, 0.5); + mat.specularColor = new Color3(0.8, 0.85, 0.9); + mat.specularPower = 96; // более чёткий блик на стекле + mat.backFaceCulling = false; // видно «толщину» — глубже эффект break; case 'neon': mat.emissiveColor = Color3.FromHexString(color || '#888888'); @@ -566,6 +568,39 @@ export class PrimitiveManager { mat.specularColor = new Color3(0, 0, 0); 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': default: mat.specularColor = new Color3(0, 0, 0); diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 6d38fa4..4119fcd 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -3890,6 +3890,53 @@ const game = { _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: { /** Прочитать namespace. fn(data) — data это сохранённый объект или null. */ get(namespace, fn) { From 2f9d6a21f6316ea225d187f91004150a0ca13035 Mon Sep 17 00:00:00 2001 From: min Date: Wed, 10 Jun 2026 01:27:48 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(rbxl-importer):=20CORS=20preflight=20+?= =?UTF-8?q?=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B2=D1=81=D0=B5=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фронт студии (studio.rublox.pro) делал POST /import/rbxl/analyze на minecraftia-school.ru/api-rbxl, preflight (OPTIONS) не получал Access-Control-Allow-Origin → CORS ошибка. Фиксы: - after_request гарантированно ставит CORS-заголовки на ВСЕ ответы (включая OPTIONS) — раньше flask-cors иногда их не отдавал - Явный handler для OPTIONS /import/rbxl/analyze + create - Headers: Allow-Origin=*, Allow-Methods, Allow-Headers content-type+x-user-id - Убрал ALLOWED_USER_IDS=[1] (импорт открыт всем — кнопка в UI уже без гейтинга, см. вики «Импорт из Roblox») Деплой: вручную через SSH на VM 130 (rbxl-importer не имеет CI/CD). --- rbxl-importer/src/app.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/rbxl-importer/src/app.py b/rbxl-importer/src/app.py index 9da2929..78c5bc3 100644 --- a/rbxl-importer/src/app.py +++ b/rbxl-importer/src/app.py @@ -59,10 +59,28 @@ STORAGE_ROOT = os.environ.get('STORAGE_ROOT', '/opt/roblox-assets') PUBLIC_ASSET_BASE = os.environ.get('PUBLIC_ASSET_BASE', 'https://assets.rublox.pro/roblox') MAX_RBXL_SIZE = 50 * 1024 * 1024 # 50 MB -ALLOWED_USER_IDS = [1] # пока только МИН app = Flask(__name__) -CORS(app, resources={r'/*': {'origins': '*'}}) +# CORS открыт для всех источников — фронт студии живёт на studio.rublox.pro, +# 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-сессий студии: фронт пушит сюда # console.error/warn, failed network requests, неожиданные exceptions. @@ -93,8 +111,7 @@ def auth_check(req) -> int: uid = int(user_id_str) except ValueError: raise RuntimeError(f'Bad X-User-Id: {user_id_str!r}') - if uid not in ALLOWED_USER_IDS: - raise RuntimeError(f'User {uid} not allowed (only МИН)') + # Импорт открыт всем (см. вики «Импорт из Roblox»). return uid From ba71f5f4b9147085a9fdf14d412d2776b03c241f Mon Sep 17 00:00:00 2001 From: min Date: Wed, 10 Jun 2026 01:41:04 +0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(studio):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B9=20escape=20?= =?UTF-8?q?\[=20=D0=B2=20docsLang=20regex=20(CI=20lint=20error=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=B2=D0=BB=D0=B8=D1=82=D0=BE=D0=B3=D0=BE=20main)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/community/docsLang.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/community/docsLang.jsx b/src/community/docsLang.jsx index ffeb43d..7118e92 100644 --- a/src/community/docsLang.jsx +++ b/src/community/docsLang.jsx @@ -83,7 +83,7 @@ export function highlightCode(text, lang) { // Классифицируем if (tok.startsWith('--') || tok.startsWith('//') || tok.startsWith('/*')) { tokens.push({ type: 'comment', text: tok }); - } else if (/^["'`\[]/.test(tok)) { + } else if (/^["'`[]/.test(tok)) { tokens.push({ type: 'string', text: tok }); } else if (/^\d/.test(tok)) { tokens.push({ type: 'number', text: tok });