studio/src/editor/engine/ShopInventoryUi.js
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

133 lines
7.3 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.

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