/** * 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 = (<>); break; case 'animal': body = (<>); break; case 'food': body = (<>); break; case 'vehicle': body = (<>); break; case 'robot': body = (<>); break; default: // custom — звезда body = (); } return ({body}); } // ---- Монета-рублик (дубль из SkinShopOverlay) ---- function CoinIcon({ size = 16 }) { return ( ); } // ---- Мелкие самописные иконки управления ---- function XIcon({ size = 14 }) { return ( ); } function CheckIcon({ size = 12 }) { return ( ); } function PlusIcon({ size = 14 }) { return ( ); } 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 (
{ if (e.target === e.currentTarget) onClose && onClose(); }} >
{/* ---------- Шапка ---------- */}
Скины игрока
{/* ---------- Тело (скролл) ---------- */}
{/* Подсказка */}
Кликни по карточке, чтобы выбрать стартовый скин. Галочкой отметь скины, которые игрок носит бесплатно с самого начала. Остальные он покупает в магазине за рублики.
{/* Табы категорий */}
{cats.map(c => { const active = c === cat; const label = c === 'all' ? 'Все' : (CAT_THEME[c]?.label || c); return ( ); })}
{/* Сетка карточек */}
{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 (
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'; }} > {/* Превью-плашка */}
{/* Бейдж «Старт» */} {isDefault && (
Старт
)} {/* Бейдж категории для не-человекоподобных */} {!isHuman && !isDefault && (
{CAT_THEME[s.category]?.label || s.category}
)}
{/* Низ карточки */}
{s.name || s.slug}
{/* Цена (если > 0) */} {price > 0 && (
{price}
)} {/* Тогл «разблокирован по умолчанию» */}
); })} {visible.length === 0 && (
В этой категории пока нет скинов
)}
{/* ---------- Настройки магазина ---------- */}
Магазин и экономика
{/* Чекбокс магазина */} {/* Стартовые рублики */}
Стартовые рублики игрока
{ 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', }} /> от 0 до 100000
{/* ---------- Свои скины (.glb) ---------- */}
Свои скины
{/* Список уже добавленных */} {customGlbs.length > 0 && (
{customGlbs.map(g => (
{g.name}
масштаб {g.scale}× · высота бёдер {g.hipHeight}
))}
)} {/* Форма добавления */}
{/* Поля параметров появляются после выбора файла */} {draftDataUrl && (
{/* Имя */}
Имя скина
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', }} />
{/* Масштаб */}
Масштаб модели {Number(draftScale).toFixed(2)}×
setDraftScale(Number(e.target.value))} style={{ width: '100%', accentColor: '#3b6cff', cursor: 'pointer' }} />
{/* Высота бёдер */}
Высота бёдер {Number(draftHip).toFixed(2)}
setDraftHip(Number(e.target.value))} style={{ width: '100%', accentColor: '#3b6cff', cursor: 'pointer' }} />
Насколько приподнять модель над землёй, чтобы ноги не уходили в пол.
)}
{/* Ошибка */} {error && (
{error}
)}
{/* ---------- Подвал ---------- */}
); }