Some checks failed
Задача 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>
714 lines
41 KiB
JavaScript
714 lines
41 KiB
JavaScript
/**
|
||
* 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>
|
||
);
|
||
}
|