/** * ShopInventoryUi — готовый GUI-кит «слот-инвентарь магазина» (задача 11). * * Нижняя (или боковая) панель кнопок-слотов с иконкой/названием/ценой и hover. * Клик по слоту → колбэк onSlotClick(item) — обычно автор вызывает внутри * game.placement.start(...). Слот серый и некликабельный, если валюты мало * (показывается, когда заданы showCurrency + текущий баланс через setBalance). * * Реализация — лёгкий DOM-оверлей поверх canvas (а не Babylon-GUI): кнопки с * иконками/hover/disabled делаются на HTML быстрее и доступнее. Крепится к * родителю canvas, абсолютным позиционированием. * * Фича-парность: идентичный модуль в rublox-player/src/engine/. */ // Встроенные inline-SVG иконки слотов (без эмодзи — самописные, см. правила UI). const SLOT_ICONS = { crate: '', plant: '', oven: '', coin: '$', box: '', }; function iconSvg(name) { return SLOT_ICONS[name] || SLOT_ICONS.box; } export class ShopInventoryUi { constructor(scene3d) { this.s = scene3d; this.root = null; this.items = []; this.balance = {}; // currency → amount this.currency = ''; this.showCost = true; this._onSlotClick = null; this._slotEls = []; } create(opts, onSlotClick) { this.remove(); this.items = Array.isArray(opts.items) ? opts.items : []; this.currency = opts.showCurrency || ''; this.showCost = opts.showCost !== false; this._onSlotClick = typeof onSlotClick === 'function' ? onSlotClick : null; const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body; // Контейнер должен быть position:relative чтобы absolute-панель легла поверх. try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ } const pos = opts.position || 'bottom'; const slotSize = Number(opts.slotSize) || 80; const spacing = Number(opts.spacing) || 4; const root = document.createElement('div'); root.className = 'kbn-shop-inv'; const sideStyle = { bottom: `left:50%;bottom:18px;transform:translateX(-50%);flex-direction:row;`, top: `left:50%;top:18px;transform:translateX(-50%);flex-direction:row;`, left: `left:18px;top:50%;transform:translateY(-50%);flex-direction:column;`, right: `right:18px;top:50%;transform:translateY(-50%);flex-direction:column;`, }[pos] || ''; root.style.cssText = `position:absolute;display:flex;gap:${spacing}px;z-index:40;` + `padding:8px;border-radius:14px;` + `background:rgba(20,26,38,0.82);backdrop-filter:blur(4px);` + `box-shadow:0 6px 24px rgba(0,0,0,0.4);` + sideStyle; this.items.forEach((it, idx) => { const slot = document.createElement('button'); slot.type = 'button'; slot.dataset.key = it.key; slot.style.cssText = `width:${slotSize}px;height:${slotSize}px;border:none;border-radius:11px;` + `display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;` + `cursor:pointer;color:#fff;font:600 12px/1.1 system-ui,sans-serif;` + `background:linear-gradient(180deg,#3a4a66,#26324a);` + `transition:transform .1s,box-shadow .1s,filter .1s;position:relative;`; slot.innerHTML = `${iconSvg(it.icon)}` + `${it.name || ''}` + (this.showCost && it.cost ? `${it.cost}${this.currency ? ' ' + this._curShort() : ''}` : ''); slot.onmouseenter = () => { if (!slot.disabled) { slot.style.transform = 'translateY(-3px)'; slot.style.boxShadow = '0 6px 14px rgba(0,0,0,0.45)'; } }; slot.onmouseleave = () => { slot.style.transform = ''; slot.style.boxShadow = ''; }; slot.onclick = () => { if (slot.disabled) return; if (this._onSlotClick) this._onSlotClick(it); }; this._slotEls[idx] = slot; root.appendChild(slot); }); parent.appendChild(root); this.root = root; this._refreshAffordability(); } _curShort() { const map = { rubles: 'руб', coins: 'мон', diamonds: 'алм' }; return map[this.currency] || this.currency; } /** Обновить баланс валюты — слоты дороже баланса станут серыми. */ setBalance(currency, amount) { if (currency) this.balance[currency] = Number(amount) || 0; this._refreshAffordability(); } _refreshAffordability() { if (!this.currency) return; // без валюты все слоты активны const bal = this.balance[this.currency] != null ? this.balance[this.currency] : Infinity; this.items.forEach((it, idx) => { const slot = this._slotEls[idx]; if (!slot) return; const afford = (Number(it.cost) || 0) <= bal; slot.disabled = !afford; slot.style.filter = afford ? '' : 'grayscale(1) brightness(0.6)'; slot.style.cursor = afford ? 'pointer' : 'not-allowed'; slot.style.opacity = afford ? '1' : '0.7'; }); } remove() { if (this.root) { try { this.root.remove(); } catch { /* ignore */ } this.root = null; } this._slotEls = []; } dispose() { this.remove(); this._onSlotClick = null; } }