studio/src/editor/SkinShopOverlay.jsx
МИН 42be04def9
Some checks failed
CI / Lint (pull_request) Failing after 1m10s
CI / Build (pull_request) Successful in 2m2s
CI / Secret scan (pull_request) Successful in 2m36s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat(week4): модальные сцены, кастомные скины игрока и вики-гайды по 4 играм
Задача 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>
2026-05-30 00:50:56 +03:00

295 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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)',
};
}