studio/src/editor/SkinManagerModal.jsx
МИН 42be04def9
Some checks failed
CI / Lint (pull_request) Failing after 1m10s
CI / Build (pull_request) Successful in 2m2s
CI / Secret scan (pull_request) Successful in 2m36s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat(week4): модальные сцены, кастомные скины игрока и вики-гайды по 4 играм
Задача 04 — модальные сцены (затемнение + GUI/3D-анимация + блок ввода):
- engine/ModalManager.js (state-машина, fade, spotlight-проекция, HighlightLayer)
- editor/ModalOverlay.jsx (CSS-overlay + mask-image для spotlight)
- PlayerController.setInputBlocked/setCameraFrozen/captureCameraState/focusOnTarget
- game.modal.open/close/update/isOpen/onClose + пресеты bossIntro/lootbox/dialog/confirmation
- Фиксы ядра: клик GUI-кнопок (realId↔localId), modalOpened через globalEvent,
  guard от двойного срабатывания колбэков, кнопка ✓ на последней строке диалога

Задача 07 — кастомные скины игрока + магазин:
- non-humanoid скины (любая 3D-модель): загрузчик, процедурная анимация,
  настраиваемый AABB, центрирование через pivot-node
- PlayerController.reloadSkin (смена скина в Play)
- game.player.setSkin/unlockSkin/getAvailableSkins/onSkinChange/openSkinShop + локальная валюта
- editor/SkinShopOverlay.jsx (встроенный магазин, клавиша B) + SkinManagerModal.jsx (вкладка «Скины»)
- сериализация scene.skins, кнопка «Скины» в тулбаре

Вики — категория «Разбор готовых игр»:
- docsGames.js: группа g5 + 4 карточки (Двор/Витрина GUI/Сундук/Парк)
- docsLessons.jsx: 4 подробные статьи-урока для детей
- «Открыть мою копию» создаёт копию проекта (оригинал не трогается)

Адаптивная ширина кнопки billboard под длинный текст.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:50:56 +03:00

714 lines
41 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.

