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 ( <> {trigger} {open && createPortal(
{typeof children === 'function' ? children(() => setOpen(false)) : children}
, 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 }) => (
{children}
{title &&
{title}
}
); /** Большая кнопка ribbon. Принимает либо iconName (имя для Icon из Icon.jsx) * либо icon (готовый JSX, для legacy/особых случаев). */ const RibbonBtn = ({ iconName, icon, label, active, disabled, danger, onClick, title }) => ( ); /** Большая кнопка с акцентным фоном — для Play/Stop в правом углу ленты. * Принимает iconName (имя для Icon) или icon (готовый JSX). */ const BigBtn = ({ iconName, icon, label, gradient, onClick, title, disabled, glow }) => ( ); 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 (
{/* === Вкладки === */}
{TABS.map(t => ( ))}
{/* === Лента содержимого вкладки === */}
{activeTab === 'home' && ( <> {/* Подсвечиваем только если activeTool='select' (инструмент создания не активен) */} onGizmoModeChange('select')} title="Выбрать без манипулятора" /> onGizmoModeChange('move')} title="Стрелки X/Y/Z для перемещения" /> onGizmoModeChange('rotate')} title="Кольца X/Y/Z для поворота" /> onGizmoModeChange('scale')} title="Кубики X/Y/Z для масштабирования" />
Шаг
{SNAP_OPTIONS.map(o => ( ))}
onToolChange('block')} /> onToolChange('primitive')} title="Параметрическая фигура (куб/сфера/...)" /> onToolChange('gui')} title="2D-интерфейс игры — перетащи элемент на экран" /> onToolChange('terrain')} title="Создание и редактирование ландшафта (как Terrain в Roblox Studio)" /> onToolChange('gen')} title="Процедурная генерация ландшафта по параметрам noise" /> )} {activeTab === 'model' && ( <> {/* Редактор моделей: создание своих моделей. Воксельная — кубики 0.25м как Minecraft/MagicaVoxel. (Гладкий редактор пока не реализован — кнопка убрана.) */} )} {activeTab === 'test' && ( <> {/* «Окружение» (время суток / амбиент / музыка) и «Скин игрока» переехали в иерархию объектов сцены: 🌞 Освещение / 🎵 Звук / 👤 Игрок. */} )} {activeTab === 'view' && ( <> onViewPreset('top')} /> onViewPreset('front')} /> onViewPreset('side')} /> onViewPreset('iso')} />
Размер
{[50, 100, 200, 500].map(sz => ( ))}
Тени
{[ { id: 'off', label: 'Выкл' }, { id: 'hard', label: 'Жёсткие' }, { id: 'soft', label: 'Мягкие' }, ].map(o => ( ))}
)} {/* === Запустить / Стоп — всегда видны, не зависят от вкладок === Большие кнопки с иконкой сверху и подписью снизу (как в Roblox Studio). */}
{ if (!isPlaying) onPlayToggle?.(); }} disabled={isPlaying} title="Запустить игру (тестировать)" /> { if (isPlaying) onPlayToggle?.(); }} disabled={!isPlaying} title="Остановить игру" />
); }; export default TopRibbon;