studio/src/editor/GameDecorModal.jsx
min 8ccea76dc0 feat(studio): система графики/эффектов (шейдеры) + материалы + перенос Оформления
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 <noreply@anthropic.com>
2026-06-10 01:24:30 +03:00

441 lines
30 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;