Единый Toolbox вместо отдельной кнопки «Модель» в панели «Создать»: - 4 верхние вкладки как в Roblox: Магазин / Инвентарь / Недавние / Советы. - Магазин: главный экран с 6 плитками-категориями (3D-объекты / Эффекты / 2D-картинки / Готовые механики / Плагины / Аудио) + ряд «Популярное» (FREE). - Клик по категории → детальный список с поиском и подкатегориями; «← Категории». - 3D-объекты = 700+ моделей; Эффекты = эмиттер/луч/указатель/свет/триггер; Готовые механики = 12 китов; 2D/Плагины/Аудио = «Скоро будет». - Инвентарь = мои воксельные модели; Недавние = модели сообщества; Советы = гайд. - TopRibbon: кнопка «Модель» → «Toolbox» (открывает магазин); вкладка «Модель» переименована в «Редактор моделей» (создание своих воксельных ассетов). - CSS: topTabs/catGrid/catTile/trendRow/breadcrumb/soon/tips/freeBadge. Вся прежняя логика моделей (lazy-load, лайки, thumbnails) сохранена внутри новой структуры. Esc в категории → назад к плиткам. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
532 lines
25 KiB
JavaScript
532 lines
25 KiB
JavaScript
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,
|
||
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="Поставить точку спавна там где смотрит камера"
|
||
/>
|
||
</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;
|