studio/src/editor/TopRibbon.jsx
min 5e1a0edf9b feat(studio): задача 17 — Toolbox переработан под Roblox Creator Store
Единый 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>
2026-06-05 01:33:39 +03:00

532 lines
25 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,
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;