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>
This commit is contained in:
parent
d62739d709
commit
5e1a0edf9b
@ -282,6 +282,13 @@ const ToolboxModal = ({
|
||||
initialSection = 'standard',
|
||||
}) => {
|
||||
// Корневой раздел: 'standard' | 'mine' | 'community'
|
||||
// === Roblox-style Toolbox (задача 17) ===
|
||||
// Верхняя вкладка: 'store' | 'inventory' | 'recent' | 'tips'.
|
||||
const [view, setView] = useState('store');
|
||||
// Выбранная категория магазина (null = главный экран с 6 плитками):
|
||||
// '3d' | 'fx' | '2d' | 'gameplay' | 'plugins' | 'audio'.
|
||||
const [storeCat, setStoreCat] = useState(null);
|
||||
|
||||
const [section, setSection] = useState('standard');
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('all'); // для 'standard'
|
||||
@ -297,6 +304,9 @@ const ToolboxModal = ({
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearch('');
|
||||
// initialSection маппится в новую структуру: mine → inventory.
|
||||
if (initialSection === 'mine') { setView('inventory'); setStoreCat(null); }
|
||||
else { setView('store'); setStoreCat(null); }
|
||||
setSection(initialSection || 'standard');
|
||||
setCategory('all');
|
||||
setUserKind('all');
|
||||
@ -307,13 +317,30 @@ const ToolboxModal = ({
|
||||
}
|
||||
}, [open, initialSection]);
|
||||
|
||||
// Esc — закрыть
|
||||
// Маппинг категории магазина → внутренний section (для lazy-load моделей).
|
||||
const STORE_CAT_TO_SECTION = { '3d': 'standard', gameplay: 'gameplay', '2d': 'standard' };
|
||||
const openStoreCategory = useCallback((catId) => {
|
||||
setStoreCat(catId);
|
||||
setSearch('');
|
||||
setCategory('all');
|
||||
setKitCat('all');
|
||||
const sec = STORE_CAT_TO_SECTION[catId];
|
||||
if (sec) setSection(sec);
|
||||
}, []);
|
||||
|
||||
// Синхронизация верхней вкладки → внутренний section (для lazy-load).
|
||||
useEffect(() => {
|
||||
if (view === 'inventory') setSection('mine');
|
||||
else if (view === 'recent') setSection('community');
|
||||
}, [view]);
|
||||
|
||||
// Esc — закрыть (если открыта категория магазина — сначала назад к плиткам)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
const onKey = (e) => { if (e.key === 'Escape') { if (view === 'store' && storeCat) setStoreCat(null); else onClose(); } };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
}, [open, onClose, view, storeCat]);
|
||||
|
||||
// Lazy-load моих моделей при переключении на 'mine'
|
||||
useEffect(() => {
|
||||
@ -438,6 +465,27 @@ const ToolboxModal = ({
|
||||
? (myModels?.length || 0)
|
||||
: (communityModels?.length || 0);
|
||||
|
||||
// 6 категорий магазина (как в Roblox Creator Store).
|
||||
const STORE_CATEGORIES = [
|
||||
{ id: '3d', label: '3D-объекты', icon: 'cube', desc: '700+ моделей: природа, дома, мебель, NPC' },
|
||||
{ id: 'fx', label: 'Эффекты', icon: 'sparkles', desc: 'Частицы, лучи, маркеры' },
|
||||
{ id: '2d', label: '2D-картинки', icon: 'image', desc: 'Иконки и текстуры для интерфейса' },
|
||||
{ id: 'gameplay', label: 'Готовые механики', icon: 'zap', desc: '12 механик: вставил — работает' },
|
||||
{ id: 'plugins', label: 'Плагины', icon: 'puzzle', desc: 'Расширения студии' },
|
||||
{ id: 'audio', label: 'Аудио', icon: 'sound', desc: 'Звуки и музыка' },
|
||||
];
|
||||
// Trending — что популярно (для главного экрана магазина). Берём яркие киты.
|
||||
const TRENDING = GAMEPLAY_KITS.filter(k =>
|
||||
['shift-to-run', 'day-night-cycle', 'loot-crate', 'confetti'].includes(k.id));
|
||||
// Эффекты-примитивы для категории «Эффекты».
|
||||
const FX_ITEMS = [
|
||||
{ id: 'emitter', name: 'Эмиттер частиц', icon: 'sparkles', desc: 'Источник частиц (огонь/искры/дым)' },
|
||||
{ id: 'beam', name: 'Луч (beam)', icon: 'zap', desc: 'Бегущий луч между точками' },
|
||||
{ id: 'pointer', name: 'Указатель-стрелка', icon: 'arrow-up', desc: 'Парящая стрелка-подсказка' },
|
||||
{ id: 'light', name: 'Источник света', icon: 'sun', desc: 'Точечная лампа' },
|
||||
{ id: 'checkpoint', name: 'Триггер-зона', icon: 'flag', desc: 'Невидимая зона-триггер' },
|
||||
];
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Обработчик выбора пользовательской модели — пока stub.
|
||||
@ -503,67 +551,63 @@ const ToolboxModal = ({
|
||||
return (
|
||||
<div className={cl.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className={cl.modal}>
|
||||
{/* === Шапка с 4 верхними вкладками (как Roblox Creator Store) === */}
|
||||
<header className={cl.header}>
|
||||
<h2 className={cl.title} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Icon name="box" size={20} /> Тулбокс — библиотека объектов
|
||||
<Icon name="box" size={20} /> Toolbox
|
||||
</h2>
|
||||
<div className={cl.headerInfo}>
|
||||
{section === 'standard'
|
||||
? `Показано ${visibleCount} из ${totalForSection}`
|
||||
: section === 'gameplay'
|
||||
? `${kitsFiltered.length} готовых механик`
|
||||
: (myModels === null && section === 'mine') || (communityModels === null && section === 'community')
|
||||
? '...'
|
||||
: `${visibleCount} из ${totalForSection}`}
|
||||
</div>
|
||||
<button className={cl.closeBtn} onClick={onClose} title="Закрыть (Esc)">
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Раздел: Стандартные / Мои / Сообщество */}
|
||||
<div className={cl.sectionTabs}>
|
||||
<div className={cl.topTabs}>
|
||||
{[
|
||||
{ id: 'store', label: 'Магазин', icon: 'box' },
|
||||
{ id: 'inventory', label: 'Инвентарь', icon: 'grid' },
|
||||
{ id: 'recent', label: 'Недавние', icon: 'clock' },
|
||||
{ id: 'tips', label: 'Советы', icon: 'bulb' },
|
||||
].map(t => (
|
||||
<button
|
||||
className={`${cl.sectionTab} ${section === 'standard' ? cl.sectionTabActive : ''}`}
|
||||
onClick={() => setSection('standard')}
|
||||
key={t.id}
|
||||
className={`${cl.topTab} ${view === t.id ? cl.topTabActive : ''}`}
|
||||
onClick={() => { setView(t.id); setStoreCat(null); setSearch(''); }}
|
||||
title={t.label}
|
||||
>
|
||||
<Icon name="library" size={15} /> Стандартные
|
||||
</button>
|
||||
<button
|
||||
className={`${cl.sectionTab} ${section === 'mine' ? cl.sectionTabActive : ''}`}
|
||||
onClick={() => setSection('mine')}
|
||||
>
|
||||
<Icon name="user" size={15} /> Мои модели
|
||||
</button>
|
||||
<button
|
||||
className={`${cl.sectionTab} ${section === 'community' ? cl.sectionTabActive : ''}`}
|
||||
onClick={() => setSection('community')}
|
||||
>
|
||||
<Icon name="globe" size={15} /> Сообщество
|
||||
</button>
|
||||
<button
|
||||
className={`${cl.sectionTab} ${section === 'gameplay' ? cl.sectionTabActive : ''}`}
|
||||
onClick={() => setSection('gameplay')}
|
||||
>
|
||||
<Icon name="zap" size={15} /> Готовые механики
|
||||
<Icon name={t.icon} size={18} />
|
||||
<span>{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Поиск — скрыт только на главном экране магазина и в советах */}
|
||||
{!(view === 'store' && !storeCat) && view !== 'tips' && (
|
||||
<div className={cl.searchBar}>
|
||||
<input
|
||||
type="text"
|
||||
className={cl.searchInput}
|
||||
placeholder={section === 'standard'
|
||||
? 'Поиск по названию, категории...'
|
||||
: 'Поиск по названию модели или автору...'}
|
||||
placeholder="Поиск по названию..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Подкатегории зависят от раздела */}
|
||||
{section === 'standard' && (
|
||||
{/* Хлебные крошки/назад при открытой категории магазина */}
|
||||
{view === 'store' && storeCat && (
|
||||
<div className={cl.breadcrumb}>
|
||||
<button className={cl.backBtn} onClick={() => setStoreCat(null)}>
|
||||
<Icon name="arrow-left" size={14} /> Категории
|
||||
</button>
|
||||
<span className={cl.crumbCurrent}>
|
||||
{(STORE_CATEGORIES.find(c => c.id === storeCat) || {}).label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Подкатегории standard (3D) */}
|
||||
{view === 'store' && storeCat === '3d' && (
|
||||
<div className={cl.categoryTabs}>
|
||||
{standardCategoriesWithCount.map(c => (
|
||||
<button
|
||||
@ -578,153 +622,182 @@ const ToolboxModal = ({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'gameplay' && (
|
||||
{/* Подкатегории механик */}
|
||||
{view === 'store' && storeCat === 'gameplay' && (
|
||||
<div className={cl.categoryTabs}>
|
||||
{KIT_CATEGORIES.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
className={`${cl.categoryTab} ${kitCat === c.id ? cl.categoryTabActive : ''}`}
|
||||
onClick={() => setKitCat(c.id)}
|
||||
>
|
||||
{c.label}
|
||||
>{c.label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Фильтр kind для инвентаря */}
|
||||
{view === 'inventory' && (
|
||||
<div className={cl.categoryTabs}>
|
||||
<button className={`${cl.categoryTab} ${userKind === 'all' ? cl.categoryTabActive : ''}`} onClick={() => setUserKind('all')}><Icon name="library" size={14} /> Все</button>
|
||||
<button className={`${cl.categoryTab} ${userKind === 'voxel' ? cl.categoryTabActive : ''}`} onClick={() => setUserKind('voxel')}><Icon name="square" size={14} /> Воксельные</button>
|
||||
<button className={`${cl.categoryTab} ${userKind === 'smooth' ? cl.categoryTabActive : ''}`} onClick={() => setUserKind('smooth')}><Icon name="sphere" size={14} /> Гладкие</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====================== КОНТЕНТ ====================== */}
|
||||
|
||||
{/* --- МАГАЗИН: главный экран (6 плиток + Trending) --- */}
|
||||
{view === 'store' && !storeCat && (
|
||||
<div className={cl.storeHome}>
|
||||
<div className={cl.sectionLabel}>Категории</div>
|
||||
<div className={cl.catGrid}>
|
||||
{STORE_CATEGORIES.map(c => (
|
||||
<button key={c.id} className={cl.catTile} onClick={() => openStoreCategory(c.id)}>
|
||||
<div className={cl.catTileIcon}><Icon name={c.icon} size={26} /></div>
|
||||
<div className={cl.catTileLabel}>{c.label}</div>
|
||||
<div className={cl.catTileDesc}>{c.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={cl.sectionLabel} style={{ marginTop: 18 }}>
|
||||
<Icon name="trending" size={15} /> Популярное
|
||||
</div>
|
||||
<div className={cl.trendRow}>
|
||||
{TRENDING.map(kit => (
|
||||
<button key={kit.id} className={cl.trendCard}
|
||||
onClick={() => { onPick('kit:' + kit.id); onClose(); }} title={kit.desc}>
|
||||
<div className={cl.trendIcon}><Icon name={kit.icon || 'zap'} size={28} /></div>
|
||||
<div className={cl.trendName}>{kit.name}</div>
|
||||
<div className={cl.freeBadge}>FREE</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- МАГАЗИН: категория 3D-объекты --- */}
|
||||
{view === 'store' && storeCat === '3d' && (
|
||||
<div className={cl.grid}>
|
||||
{standardFiltered.length === 0
|
||||
? <div className={cl.empty}>Ничего не найдено</div>
|
||||
: standardFiltered.map(m => (
|
||||
<ThumbCard key={m.id} model={m} active={activeId === m.id}
|
||||
onPick={() => { onPick(m.id); onClose(); }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- МАГАЗИН: Эффекты --- */}
|
||||
{view === 'store' && storeCat === 'fx' && (
|
||||
<div className={cl.grid}>
|
||||
{FX_ITEMS.filter(f => !search.trim() || f.name.toLowerCase().includes(search.trim().toLowerCase())).map(f => (
|
||||
<button key={f.id} type="button" className={cl.card} style={{ textAlign: 'left' }}
|
||||
onClick={() => { onPick('primitive:' + f.id); onClose(); }} title={f.desc}>
|
||||
<div className={cl.cardIconWrap}>
|
||||
<div className={cl.cardIconPlaceholder} style={{ background: 'linear-gradient(135deg, rgba(255,90,176,0.22), rgba(255,210,58,0.18))' }}>
|
||||
<Icon name={f.icon} size={30} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cl.cardName}>{f.name}</div>
|
||||
<div className={cl.cardCat} style={{ fontSize: 11, opacity: 0.8 }}>{f.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(section === 'mine' || section === 'community') && (
|
||||
<div className={cl.categoryTabs}>
|
||||
<button
|
||||
className={`${cl.categoryTab} ${userKind === 'all' ? cl.categoryTabActive : ''}`}
|
||||
onClick={() => setUserKind('all')}
|
||||
>
|
||||
<Icon name="library" size={14} /> Все
|
||||
</button>
|
||||
<button
|
||||
className={`${cl.categoryTab} ${userKind === 'voxel' ? cl.categoryTabActive : ''}`}
|
||||
onClick={() => setUserKind('voxel')}
|
||||
>
|
||||
<Icon name="square" size={14} /> Воксельные
|
||||
</button>
|
||||
<button
|
||||
className={`${cl.categoryTab} ${userKind === 'smooth' ? cl.categoryTabActive : ''}`}
|
||||
onClick={() => setUserKind('smooth')}
|
||||
>
|
||||
<Icon name="sphere" size={14} /> Гладкие
|
||||
{/* --- МАГАЗИН: Готовые механики --- */}
|
||||
{view === 'store' && storeCat === 'gameplay' && (
|
||||
<div className={cl.grid}>
|
||||
{kitsFiltered.length === 0
|
||||
? <div className={cl.empty}>Ничего не найдено</div>
|
||||
: kitsFiltered.map(kit => (
|
||||
<button key={kit.id} type="button" className={cl.card} style={{ textAlign: 'left' }}
|
||||
onClick={() => { onPick('kit:' + kit.id); onClose(); }} title={kit.desc}>
|
||||
<div className={cl.cardIconWrap}>
|
||||
<div className={cl.cardIconPlaceholder} style={{ background: 'linear-gradient(135deg, rgba(77,107,255,0.25), rgba(54,213,122,0.18))' }}>
|
||||
<Icon name={kit.icon || 'zap'} size={30} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cl.cardName}>{kit.name}</div>
|
||||
<div className={cl.cardCat} style={{ fontSize: 11, opacity: 0.8, lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{kit.desc}</div>
|
||||
<div className={cl.freeBadge}>FREE</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === Контент === */}
|
||||
<div className={cl.grid}>
|
||||
{section === 'standard' && (
|
||||
standardFiltered.length === 0 ? (
|
||||
<div className={cl.empty}>Ничего не найдено</div>
|
||||
) : (
|
||||
standardFiltered.map(m => (
|
||||
<ThumbCard
|
||||
key={m.id}
|
||||
model={m}
|
||||
active={activeId === m.id}
|
||||
onPick={() => { onPick(m.id); onClose(); }}
|
||||
/>
|
||||
))
|
||||
)
|
||||
{/* --- МАГАЗИН: 2D-картинки / Плагины / Аудио — пока «скоро» --- */}
|
||||
{view === 'store' && (storeCat === '2d' || storeCat === 'plugins' || storeCat === 'audio') && (
|
||||
<div className={cl.soon}>
|
||||
<Icon name={storeCat === 'audio' ? 'sound' : storeCat === '2d' ? 'image' : 'puzzle'} size={42} />
|
||||
<div className={cl.soonTitle}>Скоро будет</div>
|
||||
<div className={cl.soonText}>
|
||||
{storeCat === '2d' && 'Иконки и текстуры для интерфейса появятся в следующем обновлении.'}
|
||||
{storeCat === 'plugins' && 'Плагины-расширения студии — в разработке (фаза T4).'}
|
||||
{storeCat === 'audio' && 'Библиотека звуков и музыки — в разработке.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'mine' && (
|
||||
loading || myModels === null ? (
|
||||
{/* --- ИНВЕНТАРЬ: мои модели --- */}
|
||||
{view === 'inventory' && (
|
||||
<div className={cl.grid}>
|
||||
{loading || myModels === null ? (
|
||||
<div className={cl.empty}>⏳ Загрузка...</div>
|
||||
) : !userId ? (
|
||||
<div className={cl.empty}>
|
||||
Войдите в аккаунт, чтобы видеть свои модели
|
||||
</div>
|
||||
<div className={cl.empty}>Войдите в аккаунт, чтобы видеть свои модели</div>
|
||||
) : loadError ? (
|
||||
<div className={cl.empty}>{loadError}</div>
|
||||
) : mineFiltered.length === 0 ? (
|
||||
<div className={cl.empty}>
|
||||
{myModels.length === 0
|
||||
? 'У вас пока нет своих моделей. Создайте их во вкладке «Модель» → «Воксельная» или «Гладкая».'
|
||||
? 'У вас пока нет своих моделей. Создайте их в воксельном редакторе.'
|
||||
: 'Ничего не найдено по фильтру'}
|
||||
</div>
|
||||
) : (
|
||||
mineFiltered.map(m => (
|
||||
<UserModelCard
|
||||
key={m.id}
|
||||
model={m}
|
||||
active={activeId === userModelKey(m)}
|
||||
onPick={() => handlePickUserModel(m)}
|
||||
isMine
|
||||
onEdit={onEditUserModel}
|
||||
onSettings={onUserModelSettings}
|
||||
onDelete={onDeleteUserModel}
|
||||
/>
|
||||
<UserModelCard key={m.id} model={m} active={activeId === userModelKey(m)}
|
||||
onPick={() => handlePickUserModel(m)} isMine
|
||||
onEdit={onEditUserModel} onSettings={onUserModelSettings} onDelete={onDeleteUserModel} />
|
||||
))
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'community' && (
|
||||
loading || communityModels === null ? (
|
||||
{/* --- НЕДАВНИЕ: сообщество (популярные модели сообщества) --- */}
|
||||
{view === 'recent' && (
|
||||
<div className={cl.grid}>
|
||||
{communityModels === null ? (
|
||||
<div className={cl.empty}>⏳ Загрузка...</div>
|
||||
) : loadError ? (
|
||||
<div className={cl.empty}>{loadError}</div>
|
||||
) : communityFiltered.length === 0 ? (
|
||||
<div className={cl.empty}>
|
||||
{communityModels.length === 0
|
||||
? 'Пока нет опубликованных моделей сообщества. Будь первым!'
|
||||
: 'Ничего не найдено по фильтру'}
|
||||
</div>
|
||||
<div className={cl.empty}>Пока пусто. Используй ассеты — они появятся здесь.</div>
|
||||
) : (
|
||||
communityFiltered.map(m => (
|
||||
<UserModelCard
|
||||
key={m.id}
|
||||
model={m}
|
||||
active={activeId === userModelKey(m)}
|
||||
<UserModelCard key={m.id} model={m} active={activeId === userModelKey(m)}
|
||||
onPick={() => handlePickUserModel(m)}
|
||||
isMine={userId != null && m.user_id === userId}
|
||||
onEdit={onEditUserModel}
|
||||
onSettings={onUserModelSettings}
|
||||
onDelete={onDeleteUserModel}
|
||||
showSocial
|
||||
onLike={handleLikeModel}
|
||||
/>
|
||||
onEdit={onEditUserModel} onSettings={onUserModelSettings} onDelete={onDeleteUserModel}
|
||||
showSocial onLike={handleLikeModel} />
|
||||
))
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'gameplay' && (
|
||||
kitsFiltered.length === 0 ? (
|
||||
<div className={cl.empty}>Ничего не найдено</div>
|
||||
) : (
|
||||
kitsFiltered.map(kit => (
|
||||
<button
|
||||
key={kit.id}
|
||||
type="button"
|
||||
className={cl.card}
|
||||
onClick={() => { onPick('kit:' + kit.id); onClose(); }}
|
||||
title={kit.desc}
|
||||
style={{ textAlign: 'left' }}
|
||||
>
|
||||
<div className={cl.cardIconWrap}>
|
||||
<div className={cl.cardIconPlaceholder} style={{
|
||||
background: 'linear-gradient(135deg, rgba(77,107,255,0.25), rgba(54,213,122,0.18))',
|
||||
}}>
|
||||
<Icon name={kit.icon || 'zap'} size={30} />
|
||||
{/* --- СОВЕТЫ --- */}
|
||||
{view === 'tips' && (
|
||||
<div className={cl.tips}>
|
||||
<h3>Как пользоваться Toolbox</h3>
|
||||
<ul>
|
||||
<li><b>3D-объекты</b> — 700+ готовых моделей: деревья, дома, мебель, персонажи. Клик → объект появляется на сцене.</li>
|
||||
<li><b>Готовые механики</b> — вставь поведение одним кликом: бег на Shift, смена дня/ночи, сундук с лутом, счётчик монет. Скрипт прикрепляется сам.</li>
|
||||
<li><b>Эффекты</b> — частицы, лучи, источники света, триггер-зоны.</li>
|
||||
<li><b>Инвентарь</b> — твои воксельные модели, созданные в редакторе.</li>
|
||||
<li>Жми на категорию, ищи через поиск, кликни ассет — он добавится в проект.</li>
|
||||
</ul>
|
||||
<p style={{ opacity: 0.7 }}>Собери целую игру, не написав ни строчки кода — просто перетаскивая готовые механики.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cl.cardName}>{kit.name}</div>
|
||||
<div className={cl.cardCat} style={{
|
||||
fontSize: 11, opacity: 0.8, lineHeight: 1.3,
|
||||
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
||||
}}>{kit.desc}</div>
|
||||
</button>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -447,3 +447,134 @@
|
||||
font-size: 36px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ====================== Roblox-style Toolbox (задача 17) ====================== */
|
||||
.topTabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.topTab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 10px 4px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-dim, #9aa3b2);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: color .12s, border-color .12s;
|
||||
}
|
||||
.topTab:hover { color: var(--text, #e8ecf2); }
|
||||
.topTabActive {
|
||||
color: var(--accent, #4d6bff);
|
||||
border-bottom-color: var(--accent, #4d6bff);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 16px 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.backBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: var(--text, #e8ecf2);
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.backBtn:hover { background: rgba(255,255,255,0.12); }
|
||||
.crumbCurrent { font-weight: 700; color: var(--text, #e8ecf2); }
|
||||
|
||||
.storeHome { overflow-y: auto; padding: 12px 16px 18px; flex: 1; }
|
||||
.sectionLabel {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-weight: 700; font-size: 14px; color: var(--text, #e8ecf2);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.catGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.catTile {
|
||||
display: flex; flex-direction: column; align-items: flex-start; gap: 4px;
|
||||
padding: 16px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.09);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: transform .1s, background .12s, border-color .12s;
|
||||
}
|
||||
.catTile:hover {
|
||||
background: rgba(77,107,255,0.12);
|
||||
border-color: var(--accent, #4d6bff);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.catTileIcon { color: var(--accent, #4d6bff); margin-bottom: 4px; }
|
||||
.catTileLabel { font-weight: 700; font-size: 15px; color: var(--text, #e8ecf2); }
|
||||
.catTileDesc { font-size: 11px; opacity: 0.7; line-height: 1.3; }
|
||||
|
||||
.trendRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.trendCard {
|
||||
position: relative;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||
padding: 14px 8px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.09);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: transform .1s, border-color .12s;
|
||||
}
|
||||
.trendCard:hover { transform: translateY(-2px); border-color: var(--accent, #4d6bff); }
|
||||
.trendIcon {
|
||||
width: 100%; height: 70px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(77,107,255,0.22), rgba(54,213,122,0.16));
|
||||
border-radius: 8px;
|
||||
color: var(--text, #e8ecf2);
|
||||
}
|
||||
.trendName { font-size: 12px; font-weight: 600; text-align: center; color: var(--text, #e8ecf2); }
|
||||
.freeBadge {
|
||||
position: absolute; top: 8px; right: 8px;
|
||||
font-size: 9px; font-weight: 800; letter-spacing: 0.5px;
|
||||
color: #36d57a;
|
||||
background: rgba(54,213,122,0.14);
|
||||
padding: 2px 6px; border-radius: 6px;
|
||||
}
|
||||
|
||||
.soon {
|
||||
flex: 1;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 10px; padding: 40px;
|
||||
color: var(--text-dim, #9aa3b2); text-align: center;
|
||||
}
|
||||
.soonTitle { font-size: 18px; font-weight: 700; color: var(--text, #e8ecf2); }
|
||||
.soonText { font-size: 13px; max-width: 360px; opacity: 0.75; }
|
||||
|
||||
.tips {
|
||||
overflow-y: auto; padding: 16px 22px; flex: 1;
|
||||
color: var(--text, #e8ecf2); line-height: 1.55;
|
||||
}
|
||||
.tips h3 { margin: 4px 0 12px; font-size: 17px; }
|
||||
.tips ul { margin: 0 0 14px; padding-left: 18px; }
|
||||
.tips li { margin-bottom: 9px; font-size: 13px; }
|
||||
.tips b { color: var(--accent, #6f8bff); }
|
||||
|
||||
@ -141,7 +141,9 @@ const Dropdown = ({ trigger, children }) => {
|
||||
|
||||
const TABS = [
|
||||
{ id: 'home', label: 'Главная', iconName: 'home' },
|
||||
{ id: 'model', label: 'Модель', iconName: 'wrench' },
|
||||
// Вкладка-редактор СВОИХ воксельных моделей (создание ассета).
|
||||
// Каталог готовых моделей/механик теперь в Toolbox (кнопка на «Главной»).
|
||||
{ id: 'model', label: 'Редактор моделей', iconName: 'wrench' },
|
||||
{ id: 'test', label: 'Игра', iconName: 'gamepad' },
|
||||
{ id: 'view', label: 'Вид', iconName: 'eye' },
|
||||
];
|
||||
@ -329,9 +331,9 @@ const TopRibbon = (props) => {
|
||||
title="Параметрическая фигура (куб/сфера/...)"
|
||||
/>
|
||||
<RibbonBtn
|
||||
iconName="trees" label="Модель"
|
||||
active={activeTool === 'model'}
|
||||
onClick={() => onToolChange('model')}
|
||||
iconName="box" label="Toolbox"
|
||||
onClick={onOpenStandardModels}
|
||||
title="Библиотека: 3D-объекты, готовые механики, эффекты (как Creator Store)"
|
||||
/>
|
||||
<RibbonBtn
|
||||
iconName="image" label="Интерфейс"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user