/**
* 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 ();
}
// ---- Монета-рублик (дубль из 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 (
);
})}