diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js
index 24b3605..1c241cf 100644
--- a/src/editor/engine/BabylonScene.js
+++ b/src/editor/engine/BabylonScene.js
@@ -77,6 +77,7 @@ import { SkyboxManager } from './SkyboxManager';
import { LeaderstatsManager } from './LeaderstatsManager';
import { AchievementsManager } from './AchievementsManager';
import { FloaterManager } from './FloaterManager'; // задача 40 — damage floaters
+import { InventoryUI } from './InventoryUI'; // задача 44 — drag-drop инвентарь
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
import { GameAudioManager } from './GameAudioManager';
import { AssetManager } from './AssetManager';
@@ -1301,6 +1302,7 @@ export class BabylonScene {
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
this.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight); // задача 16 — кастомное небо (единый источник света)
this.floaters = new FloaterManager(this); // задача 40 — damage floaters
+ this.invUI = new InventoryUI(this); // задача 44 — drag-drop инвентарь
this.leaderstats = new LeaderstatsManager(this); // задача 20 — лидерборды
this.achievements = new AchievementsManager(this); // задача 20 — достижения
this.audioManager = new AudioManager();
@@ -2580,6 +2582,19 @@ export class BabylonScene {
const key = this._normalizeKey(e);
this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code });
}
+ // Задача 44: клавиша I — открыть/закрыть инвентарь (в Play, если он активен).
+ if (this._isPlaying && e.code === 'KeyI' && this.invUI &&
+ (this.invUI.defs.size > 0 || this.invUI.grid.some(Boolean) || this.invUI.hotbar.some(Boolean))) {
+ e.preventDefault(); this.invUI.toggle(); return;
+ }
+ if (this._isPlaying && e.code === 'Escape' && this.invUI?.isOpen()) {
+ e.preventDefault(); this.invUI.close(); return;
+ }
+ // Цифры 1-9 → активный hotbar-слот инвентаря (задача 44).
+ if (this._isPlaying && this.invUI && /^Digit[1-9]$/.test(e.code) &&
+ (this.invUI.hotbar.some(Boolean) || this.invUI.defs.size > 0)) {
+ this.invUI.setActiveHotbar(parseInt(e.code.slice(5), 10) - 1);
+ }
// Placement mode (задача 11): R — повернуть preview, Esc — отмена.
if (this._isPlaying && this.placementManager && this.placementManager.isActive()) {
if (e.code === 'KeyR') { e.preventDefault(); this.placementManager.rotate(); return; }
@@ -6209,6 +6224,12 @@ export class BabylonScene {
// загружены из проекта (define из project_data при load).
try { if (this.leaderstats?.active) this.leaderstats._mount(); } catch (e) {}
try { if (this.achievements?.active) this.achievements._mountButton(); } catch (e) {}
+ // Задача 44: хотбар инвентаря показываем если есть определения/предметы.
+ try {
+ if (this.invUI && (this.invUI.defs.size > 0 || this.invUI.hotbar.some(Boolean) || this.invUI.grid.some(Boolean))) {
+ this.invUI.mountHotbar();
+ }
+ } catch (e) {}
// Старт через requestAnimationFrame — даём Babylon собрать сцену
requestAnimationFrame(() => {
if (this._isPlaying) this.gameRuntime?.start(this._scripts || []);
@@ -7614,6 +7635,7 @@ export class BabylonScene {
folders: this.folderManager ? this.folderManager.serialize() : [],
gui: this.guiManager ? this.guiManager.serialize() : [],
inventory: this.inventory ? this.inventory.serialize() : null,
+ inventory2: this.invUI ? this.invUI.serialize() : null, // задача 44
spawnPoint: { ...this._spawnPoint },
spawnEnabled: this._spawnEnabled !== false,
playerModelType: this._playerModelType,
@@ -7995,6 +8017,10 @@ export class BabylonScene {
if (this.inventory) {
this.inventory.loadFromArray(state.scene.inventory || null);
}
+ // Задача 44: drag-drop инвентарь (определения предметов + слоты).
+ if (this.invUI && state.scene.inventory2) {
+ this.invUI.load(state.scene.inventory2);
+ }
// Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле)
if (this.blockManager && Array.isArray(state.scene.blocks)) {
for (const b of state.scene.blocks) {
@@ -8206,6 +8232,7 @@ export class BabylonScene {
try { this.leaderstats?.resetRuntime?.(); } catch (e) {}
try { this.achievements?.resetRuntime?.(); } catch (e) {}
try { this.floaters?.resetRuntime?.(); } catch (e) {} // задача 40
+ try { this.invUI?.resetRuntime?.(); } catch (e) {} // задача 44
// Сбрасываем таймер прохождения
this._timerRunning = false;
this._timerStartedAt = null;
diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js
index 45c5ca1..c329e0a 100644
--- a/src/editor/engine/GameRuntime.js
+++ b/src/editor/engine/GameRuntime.js
@@ -2087,6 +2087,19 @@ export class GameRuntime {
}
return;
}
+ // === Задача 44: drag-drop инвентарь (invUI) ===
+ if (cmd === 'items.define') { try { this.scene3d?.invUI?.defineItem(payload.def); } catch (e) {} return; }
+ if (cmd === 'inv2.add') {
+ try { this.scene3d?.invUI?.add(payload.itemId, payload.count); this.scene3d?.invUI?.mountHotbar(); } catch (e) {}
+ return;
+ }
+ if (cmd === 'inv2.remove') { try { this.scene3d?.invUI?.remove(payload.itemId, payload.count); } catch (e) {} return; }
+ if (cmd === 'inv2.open') { try { this.scene3d?.invUI?.open(); } catch (e) {} return; }
+ if (cmd === 'inv2.close') { try { this.scene3d?.invUI?.close(); } catch (e) {} return; }
+ if (cmd === 'inv2.toggle') { try { this.scene3d?.invUI?.toggle(); } catch (e) {} return; }
+ if (cmd === 'inv2.sort') { try { this.scene3d?.invUI?.sort(payload.by); } catch (e) {} return; }
+ if (cmd === 'inv2.setActive') { try { this.scene3d?.invUI?.setActiveHotbar(payload.i); } catch (e) {} return; }
+
if (cmd === 'inventory.remove') {
// payload: { modelTypeId? , name? } — убрать первый совпавший слот.
const inv = this.scene3d?.inventory;
diff --git a/src/editor/engine/InventoryUI.js b/src/editor/engine/InventoryUI.js
new file mode 100644
index 0000000..0a49359
--- /dev/null
+++ b/src/editor/engine/InventoryUI.js
@@ -0,0 +1,370 @@
+/**
+ * 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 =
+ '