/** * InventoryUI — drag-drop инвентарь (задача 44): сетка 8×5 + hotbar 9 + стаки + * редкости + ПКМ-меню + tooltip. Самодостаточный DOM-оверлей (как * LoadingScreenOverlay) — крепится к canvas.parentElement, работает в студии и * плеере одинаково. * * Хранит: item-defs (game.items.define), слоты основного инвентаря (GRID), * слоты hotbar (HOTBAR), активный hotbar-слот. Постоянный hotbar внизу HUD; * окно инвентаря по клавише I (toggle). * * API (через game.inventory.* / game.items.*): * game.items.define({id,name,icon,rarity,maxStack,description,value,onUse,tags}) * game.inventory.add(itemId, count) / remove / has / count * game.inventory.open() / close() / toggle() / isOpen() * game.inventory.move(from, to) / split(slot, n) / sort(by) / use(slot) * game.inventory.setActiveHotbar(i) / getActiveItem() * * Фича-парность: тот же модуль в rublox-player/src/engine/. */ const GRID = 40; // 8×5 основной инвентарь const COLS = 8; const HOTBAR = 9; const RARITY = { common: { color: '#bbbbbb', label: 'Обычное' }, uncommon: { color: '#5cb85c', label: 'Необычное' }, rare: { color: '#5bc0de', label: 'Редкое' }, epic: { color: '#9b59b6', label: 'Эпическое' }, legendary: { color: '#f0ad4e', label: 'Легендарное' }, }; export class InventoryUI { constructor(scene3d) { this.s = scene3d; this.defs = new Map(); // itemId → def this.grid = new Array(GRID).fill(null); // {itemId,count}|null this.hotbar = new Array(HOTBAR).fill(null); this.active = 0; this._open = false; this.root = null; this.hotbarRoot = null; this.tooltip = null; this.ctxMenu = null; this._drag = null; // {from:'grid'|'hotbar', idx} this._onChange = []; this._events = { added: [], removed: [], used: [], slot: [] }; this._opts = { allowDrop: true, allowSplit: true, showRarity: true }; } // ── Определения предметов ─────────────────────────────────────────────── defineItem(def) { if (!def || typeof def.id !== 'string') return; this.defs.set(def.id, { id: def.id, name: def.name || def.id, icon: def.icon || null, emoji: def.emoji || null, rarity: RARITY[def.rarity] ? def.rarity : 'common', maxStack: Number(def.maxStack) > 0 ? Number(def.maxStack) : 1, description: def.description || '', value: Number(def.value) || 0, tags: Array.isArray(def.tags) ? def.tags : [], onUseEffect: def.onUseEffect || null, // 'heal:50' | 'speed:1.5:5' | null }); } _def(id) { return this.defs.get(id) || { id, name: id, rarity: 'common', maxStack: 99, emoji: '📦', icon: null, description: '', value: 0, tags: [] }; } // ── Операции ──────────────────────────────────────────────────────────── add(itemId, count = 1) { const def = this._def(itemId); let left = count; // 1) долить в существующие стаки (grid, потом hotbar) const fill = (arr) => { for (let i = 0; i < arr.length && left > 0; i++) { const s = arr[i]; if (s && s.itemId === itemId && s.count < def.maxStack) { const room = def.maxStack - s.count; const take = Math.min(room, left); s.count += take; left -= take; } } }; fill(this.grid); fill(this.hotbar); // 2) в пустые слоты (grid, потом hotbar) const place = (arr) => { for (let i = 0; i < arr.length && left > 0; i++) { if (!arr[i]) { const take = Math.min(def.maxStack, left); arr[i] = { itemId, count: take }; left -= take; } } }; place(this.grid); place(this.hotbar); const added = count - left; if (added > 0) { this._emit('added', { itemId, count: added }); this._changed(); } return { added, overflow: left }; } remove(itemId, count = 1) { let left = count; const drain = (arr) => { for (let i = arr.length - 1; i >= 0 && left > 0; i--) { const s = arr[i]; if (s && s.itemId === itemId) { const take = Math.min(s.count, left); s.count -= take; left -= take; if (s.count <= 0) arr[i] = null; } } }; drain(this.hotbar); drain(this.grid); const removed = count - left; if (removed > 0) { this._emit('removed', { itemId, count: removed }); this._changed(); } return removed; } count(itemId) { let n = 0; for (const s of this.grid) if (s && s.itemId === itemId) n += s.count; for (const s of this.hotbar) if (s && s.itemId === itemId) n += s.count; return n; } has(itemId, n = 1) { return this.count(itemId) >= n; } /** slot-ref: число 0..39 = grid; строка 'h0'..'h8' = hotbar. */ _arrIdx(ref) { if (typeof ref === 'string' && ref[0] === 'h') return { arr: this.hotbar, idx: parseInt(ref.slice(1), 10) }; return { arr: this.grid, idx: Number(ref) }; } move(from, to) { const a = this._arrIdx(from), b = this._arrIdx(to); if (!a.arr || !b.arr || a.idx == null || b.idx == null) return; if (a.arr === b.arr && a.idx === b.idx) return; const src = a.arr[a.idx], dst = b.arr[b.idx]; // merge одинаковых стаков if (src && dst && src.itemId === dst.itemId) { const def = this._def(src.itemId); const room = def.maxStack - dst.count; if (room > 0) { const take = Math.min(room, src.count); dst.count += take; src.count -= take; if (src.count <= 0) a.arr[a.idx] = null; this._changed(); return; } } // swap a.arr[a.idx] = dst; b.arr[b.idx] = src; this._changed(); } split(ref, n) { if (!this._opts.allowSplit) return; const { arr, idx } = this._arrIdx(ref); const s = arr[idx]; if (!s || s.count <= 1) return; const take = Math.max(1, Math.min(s.count - 1, n || Math.floor(s.count / 2))); const empty = this.grid.indexOf(null); if (empty < 0) return; s.count -= take; this.grid[empty] = { itemId: s.itemId, count: take }; this._changed(); } sort(by = 'rarity') { const order = { legendary: 0, epic: 1, rare: 2, uncommon: 3, common: 4 }; const all = this.grid.filter(Boolean); all.sort((x, y) => { const dx = this._def(x.itemId), dy = this._def(y.itemId); if (by === 'rarity') return (order[dx.rarity] - order[dy.rarity]) || dx.name.localeCompare(dy.name); if (by === 'name') return dx.name.localeCompare(dy.name); return dx.id.localeCompare(dy.id); }); this.grid = all.concat(new Array(GRID - all.length).fill(null)); this._changed(); } use(ref) { const { arr, idx } = this._arrIdx(ref); const s = arr[idx]; if (!s) return; const def = this._def(s.itemId); let consume = false; if (def.onUseEffect) { const [eff, a, b] = String(def.onUseEffect).split(':'); try { if (eff === 'heal') { this.s?.player?.heal?.(Number(a) || 25); consume = true; } else if (eff === 'speed') { this.s?.player?.setSpeed?.(Number(a) || 1.5); consume = true; } } catch (e) { /* ignore */ } } this._emit('used', { itemId: s.itemId }); if (consume) { s.count -= 1; if (s.count <= 0) arr[idx] = null; this._changed(); } } setActiveHotbar(i) { this.active = Math.max(0, Math.min(HOTBAR - 1, i | 0)); this._renderHotbar(); } getActiveItem() { const s = this.hotbar[this.active]; return s ? { ...s, def: this._def(s.itemId) } : null; } onChange(fn) { if (typeof fn === 'function') this._onChange.push(fn); } on(evt, fn) { if (this._events[evt] && typeof fn === 'function') this._events[evt].push(fn); } _emit(evt, data) { for (const fn of (this._events[evt] || [])) { try { fn(data); } catch (e) {} } } _changed() { for (const fn of this._onChange) { try { fn(); } catch (e) {} } this._emit('slot', {}); if (this._open) this._renderGrid(); this._renderHotbar(); } // ── DOM: hotbar (постоянный) ─────────────────────────────────────────── _parent() { return (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body; } mountHotbar() { if (this.hotbarRoot) return; const r = document.createElement('div'); r.style.cssText = 'position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:48;display:flex;gap:6px;pointer-events:auto;font-family:Inter,system-ui,sans-serif'; this._parent().appendChild(r); this.hotbarRoot = r; this._renderHotbar(); } _slotInner(s) { if (!s) return ''; const def = this._def(s.itemId); const icon = def.icon ? `` : `${def.emoji || '📦'}`; const cnt = s.count > 1 ? `${s.count}` : ''; return icon + cnt; } _slotStyle(s, activeBorder) { const rc = (s && this._opts.showRarity) ? RARITY[this._def(s.itemId).rarity].color : 'rgba(255,255,255,0.15)'; const border = activeBorder ? '#ffd23a' : rc; return `position:relative;width:52px;height:52px;border-radius:10px;border:2px solid ${border};background:rgba(20,26,40,0.7);display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.3)` + (activeBorder ? ';box-shadow:0 0 12px #ffd23a' : ''); } _renderHotbar() { if (!this.hotbarRoot) return; this.hotbarRoot.innerHTML = ''; for (let i = 0; i < HOTBAR; i++) { const s = this.hotbar[i]; const cell = document.createElement('div'); cell.style.cssText = this._slotStyle(s, i === this.active); cell.innerHTML = `${i + 1}` + this._slotInner(s); cell.onmouseenter = (e) => this._showTooltip(s, e); cell.onmouseleave = () => this._hideTooltip(); cell.onclick = () => { this.setActiveHotbar(i); }; cell.oncontextmenu = (e) => { e.preventDefault(); this._openCtx('h' + i, e); }; this._wireDrag(cell, 'h' + i); this.hotbarRoot.appendChild(cell); } } // ── DOM: окно инвентаря ───────────────────────────────────────────────── open() { if (this._open) return; this._open = true; this._mountWindow(); } close() { this._open = false; if (this.root) { try { this.root.remove(); } catch (e) {} this.root = null; } this._hideTooltip(); this._closeCtx(); } toggle() { this._open ? this.close() : this.open(); } isOpen() { return this._open; } _mountWindow() { const overlay = document.createElement('div'); overlay.style.cssText = 'position:absolute;inset:0;z-index:70;background:rgba(8,10,16,0.6);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;font-family:Inter,system-ui,sans-serif;pointer-events:auto'; overlay.onclick = (e) => { if (e.target === overlay) this.close(); }; const panel = document.createElement('div'); panel.style.cssText = 'width:min(640px,94%);background:#141826;border:1px solid rgba(255,255,255,0.12);border-radius:18px;padding:20px;color:#e8ecf2;box-shadow:0 20px 60px rgba(0,0,0,0.5)'; panel.onclick = (e) => e.stopPropagation(); panel.innerHTML = '
' + '
🎒 Инвентарь
' + '
' + '' + '' + '
' + '
' + '
Панель быстрого доступа (1-9)
' + '
'; overlay.appendChild(panel); this._parent().appendChild(overlay); this.root = overlay; panel.querySelector('#_inv_close').onclick = () => this.close(); panel.querySelector('#_inv_sort').onclick = () => this.sort('rarity'); this._gridEl = panel.querySelector('#_inv_grid'); this._hbEl = panel.querySelector('#_inv_hb'); this._renderGrid(); } _renderGrid() { if (!this._gridEl) return; const build = (el, arr, prefix) => { el.innerHTML = ''; for (let i = 0; i < arr.length; i++) { const ref = prefix === 'h' ? 'h' + i : i; const s = arr[i]; const cell = document.createElement('div'); cell.style.cssText = this._slotStyle(s, prefix === 'h' && i === this.active).replace('52px', '56px'); cell.innerHTML = (prefix === 'h' ? `${i + 1}` : '') + this._slotInner(s); cell.onmouseenter = (e) => this._showTooltip(s, e); cell.onmouseleave = () => this._hideTooltip(); cell.oncontextmenu = (e) => { e.preventDefault(); this._openCtx(ref, e); }; if (prefix === 'h') cell.onclick = () => this.setActiveHotbar(i); this._wireDrag(cell, ref); el.appendChild(cell); } }; build(this._gridEl, this.grid, 'g'); if (this._hbEl) build(this._hbEl, this.hotbar, 'h'); } // ── Drag-drop (HTML5 native) ──────────────────────────────────────────── _wireDrag(cell, ref) { cell.draggable = true; cell.addEventListener('dragstart', (e) => { this._drag = ref; cell.style.opacity = '0.4'; try { e.dataTransfer.setData('text/plain', String(ref)); e.dataTransfer.effectAllowed = 'move'; } catch (er) {} }); cell.addEventListener('dragend', () => { cell.style.opacity = '1'; this._drag = null; }); cell.addEventListener('dragover', (e) => { e.preventDefault(); }); cell.addEventListener('drop', (e) => { e.preventDefault(); const from = this._drag; if (from != null && String(from) !== String(ref)) this.move(from, ref); }); } // ── Tooltip ────────────────────────────────────────────────────────────── _showTooltip(s, e) { if (!s) return; this._hideTooltip(); const def = this._def(s.itemId), rc = RARITY[def.rarity]; const t = document.createElement('div'); t.style.cssText = 'position:absolute;z-index:90;max-width:240px;padding:10px 12px;background:rgba(12,16,26,0.96);border:1px solid ' + rc.color + ';border-radius:10px;color:#e8ecf2;font-family:Inter,system-ui,sans-serif;font-size:13px;pointer-events:none;box-shadow:0 6px 20px rgba(0,0,0,0.5)'; t.innerHTML = '
' + this._esc(def.name) + '
' + '
' + rc.label + (def.tags.length ? ' · ' + def.tags.join(', ') : '') + '
' + (def.description ? '
' + this._esc(def.description) + '
' : '') + (def.value ? '
💰 ' + def.value + '
' : ''); document.body.appendChild(t); const x = (e && e.clientX) || 0, y = (e && e.clientY) || 0; t.style.left = Math.min(x + 14, window.innerWidth - 250) + 'px'; t.style.top = (y + 14) + 'px'; this.tooltip = t; } _hideTooltip() { if (this.tooltip) { try { this.tooltip.remove(); } catch (e) {} this.tooltip = null; } } // ── ПКМ-меню (Use/Split/Drop) ───────────────────────────────────────────── _openCtx(ref, e) { this._closeCtx(); const { arr, idx } = this._arrIdx(ref); const s = arr[idx]; if (!s) return; const m = document.createElement('div'); m.style.cssText = 'position:absolute;z-index:95;background:#1a2030;border:1px solid rgba(255,255,255,0.15);border-radius:10px;padding:5px;min-width:140px;font-family:Inter,system-ui,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,0.5)'; const item = (label, fn) => { const b = document.createElement('div'); b.textContent = label; b.style.cssText = 'padding:8px 12px;border-radius:7px;cursor:pointer;color:#e8ecf2;font-size:14px'; b.onmouseenter = () => b.style.background = 'rgba(255,255,255,0.08)'; b.onmouseleave = () => b.style.background = 'transparent'; b.onclick = () => { fn(); this._closeCtx(); }; m.appendChild(b); }; item('Использовать', () => this.use(ref)); if (this._opts.allowSplit && s.count > 1) item('Разделить', () => this.split(ref, Math.floor(s.count / 2))); if (this._opts.allowDrop && !this._def(s.itemId).tags.includes('quest')) item('Выбросить', () => { arr[idx] = null; this._changed(); }); item('Отмена', () => {}); document.body.appendChild(m); m.style.left = Math.min((e.clientX || 0), window.innerWidth - 150) + 'px'; m.style.top = (e.clientY || 0) + 'px'; this.ctxMenu = m; setTimeout(() => { this._ctxCloser = () => this._closeCtx(); window.addEventListener('click', this._ctxCloser, { once: true }); }, 0); } _closeCtx() { if (this.ctxMenu) { try { this.ctxMenu.remove(); } catch (e) {} this.ctxMenu = null; } } _esc(str) { return String(str).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); } // ── Сериализация ────────────────────────────────────────────────────────── serialize() { return { defs: [...this.defs.values()], grid: this.grid, hotbar: this.hotbar, active: this.active, opts: this._opts }; } load(data) { if (!data) return; if (Array.isArray(data.defs)) for (const d of data.defs) this.defineItem(d); if (Array.isArray(data.grid)) this.grid = data.grid.slice(0, GRID).concat(new Array(Math.max(0, GRID - data.grid.length)).fill(null)); if (Array.isArray(data.hotbar)) this.hotbar = data.hotbar.slice(0, HOTBAR).concat(new Array(Math.max(0, HOTBAR - data.hotbar.length)).fill(null)); if (typeof data.active === 'number') this.active = data.active; if (data.opts) this._opts = { ...this._opts, ...data.opts }; } dispose() { this.close(); if (this.hotbarRoot) { try { this.hotbarRoot.remove(); } catch (e) {} this.hotbarRoot = null; } } resetRuntime() { this.close(); if (this.hotbarRoot) { try { this.hotbarRoot.remove(); } catch (e) {} this.hotbarRoot = null; } } }