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>
295 lines
16 KiB
JavaScript
295 lines
16 KiB
JavaScript
/**
|
||
* 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 = (<><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>);
|
||
}
|
||
|
||
// Монета-рублик (для баланса/цены).
|
||
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>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
style={{
|
||
position: 'absolute', inset: 0, zIndex: 55,
|
||
background: 'rgba(6, 9, 20, 0.72)',
|
||
backdropFilter: 'blur(4px)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
}}
|
||
onClick={close}
|
||
>
|
||
<div
|
||
onClick={(e) => 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',
|
||
}}
|
||
>
|
||
{/* Шапка */}
|
||
<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.18), transparent)',
|
||
}}>
|
||
<div style={{ fontSize: 22, fontWeight: 900, color: '#fff', letterSpacing: 0.3 }}>
|
||
Магазин скинов
|
||
</div>
|
||
<div style={{ flex: 1 }} />
|
||
{/* Баланс */}
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 6,
|
||
background: 'rgba(255, 210, 74, 0.14)',
|
||
border: '1px solid rgba(255, 210, 74, 0.4)',
|
||
borderRadius: 999, padding: '6px 14px',
|
||
color: '#ffd24a', fontWeight: 900, fontSize: 16,
|
||
}}>
|
||
<CoinIcon size={18} /> {coins}
|
||
</div>
|
||
{/* Закрыть */}
|
||
<button
|
||
onClick={close}
|
||
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', fontSize: 18, fontWeight: 700, lineHeight: 1,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
}}
|
||
title="Закрыть (B / Esc)"
|
||
>×</button>
|
||
</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',
|
||
}}
|
||
>{label}</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Сетка карточек */}
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
|
||
gap: 14, padding: 20, overflowY: 'auto',
|
||
}}>
|
||
{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 (
|
||
<div
|
||
key={s.slug}
|
||
onClick={() => 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'; }}
|
||
>
|
||
{/* Превью-плашка с иконкой категории */}
|
||
<div style={{
|
||
height: 96, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
background: `linear-gradient(150deg, ${theme.from}, ${theme.to})`,
|
||
color: 'rgba(255,255,255,0.92)',
|
||
}}>
|
||
<CatGlyph cat={s.category || 'human'} size={50} />
|
||
</div>
|
||
{/* Бейдж активного/купленного */}
|
||
{isActive && (
|
||
<div style={badgeStyle('#22ff88', '#04361b')}>Надет</div>
|
||
)}
|
||
{!isActive && owned && (
|
||
<div style={badgeStyle('#ffd24a', '#5a3a00')}>Куплено</div>
|
||
)}
|
||
{/* Низ карточки: имя + цена/статус */}
|
||
<div style={{ padding: '10px 12px' }}>
|
||
<div style={{
|
||
color: '#fff', fontWeight: 800, fontSize: 14,
|
||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||
}}>{s.name || s.slug}</div>
|
||
<div style={{ marginTop: 6, minHeight: 22 }}>
|
||
{isActive ? (
|
||
<span style={{ color: '#22ff88', fontWeight: 800, fontSize: 13 }}>Активен</span>
|
||
) : owned ? (
|
||
<span style={{ color: '#9fb0d8', fontWeight: 700, fontSize: 13 }}>Нажми, чтобы надеть</span>
|
||
) : price === 0 ? (
|
||
<span style={{ color: '#7fe0a0', fontWeight: 800, fontSize: 13 }}>Бесплатно</span>
|
||
) : (
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||
color: canAfford ? '#ffd24a' : '#ff7a7a', fontWeight: 900, fontSize: 14,
|
||
}}>
|
||
<CoinIcon size={15} /> {price}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{skins.length === 0 && (
|
||
<div style={{ color: '#8a93b4', gridColumn: '1 / -1', textAlign: 'center', padding: 30 }}>
|
||
В этой категории пока нет скинов
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Подвал-подсказка */}
|
||
<div style={{
|
||
padding: '10px 20px', borderTop: '1px solid rgba(255,255,255,0.08)',
|
||
color: '#6b76a0', fontSize: 12, textAlign: 'center',
|
||
}}>
|
||
Нажми <b style={{ color: '#aab4d4' }}>B</b> или <b style={{ color: '#aab4d4' }}>Esc</b>, чтобы закрыть
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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)',
|
||
};
|
||
}
|