studio/src/editor/TopRibbon.jsx
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
Open-source веб-студия для создания игр Рублокса, двойная лицензия
AGPL-3.0 + Коммерческая.

Главное:
- Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16
- Самодостаточный движок ~28к строк (66 файлов): BlockManager,
  TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController,
  ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов
- Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco)
- Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn)
- Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt)
- 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.)
- Конфигурируемый бэкенд через VITE_API_BASE — работает со staging
  (dev-api.rublox.pro) без настройки
- Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка
- Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING,
  SECURITY, CHANGELOG
- ESLint + Prettier + EditorConfig
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Перед публикацией:
- Все импорты из minecraftia заменены на локальные
- Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env
- Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо)
- AdminKubikonModeration не публикуется (модерация — в team.rublox.pro)
- 93 МБ ассетов public/kubikon-assets вынесены в .gitignore
  (раздаются через release artifact)
2026-05-27 23:41:10 +03:00

530 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' },
{ 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="trees" label="Модель"
active={activeTool === 'model'}
onClick={() => onToolChange('model')}
/>
<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;