studio/src/editor/Hotbar.jsx
min ee1b7352b7
Some checks failed
CI / Lint (pull_request) Successful in 1m15s
CI / Build (pull_request) Successful in 1m58s
CI / Secret scan (pull_request) Failing after 9s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat(11): placement mode — расстановка предметов (tycoon)
Движок: PlacementManager (тень-превью формой воксельной модели за курсором,
снап к сетке, стопка, проверка зоны и баланса, поворот R/колесо, ПКМ/Esc),
ShopInventoryUi (магазин-слоты, авто-серые при нехватке валюты); проводка
game.placement.* и game.inventoryUi.* в worker/GameRuntime/BabylonScene.

Попутные фиксы:
- TerrainManager: backFaceCulling=false — воксели не просвечивают (видна была
  задняя грань сквозь переднюю);
- KubikonEditor: guard от потери userModels/scripts при частичной загрузке
  (terrain догрузился, модели/скрипт нет → автосейв затирал) — критичный
  фикс защиты данных для ВСЕХ игр;
- Hotbar: пустой инвентарь не показывает панель (глобальное правило);
- MinimapOverlay: миникарта только по флагу игры (не авто на больших картах);
- cleanup usermodel-инстансов при Stop.

Вики: карточка #58 + статья-урок «Мой завод» (g5 Разбор готовых игр),
openProjectId=2345, скриншоты залиты на прод.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:06:03 +03:00

166 lines
7.2 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.

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;