Some checks failed
Движок: 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>
133 lines
7.3 KiB
JavaScript
133 lines
7.3 KiB
JavaScript
/**
|
||
* 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: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#7a4a1e" stroke-width="1.6"><rect x="3" y="6" width="18" height="13" rx="1" fill="#c2884a"/><path d="M3 10h18M9 6v13M15 6v13" stroke="#7a4a1e"/></svg>',
|
||
plant: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none"><path d="M12 21V11" stroke="#4a7a2e" stroke-width="2"/><path d="M12 12c-3-1-5-4-5-7 3 0 6 2 5 7zM12 11c3-1 5-3 5-6-3 0-6 1-5 6z" fill="#5aa83a"/></svg>',
|
||
oven: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#444" stroke-width="1.4"><rect x="4" y="3" width="16" height="18" rx="1.5" fill="#9aa0a6"/><rect x="7" y="9" width="10" height="9" rx="1" fill="#3a3f44"/><circle cx="9" cy="6" r="1" fill="#444"/><circle cx="13" cy="6" r="1" fill="#444"/></svg>',
|
||
coin: '<svg viewBox="0 0 24 24" width="34" height="34"><circle cx="12" cy="12" r="9" fill="#f5c542" stroke="#b8860b" stroke-width="1.4"/><text x="12" y="16" font-size="10" text-anchor="middle" fill="#7a5a00" font-weight="700">$</text></svg>',
|
||
box: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#666" stroke-width="1.6"><path d="M12 2l9 5v10l-9 5-9-5V7z" fill="#b0b6bc"/><path d="M12 2v20M3 7l9 5 9-5" /></svg>',
|
||
};
|
||
|
||
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 =
|
||
`<span style="pointer-events:none">${iconSvg(it.icon)}</span>` +
|
||
`<span style="pointer-events:none;max-width:${slotSize - 8}px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${it.name || ''}</span>` +
|
||
(this.showCost && it.cost
|
||
? `<span class="kbn-cost" style="pointer-events:none;color:#ffd23a;font-size:11px">${it.cost}${this.currency ? ' ' + this._curShort() : ''}</span>`
|
||
: '');
|
||
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; }
|
||
}
|