/**
* SkinManagerModal — модал управления скинами проекта (задача 07).
*
* Здесь автор игры настраивает, какие скины доступны игрокам:
* - выбирает СТАРТОВЫЙ скин (default) — с него игрок начинает;
* - отмечает, какие скины разблокированы по умолчанию (unlocked) —
* их игрок носит бесплатно без покупки в магазине;
* - включает/выключает встроенный магазин скинов (клавиша B в Play);
* - задаёт стартовый баланс рубликов игрока (coins);
* - добавляет СВОИ скины из .glb-файлов (customGlbs) — каждый со своим
* масштабом (scale) и высотой бёдер (hipHeight) для правильной посадки.
*
* Итог сохраняется в конфиг проекта:
* { default, unlocked:[slug], shopVisible, coins, customGlbs:[{...}] }
*
* Стиль повторяет SkinShopOverlay/GameSettingsModal (тёмная тема, акцент
* #3b6cff, шрифт Roboto Condensed). Иконки — самописные inline-SVG
* (правило проекта: НИКОГДА не эмодзи в UI).
*
* Props:
* open — bool, показывать ли модал (false → null);
* onClose() — закрыть без сохранения;
* onSave(cfg) — сохранить собранный конфиг;
* allSkins — [{ id, slug, name, kind, category, price }] — полный манифест;
* skinsConfig — текущая конфигурация проекта (может быть null).
*/
import React, { useState, useEffect, useMemo, useRef } from 'react';
// ---- Палитра по категориям (дубль из SkinShopOverlay, чтобы не связывать модули) ----
const CAT_THEME = {
human: { from: '#3b6cff', to: '#1e2da5', label: 'Люди' },
animal: { from: '#46b34a', to: '#1f6b2a', label: 'Животные' },
food: { from: '#ff8a3d', to: '#c2410c', label: 'Еда' },
vehicle: { from: '#9b59ff', to: '#5b21b6', label: 'Транспорт' },
robot: { from: '#22c6d6', to: '#0e7490', label: 'Роботы' },
custom: { from: '#fbbf24', to: '#b45309', label: 'Свои' },
};
const CAT_ORDER = ['all', 'human', 'animal', 'food', 'vehicle', 'robot', 'custom'];
const MAX_GLB_BYTES = 4 * 1024 * 1024; // 4 МБ — потолок на кастомный .glb
// ---- Самописные SVG-иконки категорий (дубль из SkinShopOverlay) ----
function CatGlyph({ cat, size = 46 }) {
const st = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' };
let body;
switch (cat) {
case 'human':
body = (<><circle cx="12" cy="7" r="3.2" {...st} /><path d="M5 21c0-4 3.2-7 7-7s7 3 7 7" {...st} /></>);
break;
case 'animal':
body = (<><path d="M5 6l2.5 3M19 6l-2.5 3" {...st} /><circle cx="12" cy="13" r="7" {...st} /><circle cx="9.5" cy="12" r="0.9" fill="currentColor" stroke="none" /><circle cx="14.5" cy="12" r="0.9" fill="currentColor" stroke="none" /><path d="M10.5 16c1 0.8 2 0.8 3 0" {...st} /></>);
break;
case 'food':
body = (<><circle cx="12" cy="12" r="8" {...st} /><circle cx="12" cy="12" r="2.6" {...st} /><path d="M7 8.5l0.5 1M16.5 9l-0.7 0.9M9 16l0.6-1M15.5 15.5l-0.7-0.9" {...st} /></>);
break;
case 'vehicle':
body = (<><path d="M3 14l1.5-4.5A2 2 0 0 1 6.4 8h11.2a2 2 0 0 1 1.9 1.5L21 14v3h-2" {...st} /><path d="M3 14v3h2" {...st} /><circle cx="7.5" cy="17" r="1.8" {...st} /><circle cx="16.5" cy="17" r="1.8" {...st} /></>);
break;
case 'robot':
body = (<><rect x="6" y="8" width="12" height="10" rx="2" {...st} /><path d="M12 8V5M9 5h6" {...st} /><circle cx="9.5" cy="13" r="1" fill="currentColor" stroke="none" /><circle cx="14.5" cy="13" r="1" fill="currentColor" stroke="none" /></>);
break;
default: // custom — звезда
body = (<path d="M12 4l2.2 4.8L19 9.4l-3.6 3.3 1 5-4.4-2.5L7.6 17.7l1-5L5 9.4l4.8-0.6z" {...st} />);
}
return (<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'block' }}>{body}</svg>);
}
// ---- Монета-рублик (дубль из SkinShopOverlay) ----
function CoinIcon({ size = 16 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'inline-block', verticalAlign: 'middle' }}>
<circle cx="12" cy="12" r="9" fill="#ffd24a" stroke="#a86b00" strokeWidth="1.6" />
<text x="12" y="16" textAnchor="middle" fontSize="11" fontWeight="900" fill="#7a4d00"></text>
</svg>
);
}
// ---- Мелкие самописные иконки управления ----
function XIcon({ size = 14 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'block' }}>
<path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function CheckIcon({ size = 12 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'block' }}>
<path d="M5 12.5l4.5 4.5L19 6.5" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function PlusIcon({ size = 14 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'block' }}>
<path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" />
</svg>
);
}
const DEFAULT_FALLBACK = 'skin_bacon-hair';
export default function SkinManagerModal({ open, onClose, onSave, allSkins, skinsConfig }) {
const manifest = useMemo(() => Array.isArray(allSkins) ? allSkins : [], [allSkins]);
// ----- Локальный state конфигурации (заполняется при открытии) -----
const [defaultSlug, setDefaultSlug] = useState(DEFAULT_FALLBACK);
const [unlocked, setUnlocked] = useState([]); // массив slug
const [shopVisible, setShopVisible] = useState(true);
const [coins, setCoins] = useState(0);
const [customGlbs, setCustomGlbs] = useState([]); // [{slug,name,kind,category,scale,hipHeight,dataUrl,price}]
// ----- UI-state -----
const [cat, setCat] = useState('all');
const [error, setError] = useState('');
// ----- Форма добавления кастомного скина -----
const [draftName, setDraftName] = useState('');
const [draftScale, setDraftScale] = useState(1.5);
const [draftHip, setDraftHip] = useState(0.4);
const [draftDataUrl, setDraftDataUrl] = useState(''); // dataURL выбранного .glb (ещё не добавлен)
const [draftFileName, setDraftFileName] = useState(''); // имя файла для подсказки
const fileInputRef = useRef(null);
// Заполняем поля ОДИН РАЗ при открытии (паттерн как в GameSettingsModal:
// зависимость только от [open], чтобы новый литерал-объект родителя
// не сбрасывал состояние при каждом ре-рендере).
useEffect(() => {
if (!open) return;
const cfg = skinsConfig || {};
// дефолтный скин: из конфига → иначе первый человекоподобный → иначе фолбэк-slug
let def = cfg.default;
if (!def) {
const firstHuman = manifest.find(s => (s.category || 'human') === 'human');
def = firstHuman ? firstHuman.slug : DEFAULT_FALLBACK;
}
setDefaultSlug(def);
setUnlocked(Array.isArray(cfg.unlocked) ? [...cfg.unlocked] : []);
setShopVisible(cfg.shopVisible !== undefined ? !!cfg.shopVisible : true);
setCoins(typeof cfg.coins === 'number' ? cfg.coins : 0);
setCustomGlbs(Array.isArray(cfg.customGlbs) ? cfg.customGlbs.map(g => ({ ...g })) : []);
// сброс UI/формы
setCat('all');
setError('');
setDraftName('');
setDraftScale(1.5);
setDraftHip(0.4);
setDraftDataUrl('');
setDraftFileName('');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Объединённый список скинов: манифест + кастомные (custom-категория).
const combined = useMemo(() => {
const customCards = customGlbs.map(g => ({
id: 'skin_' + g.slug,
slug: g.slug,
name: g.name,
kind: g.kind || 'non-humanoid-mesh',
category: 'custom',
price: g.price || 0,
__custom: true,
}));
return [...manifest, ...customCards];
}, [manifest, customGlbs]);
// Видимые категории (только присутствующие) для табов-фильтра.
const cats = useMemo(() => {
const present = new Set(combined.map(s => s.category || 'human'));
return CAT_ORDER.filter(c => c === 'all' || present.has(c));
}, [combined]);
// Фильтрованная сетка.
const visible = useMemo(() => {
if (cat === 'all') return combined;
return combined.filter(s => (s.category || 'human') === cat);
}, [combined, cat]);
if (!open) return null;
const unlockedSet = new Set(unlocked);
// Выбрать карточку как стартовый скин.
const pickDefault = (slug) => {
setDefaultSlug(slug);
};
// Переключить «разблокирован по умолчанию» (стартовый — всегда включён).
const toggleUnlock = (slug) => {
if (slug === defaultSlug) return; // стартовый нельзя снять — он всегда unlocked
setUnlocked(prev => prev.includes(slug)
? prev.filter(x => x !== slug)
: [...prev, slug]);
};
// Выбор .glb → читаем как dataURL, проверяем размер.
const handleFile = (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
if (!/\.glb$/i.test(file.name)) {
setError('Нужен файл с расширением .glb');
return;
}
if (file.size > MAX_GLB_BYTES) {
setError('Файл слишком большой (макс. 4 МБ)');
return;
}
const reader = new FileReader();
reader.onload = (ev) => {
setDraftDataUrl(ev.target.result);
setDraftFileName(file.name);
// предзаполним имя из имени файла, если поле пустое
setDraftName(prev => prev || file.name.replace(/\.glb$/i, ''));
setError('');
};
reader.onerror = () => setError('Не удалось прочитать файл');
reader.readAsDataURL(file);
};
// Подтвердить добавление кастомного скина в список.
const addCustom = () => {
if (!draftDataUrl) {
setError('Сначала выбери .glb-файл');
return;
}
const name = draftName.trim();
if (!name) {
setError('Введи имя скина');
return;
}
const entry = {
slug: 'custom-' + Date.now(),
name,
kind: 'non-humanoid-mesh',
category: 'custom',
scale: Math.max(0.5, Math.min(3, Number(draftScale) || 1.5)),
hipHeight: Math.max(0, Math.min(1, Number(draftHip) || 0.4)),
dataUrl: draftDataUrl,
price: 0,
};
setCustomGlbs(prev => [...prev, entry]);
// кастомные по умолчанию разблокированы (иначе игрок не сможет их надеть бесплатно)
setUnlocked(prev => prev.includes(entry.slug) ? prev : [...prev, entry.slug]);
// сброс формы
setDraftName('');
setDraftScale(1.5);
setDraftHip(0.4);
setDraftDataUrl('');
setDraftFileName('');
setError('');
if (fileInputRef.current) fileInputRef.current.value = '';
};
// Удалить кастомный скин.
const removeCustom = (slug) => {
setCustomGlbs(prev => prev.filter(g => g.slug !== slug));
setUnlocked(prev => prev.filter(x => x !== slug));
if (defaultSlug === slug) {
// если удалили стартовый — откатываемся на первый человекоподобный/фолбэк
const firstHuman = manifest.find(s => (s.category || 'human') === 'human');
setDefaultSlug(firstHuman ? firstHuman.slug : DEFAULT_FALLBACK);
}
};
// Собрать и сохранить.
const handleSave = () => {
// гарантируем, что стартовый скин всегда в unlocked
const finalUnlocked = unlocked.includes(defaultSlug)
? [...unlocked]
: [...unlocked, defaultSlug];
const config = {
default: defaultSlug,
unlocked: finalUnlocked,
shopVisible: !!shopVisible,
coins: Math.max(0, Math.min(100000, Number(coins) || 0)),
customGlbs: customGlbs.map(g => ({ ...g })),
};
onSave && onSave(config);
onClose && onClose();
};
// ====================== РЕНДЕР ======================
return (
<div
style={{
position: 'fixed', inset: 0, zIndex: 10000,
background: 'rgba(6, 9, 20, 0.78)',
backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 20,
fontFamily: '"Roboto Condensed", system-ui, -apple-system, sans-serif',
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose && onClose(); }}
>
<div
style={{
width: 'min(960px, 94vw)', maxHeight: '92vh',
background: 'linear-gradient(160deg, #161b2e 0%, #0c1020 100%)',
border: '2px solid #2b3a66', borderRadius: 20,
boxShadow: '0 28px 70px rgba(0,0,0,0.6)',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
color: '#e8ecf8',
}}
>
{/* ---------- Шапка ---------- */}
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '16px 20px',
borderBottom: '1px solid rgba(255,255,255,0.08)',
background: 'linear-gradient(90deg, rgba(59,108,255,0.20), transparent)',
}}>
<div style={{ fontSize: 22, fontWeight: 900, color: '#fff', letterSpacing: 0.3 }}>
Скины игрока
</div>
<div style={{ flex: 1 }} />
<button
onClick={onClose}
title="Закрыть"
style={{
width: 34, height: 34, borderRadius: 10, cursor: 'pointer',
background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.16)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
><XIcon size={16} /></button>
</div>
{/* ---------- Тело (скролл) ---------- */}
<div style={{ display: 'flex', flexDirection: 'column', overflowY: 'auto', flex: 1, minHeight: 0 }}>
{/* Подсказка */}
<div style={{
padding: '12px 20px 0', color: '#8a93b4', fontSize: 13, lineHeight: 1.45,
}}>
Кликни по карточке, чтобы выбрать <b style={{ color: '#22ff88' }}>стартовый скин</b>.
Галочкой отметь скины, которые игрок носит бесплатно с самого начала.
Остальные он покупает в магазине за рублики.
</div>
{/* Табы категорий */}
<div style={{ display: 'flex', gap: 8, padding: '12px 20px 4px', flexWrap: 'wrap' }}>
{cats.map(c => {
const active = c === cat;
const label = c === 'all' ? 'Все' : (CAT_THEME[c]?.label || c);
return (
<button
key={c}
onClick={() => setCat(c)}
style={{
padding: '6px 14px', borderRadius: 999, cursor: 'pointer',
fontSize: 13, fontWeight: 800,
background: active ? 'linear-gradient(135deg, #3b6cff, #1e2da5)' : 'rgba(255,255,255,0.06)',
border: active ? '1px solid #6b8cff' : '1px solid rgba(255,255,255,0.12)',
color: active ? '#fff' : '#aab4d4',
fontFamily: 'inherit',
}}
>{label}</button>
);
})}
</div>
{/* Сетка карточек */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 12, padding: 20,
}}>
{visible.map(s => {
const theme = CAT_THEME[s.category] || CAT_THEME.human;
const isDefault = defaultSlug === s.slug;
const isUnlocked = isDefault || unlockedSet.has(s.slug);
const price = s.price || 0;
const isHuman = (s.kind || 'r15') === 'r15';
return (
<div
key={s.slug}
onClick={() => pickDefault(s.slug)}
style={{
borderRadius: 14, overflow: 'hidden', cursor: 'pointer',
border: isDefault ? '3px solid #22ff88' : '2px solid rgba(255,255,255,0.10)',
background: 'rgba(255,255,255,0.04)',
transition: 'transform 0.1s, border-color 0.15s',
position: 'relative',
display: 'flex', flexDirection: 'column',
}}
onMouseEnter={(e) => { if (!isDefault) e.currentTarget.style.transform = 'translateY(-3px)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'none'; }}
>
{/* Превью-плашка */}
<div style={{
height: 84, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: `linear-gradient(150deg, ${theme.from}, ${theme.to})`,
color: 'rgba(255,255,255,0.92)', position: 'relative',
}}>
<CatGlyph cat={s.category || 'human'} size={44} />
{/* Бейдж «Старт» */}
{isDefault && (
<div style={{
position: 'absolute', top: 6, right: 6,
background: '#22ff88', color: '#04361b',
fontSize: 10, fontWeight: 900, padding: '2px 7px', borderRadius: 999,
boxShadow: '0 2px 6px rgba(0,0,0,0.4)',
}}>Старт</div>
)}
{/* Бейдж категории для не-человекоподобных */}
{!isHuman && !isDefault && (
<div style={{
position: 'absolute', top: 6, left: 6,
background: 'rgba(0,0,0,0.35)', color: '#fff',
fontSize: 10, fontWeight: 800, padding: '2px 7px', borderRadius: 999,
}}>{CAT_THEME[s.category]?.label || s.category}</div>
)}
</div>
{/* Низ карточки */}
<div style={{ padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }}>
<div style={{
color: '#fff', fontWeight: 800, fontSize: 13,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{s.name || s.slug}</div>
{/* Цена (если > 0) */}
{price > 0 && (
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
color: '#ffd24a', fontWeight: 900, fontSize: 13,
}}>
<CoinIcon size={14} /> {price}
</div>
)}
{/* Тогл «разблокирован по умолчанию» */}
<label
onClick={(e) => { e.stopPropagation(); }}
style={{
display: 'flex', alignItems: 'center', gap: 6,
cursor: isDefault ? 'default' : 'pointer',
marginTop: 'auto',
}}
>
<span
onClick={() => { if (!isDefault) toggleUnlock(s.slug); }}
style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: isUnlocked ? (isDefault ? '#1f6b2a' : '#3b6cff') : 'transparent',
border: isUnlocked ? '1px solid transparent' : '1.5px solid rgba(255,255,255,0.3)',
color: '#fff',
opacity: isDefault ? 0.7 : 1,
}}
>
{isUnlocked && <CheckIcon size={11} />}
</span>
<span style={{
fontSize: 11, fontWeight: 600,
color: isDefault ? '#7fe0a0' : (isUnlocked ? '#aab4d4' : '#6b76a0'),
}}>
{isDefault ? 'всегда включён' : 'разблокирован'}
</span>
</label>
</div>
</div>
);
})}
{visible.length === 0 && (
<div style={{ color: '#8a93b4', gridColumn: '1 / -1', textAlign: 'center', padding: 30 }}>
В этой категории пока нет скинов
</div>
)}
</div>
{/* ---------- Настройки магазина ---------- */}
<div style={{
margin: '0 20px 16px', padding: 16, borderRadius: 14,
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)',
display: 'flex', flexDirection: 'column', gap: 14,
}}>
<div style={{ fontSize: 12, fontWeight: 900, color: '#9fb0d8', textTransform: 'uppercase', letterSpacing: 0.8 }}>
Магазин и экономика
</div>
{/* Чекбокс магазина */}
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}>
<span
onClick={() => setShopVisible(v => !v)}
style={{
width: 18, height: 18, borderRadius: 5, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: shopVisible ? '#3b6cff' : 'transparent',
border: shopVisible ? '1px solid transparent' : '1.5px solid rgba(255,255,255,0.3)',
color: '#fff',
}}
>
{shopVisible && <CheckIcon size={12} />}
</span>
<span>
<span style={{ fontWeight: 800, color: '#fff', fontSize: 14 }}>Встроенный магазин скинов</span>
<span style={{ display: 'block', fontSize: 12, color: '#8a93b4', marginTop: 2 }}>
Игрок открывает его клавишей <b style={{ color: '#aab4d4' }}>B</b> прямо в игре
</span>
</span>
</label>
{/* Стартовые рублики */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 11, fontWeight: 800, color: '#9fb0d8', textTransform: 'uppercase', letterSpacing: 0.8 }}>
Стартовые рублики игрока
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<CoinIcon size={18} />
<input
type="number"
min={0}
max={100000}
step={1}
value={coins}
onChange={(e) => {
const v = e.target.value === '' ? 0 : Number(e.target.value);
setCoins(Math.max(0, Math.min(100000, isNaN(v) ? 0 : v)));
}}
style={{
width: 160,
background: '#0c1020', border: '1.5px solid #2b3a66', borderRadius: 10,
padding: '8px 12px', color: '#fff', fontSize: 14, fontFamily: 'inherit', outline: 'none',
}}
/>
<span style={{ fontSize: 12, color: '#6b76a0' }}>от 0 до 100000</span>
</div>
</div>
</div>
{/* ---------- Свои скины (.glb) ---------- */}
<div style={{
margin: '0 20px 20px', padding: 16, borderRadius: 14,
background: 'rgba(251,191,36,0.06)', border: '1px solid rgba(251,191,36,0.22)',
display: 'flex', flexDirection: 'column', gap: 14,
}}>
<div style={{ fontSize: 12, fontWeight: 900, color: '#fbbf24', textTransform: 'uppercase', letterSpacing: 0.8 }}>
Свои скины
</div>
{/* Список уже добавленных */}
{customGlbs.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{customGlbs.map(g => (
<div key={g.slug} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 12px', borderRadius: 10,
background: 'rgba(0,0,0,0.25)', border: '1px solid rgba(255,255,255,0.08)',
}}>
<div style={{ color: '#fbbf24', display: 'flex' }}><CatGlyph cat="custom" size={22} /></div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
color: '#fff', fontWeight: 800, fontSize: 13,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{g.name}</div>
<div style={{ fontSize: 11, color: '#8a93b4' }}>
масштаб {g.scale}× · высота бёдер {g.hipHeight}
</div>
</div>
<button
onClick={() => removeCustom(g.slug)}
title="Удалить"
style={{
width: 30, height: 30, borderRadius: 8, cursor: 'pointer',
background: 'rgba(239,68,68,0.16)', border: '1px solid rgba(239,68,68,0.5)',
color: '#ff7a7a', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
><XIcon size={14} /></button>
</div>
))}
</div>
)}
{/* Форма добавления */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input
ref={fileInputRef}
type="file"
accept=".glb"
style={{ display: 'none' }}
onChange={handleFile}
/>
<button
onClick={() => fileInputRef.current && fileInputRef.current.click()}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8, alignSelf: 'flex-start',
padding: '9px 16px', borderRadius: 10, cursor: 'pointer',
background: draftDataUrl ? 'rgba(34,255,136,0.14)' : 'rgba(255,255,255,0.06)',
border: draftDataUrl ? '1px solid rgba(34,255,136,0.5)' : '1px solid rgba(255,255,255,0.16)',
color: draftDataUrl ? '#7fe0a0' : '#e8ecf8',
fontSize: 13, fontWeight: 800, fontFamily: 'inherit',
}}
>
<PlusIcon size={14} />
{draftDataUrl ? `Файл выбран: ${draftFileName}` : 'Выбрать свой скин (.glb)'}
</button>
{/* Поля параметров появляются после выбора файла */}
{draftDataUrl && (
<div style={{
display: 'flex', flexDirection: 'column', gap: 12,
padding: 12, borderRadius: 10,
background: 'rgba(0,0,0,0.25)', border: '1px solid rgba(255,255,255,0.08)',
}}>
{/* Имя */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 11, fontWeight: 800, color: '#9fb0d8', textTransform: 'uppercase', letterSpacing: 0.8 }}>
Имя скина
</div>
<input
type="text"
value={draftName}
onChange={(e) => setDraftName(e.target.value.slice(0, 60))}
placeholder="Например, Мой дракон"
style={{
background: '#0c1020', border: '1.5px solid #2b3a66', borderRadius: 10,
padding: '8px 12px', color: '#fff', fontSize: 14, fontFamily: 'inherit', outline: 'none',
}}
/>
</div>
{/* Масштаб */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 11, fontWeight: 800, color: '#9fb0d8', textTransform: 'uppercase', letterSpacing: 0.8 }}>
Масштаб модели
</span>
<span style={{ fontSize: 12, color: '#ffd24a', fontWeight: 800 }}>{Number(draftScale).toFixed(2)}×</span>
</div>
<input
type="range" min={0.5} max={3} step={0.1}
value={draftScale}
onChange={(e) => setDraftScale(Number(e.target.value))}
style={{ width: '100%', accentColor: '#3b6cff', cursor: 'pointer' }}
/>
</div>
{/* Высота бёдер */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 11, fontWeight: 800, color: '#9fb0d8', textTransform: 'uppercase', letterSpacing: 0.8 }}>
Высота бёдер
</span>
<span style={{ fontSize: 12, color: '#ffd24a', fontWeight: 800 }}>{Number(draftHip).toFixed(2)}</span>
</div>
<input
type="range" min={0} max={1} step={0.05}
value={draftHip}
onChange={(e) => setDraftHip(Number(e.target.value))}
style={{ width: '100%', accentColor: '#3b6cff', cursor: 'pointer' }}
/>
<div style={{ fontSize: 11, color: '#6b76a0' }}>
Насколько приподнять модель над землёй, чтобы ноги не уходили в пол.
</div>
</div>
<button
onClick={addCustom}
style={{
alignSelf: 'flex-start',
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '9px 18px', borderRadius: 10, cursor: 'pointer',
background: 'linear-gradient(135deg, #fbbf24, #b45309)',
border: '1px solid transparent', color: '#1a1205',
fontSize: 14, fontWeight: 900, fontFamily: 'inherit',
}}
>
<PlusIcon size={14} /> Добавить скин
</button>
</div>
)}
</div>
</div>
{/* Ошибка */}
{error && (
<div style={{
margin: '0 20px 16px', padding: '10px 14px', borderRadius: 10,
background: 'rgba(239,68,68,0.16)', border: '1px solid rgba(239,68,68,0.4)',
color: '#ff9d9d', fontSize: 13, fontWeight: 700,
}}>{error}</div>
)}
</div>
{/* ---------- Подвал ---------- */}
<div style={{
display: 'flex', justifyContent: 'flex-end', gap: 10,
padding: '14px 20px', borderTop: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(0,0,0,0.25)',
}}>
<button
onClick={onClose}
style={{
background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.16)',
borderRadius: 10, color: '#e8ecf8', fontSize: 14, fontWeight: 700,
padding: '10px 20px', cursor: 'pointer', fontFamily: 'inherit',
}}
>Отмена</button>
<button
onClick={handleSave}
style={{
background: 'linear-gradient(135deg, #3b6cff, #1e2da5)', border: '1px solid transparent',
borderRadius: 10, color: '#fff', fontSize: 14, fontWeight: 800,
padding: '10px 24px', cursor: 'pointer', fontFamily: 'inherit',
boxShadow: '0 6px 16px rgba(59,108,255,0.32)',
}}
>Сохранить</button>
</div>
</div>
</div>
);
}