studio/src/editor/TopRibbon.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

569 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import { MODEL_TYPES } from './engine/ModelTypes';
import { AMBIENT_PRESETS, MUSIC_PRESETS } from './engine/AudioManager';
import Icon from './Icon';
import cl from './TopRibbon.module.css';
const CHARACTER_OPTIONS = MODEL_TYPES.filter(m => m.category === 'Персонажи');
// Глобальный реестр функций-закрывателей для всех Dropdown'ов.
// При открытии нового закрываем все предыдущие.
const _dropdownClosers = new Set();
/**
* Выпадающий список через React portal — рендерится в body, поэтому
* не обрезается overflow родительских контейнеров TopRibbon.
*/
const Dropdown = ({ trigger, children }) => {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0 });
const triggerRef = useRef(null);
const menuRef = useRef(null);
// Регистрируем closer для глобальной координации
useEffect(() => {
const closer = () => setOpen(false);
_dropdownClosers.add(closer);
return () => { _dropdownClosers.delete(closer); };
}, []);
// Считаем позицию dropdown относительно viewport при открытии
useLayoutEffect(() => {
if (!open || !triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
setPos({
top: rect.bottom + 4,
left: rect.left,
});
}, [open]);
useEffect(() => {
if (!open) return;
const onDoc = (e) => {
if (triggerRef.current && triggerRef.current.contains(e.target)) return;
if (menuRef.current && menuRef.current.contains(e.target)) return;
setOpen(false);
};
// setTimeout чтобы listener привязался ПОСЛЕ текущего click event
// (иначе клик который открыл dropdown сразу же его закроет)
const t = setTimeout(() => document.addEventListener('click', onDoc), 0);
return () => {
clearTimeout(t);
document.removeEventListener('click', onDoc);
};
}, [open]);
const handleTriggerClick = (e) => {
e.stopPropagation();
if (open) {
setOpen(false);
} else {
// Закрываем все остальные открытые dropdown'ы
_dropdownClosers.forEach(close => close());
setOpen(true);
}
};
return (
<>
<span
className={cl.dropdownWrap}
ref={triggerRef}
onClick={handleTriggerClick}
style={{ display: 'inline-flex', cursor: 'pointer' }}
>
{trigger}
</span>
{open && createPortal(
<div
ref={menuRef}
className={cl.dropdownMenu}
style={{
position: 'fixed',
top: pos.top,
left: pos.left,
zIndex: 99999,
background: '#252525',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
border: '1px solid #3a3a3a',
borderRadius: 12,
padding: 6,
minWidth: 220,
maxHeight: 320,
overflowY: 'auto',
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(79, 116, 255, 0.12)',
color: '#e8e8ea',
fontFamily: '"Roboto Condensed", system-ui, -apple-system, sans-serif',
}}
>
{typeof children === 'function' ? children(() => setOpen(false)) : children}
</div>,
document.body
)}
</>
);
};
/**
* Roblox Studio-style верхняя панель инструментов.
*
* Архитектура: Tabs (вкладки сверху) + Ribbon (лента кнопок-групп).
* Каждая кнопка = крупная иконка-эмодзи + подпись снизу.
*
* Props:
* activeTab — 'home' | 'model' | 'test' | 'view'
* onTabChange(tab)
*
* gizmoMode — 'select' | 'move' | 'rotate' | 'scale'
* onGizmoModeChange(mode)
*
* snap — 1.0 | 0.5 | 0.25 | 0
* onSnapChange(step)
*
* activeTool — 'select' | 'block' | 'model' | 'erase' (создание)
* onToolChange(tool)
*
* isPlaying — режим Play
* onPlayToggle()
* onSetSpawn()
*
* hasSelection — есть ли выделенный объект (для активации Model-инструментов)
* onDuplicate()
* onAlignToFloor()
* onDelete()
*
* onClearScene()
*
* onViewPreset(preset) — 'top' | 'front' | 'side' | 'iso'
*/
const TABS = [
{ id: 'home', label: 'Главная', iconName: 'home' },
// Вкладка-редактор СВОИХ воксельных моделей (создание ассета).
// Каталог готовых моделей/механик теперь в Toolbox (кнопка на «Главной»).
{ id: 'model', label: 'Редактор моделей', iconName: 'wrench' },
{ id: 'test', label: 'Игра', iconName: 'gamepad' },
{ id: 'view', label: 'Вид', iconName: 'eye' },
];
const SNAP_OPTIONS = [
{ value: 1.0, label: '1.0' },
{ value: 0.5, label: '0.5' },
{ value: 0.25, label: '0.25' },
{ value: 0, label: 'Off' },
];
/** Группа кнопок в ribbon с подписью. */
const Group = ({ title, children }) => (
<div className={cl.group}>
<div className={cl.groupBody}>{children}</div>
{title && <div className={cl.groupTitle}>{title}</div>}
</div>
);
/** Большая кнопка ribbon. Принимает либо iconName (имя для Icon из Icon.jsx)
* либо icon (готовый JSX, для legacy/особых случаев). */
const RibbonBtn = ({ iconName, icon, label, active, disabled, danger, onClick, title }) => (
<button
className={`${cl.ribbonBtn} ${active ? cl.btnActive : ''} ${danger ? cl.btnDanger : ''}`}
onClick={onClick}
disabled={disabled}
title={title || label}
>
<span className={cl.btnIcon}>
{iconName ? <Icon name={iconName} size={20} /> : icon}
</span>
<span className={cl.btnLabel}>{label}</span>
</button>
);
/** Большая кнопка с акцентным фоном — для Play/Stop в правом углу ленты.
* Принимает iconName (имя для Icon) или icon (готовый JSX). */
const BigBtn = ({ iconName, icon, label, gradient, onClick, title, disabled, glow }) => (
<button
onClick={onClick}
disabled={disabled}
title={title || label}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minWidth: 84,
height: 70,
padding: '6px 12px',
border: '1px solid transparent',
borderRadius: 12,
background: disabled
? 'rgba(148, 163, 184, 0.12)'
: (gradient || 'linear-gradient(135deg, #3357ff 0%, #1e2da5 100%)'),
color: disabled ? '#94a3b8' : '#fff',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.85 : 1,
fontWeight: 800,
transition: 'all 200ms cubic-bezier(0.34, 1.56, 0.64, 1)',
boxShadow: disabled ? 'none' : (glow || '0 8px 20px rgba(51, 87, 255, 0.40)'),
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
letterSpacing: 0.2,
}}
onMouseEnter={(e) => {
if (disabled) return;
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.filter = 'brightness(1.08)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.filter = 'brightness(1)';
}}
>
<span style={{ fontSize: 22, lineHeight: 1, marginBottom: 4, display: 'flex', alignItems: 'center' }}>
{iconName ? <Icon name={iconName} size={26} strokeWidth={2.2} /> : icon}
</span>
<span style={{ fontSize: 12, letterSpacing: 0.3, fontWeight: 800 }}>{label}</span>
</button>
);
const TopRibbon = (props) => {
const {
activeTab, onTabChange,
gizmoMode, onGizmoModeChange,
snap, onSnapChange,
activeTool, onToolChange,
isPlaying, onPlayToggle, onSetSpawn,
onSkins, onInvite, collabActive, collabPeers,
onGraphics, onStartScreen, onLoadingScreen,
hasSelection,
onDuplicate, onAlignToFloor, onDelete,
onClearScene,
onViewPreset,
// === Этап 3: Создание моделей ===
onCreateVoxelModel,
onCreateSmoothModel,
// Открыть Toolbox на нужной вкладке: стандартные / мои / сообщество.
onOpenStandardModels,
onOpenMyModels,
onOpenCommunityModels,
} = props;
return (
<div className={cl.ribbon}>
{/* === Вкладки === */}
<div className={cl.tabs}>
{TABS.map(t => (
<button
key={t.id}
className={`${cl.tab} ${activeTab === t.id ? cl.tabActive : ''}`}
onClick={() => onTabChange(t.id)}
>
<span className={cl.tabIcon}><Icon name={t.iconName} size={14} /></span> {t.label}
</button>
))}
</div>
{/* === Лента содержимого вкладки === */}
<div className={cl.body}>
{activeTab === 'home' && (
<>
<Group title="Манипуляторы">
{/* Подсвечиваем только если activeTool='select' (инструмент создания не активен) */}
<RibbonBtn
iconName="cursor" label="Выделить"
active={activeTool === 'select' && gizmoMode === 'select'}
onClick={() => onGizmoModeChange('select')}
title="Выбрать без манипулятора"
/>
<RibbonBtn
iconName="move" label="Двигать"
active={activeTool === 'select' && gizmoMode === 'move'}
onClick={() => onGizmoModeChange('move')}
title="Стрелки X/Y/Z для перемещения"
/>
<RibbonBtn
iconName="rotate" label="Вращать"
active={activeTool === 'select' && gizmoMode === 'rotate'}
onClick={() => onGizmoModeChange('rotate')}
title="Кольца X/Y/Z для поворота"
/>
<RibbonBtn
iconName="ruler" label="Масштаб"
active={activeTool === 'select' && gizmoMode === 'scale'}
onClick={() => onGizmoModeChange('scale')}
title="Кубики X/Y/Z для масштабирования"
/>
</Group>
<Group title="Шаг привязки">
<div
className={cl.snapBox}
title="Шаг при перетаскивании гизмо «Двигать». 1.0 — координаты округляются до целых (как блоки), 0.5 — до полушагов, Off — плавно без привязки."
>
<div className={cl.snapLabel}>Шаг</div>
<div className={cl.snapButtons}>
{SNAP_OPTIONS.map(o => (
<button
key={o.value}
className={`${cl.snapBtn} ${snap === o.value ? cl.snapBtnActive : ''}`}
onClick={() => onSnapChange(o.value)}
title={
o.value === 0
? 'Без привязки — плавное перемещение'
: `Шаг ${o.label} (округление координат)`
}
>
{o.label}
</button>
))}
</div>
</div>
</Group>
<Group title="Создать">
<RibbonBtn
iconName="boxes" label="Блок"
active={activeTool === 'block'}
onClick={() => onToolChange('block')}
/>
<RibbonBtn
iconName="cube" label="Примитив"
active={activeTool === 'primitive'}
onClick={() => onToolChange('primitive')}
title="Параметрическая фигура (куб/сфера/...)"
/>
<RibbonBtn
iconName="box" label="Toolbox"
onClick={onOpenStandardModels}
title="Библиотека: 3D-объекты, готовые механики, эффекты (как Creator Store)"
/>
<RibbonBtn
iconName="image" label="Интерфейс"
active={activeTool === 'gui'}
onClick={() => onToolChange('gui')}
title="2D-интерфейс игры — перетащи элемент на экран"
/>
<RibbonBtn
iconName="mountain" label="Ландшафт"
active={activeTool === 'terrain'}
onClick={() => onToolChange('terrain')}
title="Создание и редактирование ландшафта (как Terrain в Roblox Studio)"
/>
<RibbonBtn
iconName="dice" label="Генератор"
active={activeTool === 'gen'}
onClick={() => onToolChange('gen')}
title="Процедурная генерация ландшафта по параметрам noise"
/>
<RibbonBtn
iconName="trash" label="Очистить"
danger
onClick={onClearScene}
title="Удалить ВСЁ из сцены"
/>
</Group>
</>
)}
{activeTab === 'model' && (
<>
{/* Редактор моделей: создание своих моделей.
Воксельная — кубики 0.25м как Minecraft/MagicaVoxel.
(Гладкий редактор пока не реализован — кнопка убрана.) */}
<Group title="Создать модель">
<RibbonBtn
iconName="cube" label="Воксельная"
onClick={onCreateVoxelModel}
title="Создать модель из кубиков 0.25м (как Minecraft)"
/>
</Group>
<Group title="Библиотека">
<RibbonBtn
iconName="package" label="Стандартные"
onClick={onOpenStandardModels}
title="Открыть Тулбокс — каталог стандартных моделей"
/>
<RibbonBtn
iconName="user" label="Мои модели"
onClick={onOpenMyModels}
title="Открыть Тулбокс на вкладке «Мои модели»"
/>
<RibbonBtn
iconName="globe" label="Сообщество"
onClick={onOpenCommunityModels}
title="Открыть Тулбокс на вкладке «Сообщество»"
/>
</Group>
<Group title="Действия">
<RibbonBtn
iconName="duplicate" label="Дублировать"
disabled={!hasSelection}
onClick={onDuplicate}
title="Создать копию рядом (Ctrl+D)"
/>
<RibbonBtn
iconName="arrow-down" label="На пол"
disabled={!hasSelection}
onClick={onAlignToFloor}
title="Опустить выделенный объект на y=0"
/>
<RibbonBtn
iconName="delete" label="Удалить"
disabled={!hasSelection}
danger
onClick={onDelete}
title="Удалить выделенное (Del)"
/>
</Group>
</>
)}
{activeTab === 'test' && (
<>
<Group title="Игра">
<RibbonBtn
iconName={isPlaying ? 'stop' : 'play'}
label={isPlaying ? 'Стоп' : 'Играть'}
active={isPlaying}
onClick={onPlayToggle}
title={isPlaying ? 'Выйти из игры (Esc)' : 'Запустить тест'}
/>
<RibbonBtn
iconName="flag" label="Ставить спавн"
onClick={onSetSpawn}
title="Поставить точку спавна там где смотрит камера"
/>
<RibbonBtn
iconName="user-square" label="Скины"
onClick={onSkins}
title="Скины игрока: стартовый скин, магазин, рублики, свои .glb"
/>
</Group>
{/* Team Create — совместное редактирование. */}
<Group title="Вместе">
<RibbonBtn
iconName="users"
label={collabActive && collabPeers > 0 ? `Вместе (${collabPeers + 1})` : 'Пригласить'}
active={collabActive && collabPeers > 0}
onClick={onInvite}
title="Пригласить друга редактировать игру вместе (Team Create)"
/>
</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>
{/* «Окружение» (время суток / амбиент / музыка) и
«Скин игрока» переехали в иерархию объектов сцены:
🌞 Освещение / 🎵 Звук / 👤 Игрок. */}
</>
)}
{activeTab === 'view' && (
<>
<Group title="Перспективы">
<RibbonBtn
iconName="arrow-up" label="Сверху"
onClick={() => onViewPreset('top')}
/>
<RibbonBtn
iconName="arrow-right" label="Спереди"
onClick={() => onViewPreset('front')}
/>
<RibbonBtn
iconName="arrow-left" label="Сбоку"
onClick={() => onViewPreset('side')}
/>
<RibbonBtn
iconName="cube" label="Изо"
onClick={() => onViewPreset('iso')}
/>
</Group>
<Group title="Размер пола">
<div className={cl.snapBox} title="Сторона прямоугольного пола в юнитах">
<div className={cl.snapLabel}>Размер</div>
<div className={cl.snapButtons}>
{[50, 100, 200, 500].map(sz => (
<button
key={sz}
className={`${cl.snapBtn} ${(props.worldSize || 80) === sz ? cl.snapBtnActive : ''}`}
onClick={() => props.onWorldSizeChange?.(sz)}
title={`Пол ${sz}×${sz}`}
>
{sz}
</button>
))}
</div>
</div>
</Group>
<Group title="Тени">
<div className={cl.snapBox} title="Качество теней">
<div className={cl.snapLabel}>Тени</div>
<div className={cl.snapButtons}>
{[
{ id: 'off', label: 'Выкл' },
{ id: 'hard', label: 'Жёсткие' },
{ id: 'soft', label: 'Мягкие' },
].map(o => (
<button
key={o.id}
className={`${cl.snapBtn} ${(props.shadowQuality || 'soft') === o.id ? cl.snapBtnActive : ''}`}
onClick={() => props.onShadowQualityChange?.(o.id)}
>
{o.label}
</button>
))}
</div>
</div>
</Group>
</>
)}
{/* === Запустить / Стоп — всегда видны, не зависят от вкладок ===
Большие кнопки с иконкой сверху и подписью снизу (как в Roblox Studio). */}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8, padding: '0 14px' }}>
<BigBtn
iconName="play"
label="Запустить"
gradient="linear-gradient(135deg, #22d97a 0%, #0f9d56 100%)"
glow="0 8px 20px rgba(34, 217, 122, 0.45)"
onClick={() => { if (!isPlaying) onPlayToggle?.(); }}
disabled={isPlaying}
title="Запустить игру (тестировать)"
/>
<BigBtn
iconName="stop"
label="Стоп"
gradient="linear-gradient(135deg, #ff6f7a 0%, #c0303f 100%)"
glow="0 8px 20px rgba(239, 68, 68, 0.45)"
onClick={() => { if (isPlaying) onPlayToggle?.(); }}
disabled={!isPlaying}
title="Остановить игру"
/>
</div>
</div>
</div>
);
};
export default TopRibbon;