feat(studio): задача 44 — drag-drop инвентарь (сетка 8×5 + hotbar 9 + стаки + редкости)
InventoryUI.js: DOM-оверлей — окно инвентаря по I (сетка 8×5), постоянный hotbar 9 (клавиши 1-9), drag-drop между слотами (HTML5), стаки с maxStack, 5 редкостей (цвет рамки), tooltip на hover, ПКМ-меню (использовать/разделить/ выбросить), сортировка по редкости. API: game.items.define([...]), game.inventory.give/take/open/toggle/sort/setActiveHotbar. onUseEffect heal/speed. Сериализация scene.inventory2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b1fbc3790e
commit
42f1334908
@ -77,6 +77,7 @@ import { SkyboxManager } from './SkyboxManager';
|
|||||||
import { LeaderstatsManager } from './LeaderstatsManager';
|
import { LeaderstatsManager } from './LeaderstatsManager';
|
||||||
import { AchievementsManager } from './AchievementsManager';
|
import { AchievementsManager } from './AchievementsManager';
|
||||||
import { FloaterManager } from './FloaterManager'; // задача 40 — damage floaters
|
import { FloaterManager } from './FloaterManager'; // задача 40 — damage floaters
|
||||||
|
import { InventoryUI } from './InventoryUI'; // задача 44 — drag-drop инвентарь
|
||||||
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
|
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
|
||||||
import { GameAudioManager } from './GameAudioManager';
|
import { GameAudioManager } from './GameAudioManager';
|
||||||
import { AssetManager } from './AssetManager';
|
import { AssetManager } from './AssetManager';
|
||||||
@ -1301,6 +1302,7 @@ export class BabylonScene {
|
|||||||
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
|
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
|
||||||
this.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight); // задача 16 — кастомное небо (единый источник света)
|
this.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight); // задача 16 — кастомное небо (единый источник света)
|
||||||
this.floaters = new FloaterManager(this); // задача 40 — damage floaters
|
this.floaters = new FloaterManager(this); // задача 40 — damage floaters
|
||||||
|
this.invUI = new InventoryUI(this); // задача 44 — drag-drop инвентарь
|
||||||
this.leaderstats = new LeaderstatsManager(this); // задача 20 — лидерборды
|
this.leaderstats = new LeaderstatsManager(this); // задача 20 — лидерборды
|
||||||
this.achievements = new AchievementsManager(this); // задача 20 — достижения
|
this.achievements = new AchievementsManager(this); // задача 20 — достижения
|
||||||
this.audioManager = new AudioManager();
|
this.audioManager = new AudioManager();
|
||||||
@ -2580,6 +2582,19 @@ export class BabylonScene {
|
|||||||
const key = this._normalizeKey(e);
|
const key = this._normalizeKey(e);
|
||||||
this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code });
|
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 — отмена.
|
// Placement mode (задача 11): R — повернуть preview, Esc — отмена.
|
||||||
if (this._isPlaying && this.placementManager && this.placementManager.isActive()) {
|
if (this._isPlaying && this.placementManager && this.placementManager.isActive()) {
|
||||||
if (e.code === 'KeyR') { e.preventDefault(); this.placementManager.rotate(); return; }
|
if (e.code === 'KeyR') { e.preventDefault(); this.placementManager.rotate(); return; }
|
||||||
@ -6209,6 +6224,12 @@ export class BabylonScene {
|
|||||||
// загружены из проекта (define из project_data при load).
|
// загружены из проекта (define из project_data при load).
|
||||||
try { if (this.leaderstats?.active) this.leaderstats._mount(); } catch (e) {}
|
try { if (this.leaderstats?.active) this.leaderstats._mount(); } catch (e) {}
|
||||||
try { if (this.achievements?.active) this.achievements._mountButton(); } 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 — даём Babylon собрать сцену
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (this._isPlaying) this.gameRuntime?.start(this._scripts || []);
|
if (this._isPlaying) this.gameRuntime?.start(this._scripts || []);
|
||||||
@ -7614,6 +7635,7 @@ export class BabylonScene {
|
|||||||
folders: this.folderManager ? this.folderManager.serialize() : [],
|
folders: this.folderManager ? this.folderManager.serialize() : [],
|
||||||
gui: this.guiManager ? this.guiManager.serialize() : [],
|
gui: this.guiManager ? this.guiManager.serialize() : [],
|
||||||
inventory: this.inventory ? this.inventory.serialize() : null,
|
inventory: this.inventory ? this.inventory.serialize() : null,
|
||||||
|
inventory2: this.invUI ? this.invUI.serialize() : null, // задача 44
|
||||||
spawnPoint: { ...this._spawnPoint },
|
spawnPoint: { ...this._spawnPoint },
|
||||||
spawnEnabled: this._spawnEnabled !== false,
|
spawnEnabled: this._spawnEnabled !== false,
|
||||||
playerModelType: this._playerModelType,
|
playerModelType: this._playerModelType,
|
||||||
@ -7995,6 +8017,10 @@ export class BabylonScene {
|
|||||||
if (this.inventory) {
|
if (this.inventory) {
|
||||||
this.inventory.loadFromArray(state.scene.inventory || null);
|
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 не знает про это поле)
|
// Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле)
|
||||||
if (this.blockManager && Array.isArray(state.scene.blocks)) {
|
if (this.blockManager && Array.isArray(state.scene.blocks)) {
|
||||||
for (const b of 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.leaderstats?.resetRuntime?.(); } catch (e) {}
|
||||||
try { this.achievements?.resetRuntime?.(); } catch (e) {}
|
try { this.achievements?.resetRuntime?.(); } catch (e) {}
|
||||||
try { this.floaters?.resetRuntime?.(); } catch (e) {} // задача 40
|
try { this.floaters?.resetRuntime?.(); } catch (e) {} // задача 40
|
||||||
|
try { this.invUI?.resetRuntime?.(); } catch (e) {} // задача 44
|
||||||
// Сбрасываем таймер прохождения
|
// Сбрасываем таймер прохождения
|
||||||
this._timerRunning = false;
|
this._timerRunning = false;
|
||||||
this._timerStartedAt = null;
|
this._timerStartedAt = null;
|
||||||
|
|||||||
@ -2087,6 +2087,19 @@ export class GameRuntime {
|
|||||||
}
|
}
|
||||||
return;
|
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') {
|
if (cmd === 'inventory.remove') {
|
||||||
// payload: { modelTypeId? , name? } — убрать первый совпавший слот.
|
// payload: { modelTypeId? , name? } — убрать первый совпавший слот.
|
||||||
const inv = this.scene3d?.inventory;
|
const inv = this.scene3d?.inventory;
|
||||||
|
|||||||
370
src/editor/engine/InventoryUI.js
Normal file
370
src/editor/engine/InventoryUI.js
Normal file
@ -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 ? `<img src="${def.icon}" style="width:80%;height:80%;object-fit:contain;pointer-events:none">`
|
||||||
|
: `<span style="font-size:26px;pointer-events:none">${def.emoji || '📦'}</span>`;
|
||||||
|
const cnt = s.count > 1 ? `<span style="position:absolute;right:3px;bottom:1px;font-size:13px;font-weight:900;color:#fff;text-shadow:0 1px 2px #000">${s.count}</span>` : '';
|
||||||
|
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 = `<span style="position:absolute;left:3px;top:1px;font-size:11px;color:#9aa3b2;font-weight:700">${i + 1}</span>` + 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 =
|
||||||
|
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">' +
|
||||||
|
'<div style="font-size:22px;font-weight:800">🎒 Инвентарь</div>' +
|
||||||
|
'<div style="display:flex;gap:8px">' +
|
||||||
|
'<button id="_inv_sort" style="height:34px;padding:0 14px;border-radius:9px;background:#2a3550;border:1px solid rgba(255,255,255,0.15);color:#fff;cursor:pointer;font-weight:700">Сорт.</button>' +
|
||||||
|
'<button id="_inv_close" style="width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);color:#fff;font-size:18px;cursor:pointer">✕</button>' +
|
||||||
|
'</div></div>' +
|
||||||
|
'<div id="_inv_grid" style="display:grid;grid-template-columns:repeat(' + COLS + ',1fr);gap:6px"></div>' +
|
||||||
|
'<div style="margin:16px 0 6px;font-size:13px;color:#9aa3b2;font-weight:700">Панель быстрого доступа (1-9)</div>' +
|
||||||
|
'<div id="_inv_hb" style="display:grid;grid-template-columns:repeat(' + HOTBAR + ',1fr);gap:6px"></div>';
|
||||||
|
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' ? `<span style="position:absolute;left:3px;top:1px;font-size:11px;color:#9aa3b2;font-weight:700">${i + 1}</span>` : '') + 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 =
|
||||||
|
'<div style="font-weight:800;color:' + rc.color + '">' + this._esc(def.name) + '</div>' +
|
||||||
|
'<div style="font-size:11px;color:#9aa3b2;margin:2px 0">' + rc.label + (def.tags.length ? ' · ' + def.tags.join(', ') : '') + '</div>' +
|
||||||
|
(def.description ? '<div style="margin-top:4px">' + this._esc(def.description) + '</div>' : '') +
|
||||||
|
(def.value ? '<div style="margin-top:4px;color:#ffd23a">💰 ' + def.value + '</div>' : '');
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3172,6 +3172,28 @@ const game = {
|
|||||||
clear() {
|
clear() {
|
||||||
_send('inventory.clear', {});
|
_send('inventory.clear', {});
|
||||||
},
|
},
|
||||||
|
// === Задача 44: drag-drop инвентарь (сетка 8×5 + hotbar 9 + стаки) ===
|
||||||
|
/** Добавить предмет по itemId со стаком. game.inventory.give('berry', 5). */
|
||||||
|
give(itemId, count) { _send('inv2.add', { itemId, count: Number(count) || 1 }); },
|
||||||
|
/** Убрать N предметов по itemId. */
|
||||||
|
take(itemId, count) { _send('inv2.remove', { itemId, count: Number(count) || 1 }); },
|
||||||
|
/** Открыть/закрыть/тоггл окна инвентаря. */
|
||||||
|
open() { _send('inv2.open', {}); },
|
||||||
|
closeUi() { _send('inv2.close', {}); },
|
||||||
|
toggle() { _send('inv2.toggle', {}); },
|
||||||
|
/** Сортировать (by: 'rarity'|'name'). */
|
||||||
|
sort(by) { _send('inv2.sort', { by: by || 'rarity' }); },
|
||||||
|
/** Активный слот хотбара (0..8). */
|
||||||
|
setActiveHotbar(i) { _send('inv2.setActive', { i: Number(i) || 0 }); },
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Определения предметов (задача 44) ===
|
||||||
|
items: {
|
||||||
|
/** Зарегистрировать предмет: {id,name,emoji|icon,rarity,maxStack,description,value,tags,onUseEffect}. */
|
||||||
|
define(def) {
|
||||||
|
if (Array.isArray(def)) { for (const d of def) _send('items.define', { def: d }); return; }
|
||||||
|
_send('items.define', { def: def || {} });
|
||||||
|
},
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Игроки комнаты (Фаза 4.3 — мультиплеер).
|
* Игроки комнаты (Фаза 4.3 — мультиплеер).
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user