Some checks failed
PlacementManager + ShopInventoryUi + проводка game.placement.*/inventoryUi.* в worker/GameRuntime/BabylonScene — опубликованные tycoon-игры с расстановкой теперь работают в плеере. + TerrainManager backFaceCulling=false (воксели не просвечивают), cleanup usermodel при Stop, Hotbar скрыт при пустом инвентаре. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
167 lines
7.4 KiB
JavaScript
167 lines
7.4 KiB
JavaScript
import React from 'react';
|
||
import Icon from './Icon';
|
||
|
||
/**
|
||
* Hotbar — панель из 5 слотов инвентаря внизу экрана. Активный слот
|
||
* подсвечен синим градиентом Рублокса. Клик и цифры 1-5 переключают.
|
||
*
|
||
* Видимая в Play. В редакторе скрыта.
|
||
*
|
||
* Props:
|
||
* visible — bool
|
||
* slots — массив элементов: { id, kind, modelTypeId, name, params } | null
|
||
* activeIndex — индекс выделенного слота
|
||
* onSelect(i) — переключить активный слот
|
||
*/
|
||
function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) {
|
||
if (!visible) return null;
|
||
|
||
// ГЛОБАЛЬНОЕ ПРАВИЛО (для всех игр тулбокса): если в инвентаре нет ни
|
||
// одного предмета — панель инвентаря НЕ показываем вовсе. Пустой hotbar
|
||
// из 5 серых ячеек загромождает экран в играх, где инвентарь не нужен.
|
||
// Панель появится автоматически, как только в слот попадёт предмет.
|
||
const hasAnyItem = Array.isArray(slots) && slots.some((s) => s != null);
|
||
if (!hasAnyItem) return null;
|
||
|
||
const SLOT_COUNT = 5;
|
||
const cells = [];
|
||
for (let i = 0; i < SLOT_COUNT; i++) {
|
||
const item = slots[i] || null;
|
||
const active = i === activeIndex;
|
||
cells.push(
|
||
<div
|
||
key={i}
|
||
onClick={() => onSelect?.(i)}
|
||
style={{
|
||
width: 60,
|
||
height: 60,
|
||
background: active
|
||
? 'linear-gradient(135deg, rgba(51, 87, 255, 0.45) 0%, rgba(30, 45, 165, 0.55) 100%)'
|
||
: 'rgba(255, 255, 255, 0.04)',
|
||
border: active
|
||
? '2px solid #3357ff'
|
||
: '2px solid rgba(255, 255, 255, 0.10)',
|
||
borderRadius: 12,
|
||
boxSizing: 'border-box',
|
||
position: 'relative',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: '#f1f5fb',
|
||
transition: 'all 200ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||
pointerEvents: 'auto',
|
||
boxShadow: active
|
||
? '0 8px 20px rgba(51, 87, 255, 0.45), 0 0 0 4px rgba(51, 87, 255, 0.18), inset 0 1px 0 rgba(255,255,255,0.12)'
|
||
: 'inset 0 1px 0 rgba(255,255,255,0.04)',
|
||
transform: active ? 'translateY(-3px) scale(1.05)' : 'translateY(0) scale(1)',
|
||
}}
|
||
title={item ? item.name : `Слот ${i + 1}`}
|
||
>
|
||
{/* Цифра слота сверху-слева */}
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 3, left: 6,
|
||
fontSize: 11,
|
||
fontWeight: 800,
|
||
color: active ? '#fff' : 'rgba(241, 245, 251, 0.55)',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
pointerEvents: 'none',
|
||
textShadow: active ? '0 1px 2px rgba(0,0,0,0.4)' : 'none',
|
||
letterSpacing: 0.2,
|
||
}}>
|
||
{i + 1}
|
||
</div>
|
||
|
||
{/* Содержимое слота — иконка-эмодзи + название */}
|
||
{item ? (
|
||
<div style={{
|
||
textAlign: 'center',
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
lineHeight: 1.1,
|
||
padding: 2,
|
||
marginTop: 8,
|
||
whiteSpace: 'nowrap',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
maxWidth: 54,
|
||
textShadow: '0 1px 2px rgba(0,0,0,0.7)',
|
||
}}>
|
||
<div style={{
|
||
lineHeight: 1, marginBottom: 2,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
filter: active
|
||
? 'drop-shadow(0 2px 4px rgba(0,0,0,0.4))'
|
||
: 'grayscale(0.2) opacity(0.85)',
|
||
transition: 'filter 200ms ease',
|
||
}}>
|
||
<Icon name={iconForItem(item)} size={24} />
|
||
</div>
|
||
<div style={{
|
||
fontSize: 9,
|
||
color: active ? '#fff' : 'rgba(241, 245, 251, 0.62)',
|
||
fontWeight: active ? 700 : 600,
|
||
}}>{item.name}</div>
|
||
</div>
|
||
) : (
|
||
/* Пустой слот — лёгкая точка-плейсхолдер */
|
||
<div style={{
|
||
width: 6, height: 6, borderRadius: '50%',
|
||
background: 'rgba(255, 255, 255, 0.10)',
|
||
}} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div data-mobile-hud="hotbar" style={{
|
||
position: 'absolute',
|
||
// На мобиле Hotbar внизу по центру между джойстиком и кнопками,
|
||
// и сжат до 70%. На десктопе — стандартно по центру.
|
||
bottom: mobileMode ? 8 : 22,
|
||
left: '50%',
|
||
transform: mobileMode
|
||
? 'translateX(-50%) scale(0.7)'
|
||
: 'translateX(-50%)',
|
||
transformOrigin: 'bottom center',
|
||
display: 'flex',
|
||
gap: 6,
|
||
padding: 8,
|
||
background: 'rgba(20, 24, 45, 0.78)',
|
||
borderRadius: 18,
|
||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||
// На мобиле hotbar должен быть ВЫШЕ overlay (zIndex: 50) чтобы
|
||
// тач-клики на слотах не уходили в overlay-touch-handler.
|
||
zIndex: mobileMode ? 60 : 28,
|
||
pointerEvents: 'none',
|
||
boxShadow:
|
||
'0 12px 32px rgba(0, 0, 0, 0.50), '
|
||
+ '0 0 0 1px rgba(51, 87, 255, 0.12), '
|
||
+ 'inset 0 1px 0 rgba(255, 255, 255, 0.08)',
|
||
backdropFilter: 'blur(20px)',
|
||
WebkitBackdropFilter: 'blur(20px)',
|
||
}}>
|
||
{cells}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Имя иконки (для <Icon name=...>) по виду предмета. */
|
||
function iconForItem(item) {
|
||
if (!item) return 'box';
|
||
if (item.kind === 'weapon') {
|
||
if (item.modelTypeId?.startsWith('blaster')) return 'crosshair';
|
||
if (item.modelTypeId?.includes('sword')) return 'sword';
|
||
if (item.modelTypeId?.includes('spear')) return 'sword';
|
||
if (item.modelTypeId?.includes('bow')) return 'target';
|
||
return 'sword';
|
||
}
|
||
if (item.kind === 'tool') return 'wrench';
|
||
if (item.kind === 'potion') return 'flask';
|
||
return 'box';
|
||
}
|
||
|
||
export default Hotbar;
|