/** * SkinShopOverlay — встроенный магазин скинов игрока (задача 07). * * Готовый GUI-кит: полноэкранная витрина карточек скинов. Открывается * клавишей B в Play или через game.player.openSkinShop(). Логика покупки * (списание локальных рубликов проекта, unlock, setSkin) живёт в GameRuntime; * этот компонент только рендерит состояние и шлёт намерение «купить/надеть». * * Подписка на состояние — rAF-поллинг scene.getSkinShopState() (как ModalOverlay): * { open, rev, data: { all:[{slug,name,kind,category,price}], unlocked:[slug], * current, coins, shopVisible } } * * Превью скина — цветная плашка по категории + крупная самописная SVG-иконка * (правило проекта: без эмодзи в UI). Категории: human/animal/food/vehicle/robot. */ import React, { useEffect, useState, useMemo } from 'react'; // Палитра градиентов по категории — чтобы витрина была живой и читаемой. 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']; // Самописные SVG-иконки категорий (viewBox 24×24, обводка currentColor). 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}); } // Монета-рублик (для баланса/цены). function CoinIcon({ size = 16 }) { return ( ); } export default function SkinShopOverlay({ scene }) { const [snap, setSnap] = useState(null); const [cat, setCat] = useState('all'); // rAF-поллинг состояния магазина из сцены. useEffect(() => { if (!scene?.getSkinShopState) return; let cancelled = false; let lastRev = -1; const tick = () => { if (cancelled) return; const s = scene.getSkinShopState?.(); if (s && s.rev !== lastRev) { lastRev = s.rev; setSnap({ open: s.open, data: s.data, buyResult: s.buyResult, }); } else if (!s && lastRev !== -1) { lastRev = -1; setSnap(null); } requestAnimationFrame(tick); }; tick(); return () => { cancelled = true; }; }, [scene]); const data = snap?.data || null; // Список скинов с категориями (фильтрованный). const skins = useMemo(() => { const all = (data?.all) || []; if (cat === 'all') return all; return all.filter(s => (s.category || 'human') === cat); }, [data, cat]); // Какие категории реально есть — для табов. const cats = useMemo(() => { const present = new Set((data?.all || []).map(s => s.category || 'human')); return CAT_ORDER.filter(c => c === 'all' || present.has(c)); }, [data]); if (!snap || !snap.open || !data) return null; const unlocked = new Set(data.unlocked || []); const current = data.current; const coins = data.coins || 0; const close = () => { try { scene._closeSkinShop?.(); } catch (e) {} }; const onCardClick = (s) => { const owned = unlocked.has(s.slug); const price = s.price || 0; if (!owned && coins < price) return; // не хватает — карточка покажет это try { scene.requestBuySkin?.(s.slug, price); } catch (e) {} }; return (
e.stopPropagation()} style={{ width: 'min(880px, 92vw)', maxHeight: '86vh', background: 'linear-gradient(160deg, #161b2e 0%, #0c1020 100%)', border: '2px solid #2b3a66', borderRadius: 20, boxShadow: '0 24px 60px rgba(0,0,0,0.55)', display: 'flex', flexDirection: 'column', overflow: 'hidden', }} > {/* Шапка */}
Магазин скинов
{/* Баланс */}
{coins}
{/* Закрыть */}
{/* Табы категорий */}
{cats.map(c => { const active = c === cat; const label = c === 'all' ? 'Все' : (CAT_THEME[c]?.label || c); return ( ); })}
{/* Сетка карточек */}
{skins.map(s => { const theme = CAT_THEME[s.category] || CAT_THEME.human; const owned = unlocked.has(s.slug); const isActive = current === s.slug; const price = s.price || 0; const canAfford = owned || coins >= price; return (
onCardClick(s)} style={{ borderRadius: 16, overflow: 'hidden', cursor: canAfford ? 'pointer' : 'not-allowed', border: isActive ? '3px solid #22ff88' : '2px solid rgba(255,255,255,0.10)', background: 'rgba(255,255,255,0.04)', opacity: canAfford ? 1 : 0.55, transition: 'transform 0.1s, border-color 0.15s', position: 'relative', }} onMouseEnter={(e) => { if (canAfford) e.currentTarget.style.transform = 'translateY(-3px)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'none'; }} > {/* Превью-плашка с иконкой категории */}
{/* Бейдж активного/купленного */} {isActive && (
Надет
)} {!isActive && owned && (
Куплено
)} {/* Низ карточки: имя + цена/статус */}
{s.name || s.slug}
{isActive ? ( Активен ) : owned ? ( Нажми, чтобы надеть ) : price === 0 ? ( Бесплатно ) : ( {price} )}
); })} {skins.length === 0 && (
В этой категории пока нет скинов
)}
{/* Подвал-подсказка */}
Нажми B или Esc, чтобы закрыть
); } function badgeStyle(bg, fg) { return { position: 'absolute', top: 8, right: 8, background: bg, color: fg, fontSize: 11, fontWeight: 900, padding: '3px 8px', borderRadius: 999, boxShadow: '0 2px 6px rgba(0,0,0,0.4)', }; }