/** * GuiManager — слой UI-элементов поверх viewport (как ScreenGui в Roblox). * * Хранит массив элементов: Frame, TextLabel, TextButton, ImageLabel. * Не зависит от Babylon — просто состояние, которое рендерит GuiOverlay (React). * * Координаты позиции/размера хранятся в процентах (0..100), чтобы интерфейс * масштабировался под любое разрешение. Также можно задавать в пикселях через * pxX/pxY/pxW/pxH (если нужна абсолютная привязка к углам). * * Элемент: * { * id: 'gui_1', — уникальный * type: 'frame'|'text'|'button'|'image', * name: 'Frame 1', * x: 50, y: 50, — % позиции центра по экрану (по умолчанию) * w: 30, h: 20, — % размера * anchor: 'center', — 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center' * visible: true, * // визуальные стили * bgColor: '#1f1810', * bgOpacity: 0.85, * borderColor: '#5a4a3a', * borderWidth: 1, * borderRadius: 6, * // text/button only: * text: 'Hello', * textColor: '#f0e6d8', * textSize: 16, * textAlign: 'center', — 'left' | 'center' | 'right' * fontWeight: 600, * // image only: * imageUrl: '', — внешний URL картинки * imageAsset: null, — id картинки из AssetManager (приоритет над imageUrl) * // зарезервировано для следующих этапов * onClickScriptId: null, — скрипт по клику (для button) * } */ let _seq = 1; /** True если элемент может быть родителем (контейнер): frame или scroll. */ function _isContainer(el) { return !!el && (el.type === 'frame' || el.type === 'scroll'); } export class GuiManager { constructor() { /** @type {Array} */ this.elements = []; this._onChange = null; } setOnChange(cb) { this._onChange = cb; } _notify() { if (this._onChange) try { this._onChange(); } catch (e) { /* ignore */ } } /** Создать элемент. type: 'frame'|'text'|'button'|'image'. Возвращает id. * opts.parentId — id Frame'а в который вкладывается элемент. По умолчанию null (на экране). */ create(type, opts = {}) { const id = opts.id || `gui_${Date.now()}_${(_seq++).toString(36)}`; // Валидация parentId — должен существовать и быть Frame'ом let parentId = opts.parentId || null; if (parentId) { const parent = this.elements.find(e => e.id === parentId); if (!_isContainer(parent)) parentId = null; } // === GD HUD layout override (2026-05-19) === // В скриптах L1-L20 счётчики 🪙 (Coins) и 💎 (Diamonds) изначально // налезали на кнопку меню ☰ (x=94, w=5.5). Сдвигаем без правки БД. // Узнаём по тексту-эмодзи (другие скрипты эти эмодзи не используют). // // Целевой layout HUD-полосы: // Title (центр): x=20 w=30 → [20..50] // Diamonds: x=52 w=10 → [52..62] right-align // Coins: x=62 w=30 → [62..92] right-align // Menu ☰: x=94 w=5.5 → [94..99.5] (не трогаем) if (type === 'text' && typeof opts.text === 'string') { // Coins: '🪙 N/3' на x=76 w=22 → x=62 w=30 if (opts.text.indexOf('🪙') >= 0 && opts.x === 76 && opts.w === 22) { opts = { ...opts, x: 62, w: 30 }; } // Diamonds: '💎 N/M' на x=58 w=18 → x=52 w=10 (узкая колонка) if ((opts.text.indexOf('\u{1F48E}') >= 0 || opts.text.indexOf('💎') >= 0) && opts.x === 58 && opts.w === 18) { opts = { ...opts, x: 52, w: 10 }; } // Title 'L4 · Быстрее ветра' на x=30 w=40 → x=20 w=30 (центр сдвинут влево) if (/^L\d+\s/.test(opts.text) && opts.x === 30 && opts.w === 40) { opts = { ...opts, x: 20, w: 30 }; } } const base = { id, type, name: opts.name || _defaultName(type, this.elements.length + 1), parentId, x: opts.x ?? 50, y: opts.y ?? 50, w: opts.w ?? _defaultSize(type).w, h: opts.h ?? _defaultSize(type).h, anchor: opts.anchor || 'center', // Phase 6.3.1: AnchorPoint -- точка ВНУТРИ элемента, относительно // которой считается позиция x/y. По умолчанию НЕ задан -- рендерер // вычислит дефолт из anchor (center → {0.5,0.5}, top-left → {0,0}, ...). // Юзер может override через opts.anchorPoint = {x:0..1, y:0..1}. anchorPoint: opts.anchorPoint || null, visible: opts.visible !== false, bgColor: opts.bgColor ?? _defaultBgColor(type), bgOpacity: opts.bgOpacity ?? _defaultBgOpacity(type), borderColor: opts.borderColor ?? '#5a4a3a', borderWidth: opts.borderWidth ?? (type === 'frame' || type === 'button' ? 1 : 0), borderRadius: opts.borderRadius ?? 6, text: opts.text ?? _defaultText(type), textColor: opts.textColor ?? '#f0e6d8', textSize: opts.textSize ?? 16, textAlign: opts.textAlign ?? (type === 'textbox' ? 'left' : 'center'), fontWeight: opts.fontWeight ?? (type === 'button' ? 700 : 500), imageUrl: opts.imageUrl ?? '', // imageAsset — id картинки из AssetManager (этап 3.6). Если задан, // имеет приоритет над imageUrl: GuiOverlay показывает dataURL ассета. imageAsset: opts.imageAsset ?? null, // placeholder — серый текст-подсказка в пустом поле ввода (textbox) placeholder: opts.placeholder ?? (type === 'textbox' ? 'Введите текст…' : ''), onClickScriptId: opts.onClickScriptId ?? null, zIndex: opts.zIndex ?? this.elements.length + 1, // Авто-раскладка детей (Фаза 5.3 + 6.3.2): // 'none' -- дети как есть (по своим x/y); // 'vertical' -- дети в столбик; 'horizontal' -- в строку; // 'grid' -- сетка (требует layoutCellW/H/Cols). layout: opts.layout ?? 'none', // Отступ между детьми и внутреннее поле контейнера (в % размера контейнера). layoutGap: opts.layoutGap ?? 2, layoutPad: opts.layoutPad ?? 3, // Phase 6.3.2: параметры grid-layout. // cellW/H -- размер каждой ячейки в %; cols -- количество колонок (0 = авто). layoutCellW: opts.layoutCellW ?? 18, layoutCellH: opts.layoutCellH ?? 18, layoutCols: opts.layoutCols ?? 0, // Текущая прокрутка scroll-контейнера (в %, только для type='scroll'). scrollY: opts.scrollY ?? 0, // Тень под элементом (Фаза 5.4) — мягкая drop-shadow. shadow: opts.shadow ?? false, // === Задача 03: расширения для красивого UI + анимаций === // Линейный градиент фона. Формат: { stops: ['#a','#b'] | [{c,p}], angle: 0..360 }. bgGradient: opts.bgGradient ?? null, // Обводка текста (для крупных подписей "X2 ДЕНЕГ"). { color, width }. textStroke: opts.textStroke ?? null, // Поворот элемента в градусах (transform: rotate). rotation: opts.rotation ?? 0, // Scale-множитель (transform: scale). 1 = нормальный размер. scaleX: opts.scaleX ?? 1, scaleY: opts.scaleY ?? 1, // Бейдж-маркер в углу: { corner, icon, color, text }. badge: opts.badge ?? null, // Hover-реакция (только для button/image-button): { scale, rotation, brightness, duration, easing }. hover: opts.hover ?? null, // Active-реакция (зажатие ЛКМ): { scale, duration }. active: opts.active ?? null, // Анимация-пресет: 'none'|'pulse'|'rotate'|'sway'|'glow'|'bounce'|'custom'. // Раскрывается в реальный tween при applyAnimationPreset(id) в Play. animationPreset: opts.animationPreset ?? 'none', // Создан скриптом в Play (game.gui.create) — НЕ сериализуется // в проект, удаляется при Stop. _scriptCreated: opts._scriptCreated === true, }; this.elements.push(base); this._notify(); return id; } get(id) { return this.elements.find(e => e.id === id) || null; } update(id, patch) { const el = this.get(id); if (!el) return; // Валидация parentId — нельзя сделать элемент потомком самого себя // или своего же потомка (циклы), и parent должен быть Frame if ('parentId' in patch) { const next = patch.parentId || null; if (next === id) { delete patch.parentId; } else if (next != null && this._isDescendant(next, id)) { delete patch.parentId; } else if (next != null) { const p = this.get(next); if (!_isContainer(p)) delete patch.parentId; } } Object.assign(el, patch); this._notify(); } /** True если `candidate` является (косвенным) потомком `ancestorId`. */ _isDescendant(candidate, ancestorId) { let cur = this.get(candidate); let safety = 0; while (cur && cur.parentId && safety++ < 100) { if (cur.parentId === ancestorId) return true; cur = this.get(cur.parentId); } return false; } /** Сменить родителя элемента. */ setParent(id, parentId) { this.update(id, { parentId: parentId || null }); } /** Список прямых детей frame-id (или корневых элементов если parentId === null). */ children(parentId) { return this.elements.filter(e => (e.parentId || null) === (parentId || null)); } remove(id) { const i = this.elements.findIndex(e => e.id === id); if (i < 0) return; // Каскадно удаляем всех потомков const queue = [id]; const toDelete = new Set(); while (queue.length > 0) { const cur = queue.shift(); toDelete.add(cur); for (const e of this.elements) { if (e.parentId === cur) queue.push(e.id); } } this.elements = this.elements.filter(e => !toDelete.has(e.id)); this._notify(); } rename(id, name) { const el = this.get(id); if (!el) return; el.name = String(name || '').slice(0, 80); this._notify(); } /** Поднять/опустить элемент в z-order (вверх = ближе к зрителю). */ moveZ(id, direction) { const i = this.elements.findIndex(e => e.id === id); if (i < 0) return; const target = direction === 'up' ? i + 1 : i - 1; if (target < 0 || target >= this.elements.length) return; const tmp = this.elements[i]; this.elements[i] = this.elements[target]; this.elements[target] = tmp; // Перенумеровываем zIndex по порядку this.elements.forEach((e, idx) => { e.zIndex = idx + 1; }); this._notify(); } getAll() { return this.elements.slice(); } serialize() { // Элементы, созданные скриптом в Play (game.gui.create), НЕ // сохраняем в проект — иначе автосейв запишет их в БД и они // «вернутся» после Stop. Служебное поле _scriptCreated не пишем. return this.elements .filter(e => !e._scriptCreated) .map(e => { const c = { ...e }; delete c._scriptCreated; return c; }); } loadFromArray(arr) { this.elements = Array.isArray(arr) ? arr.filter(e => e && typeof e.id === 'string' && typeof e.type === 'string') .map(e => ({ ...e, parentId: e.parentId || null })) : []; // Чистим висящие parentId на несуществующих или не-frame родителей const ids = new Set(this.elements.map(e => e.id)); for (const el of this.elements) { if (el.parentId && !ids.has(el.parentId)) { el.parentId = null; } else if (el.parentId) { const p = this.elements.find(e => e.id === el.parentId); if (!_isContainer(p)) el.parentId = null; } } this._notify(); } clear() { if (this.elements.length === 0) return; this.elements = []; this._notify(); } } function _defaultName(type, n) { if (type === 'frame') return `Контейнер ${n}`; if (type === 'text') return `Надпись ${n}`; if (type === 'button') return `Кнопка ${n}`; if (type === 'image') return `Картинка ${n}`; if (type === 'textbox') return `Поле ввода ${n}`; if (type === 'scroll') return `Список ${n}`; return `Элемент ${n}`; } function _defaultSize(type) { if (type === 'frame') return { w: 30, h: 20 }; if (type === 'text') return { w: 20, h: 6 }; if (type === 'button') return { w: 18, h: 8 }; if (type === 'image') return { w: 15, h: 15 }; if (type === 'textbox') return { w: 24, h: 8 }; if (type === 'scroll') return { w: 30, h: 40 }; return { w: 20, h: 10 }; } function _defaultText(type) { if (type === 'text') return 'Текст'; if (type === 'button') return 'Кнопка'; return ''; // textbox стартует пустым } function _defaultBgColor(type) { if (type === 'frame') return '#1f1810'; if (type === 'button') return '#5a8c3e'; if (type === 'text') return 'transparent'; if (type === 'image') return 'transparent'; if (type === 'textbox') return '#2a2218'; // поле ввода — тёмный фон if (type === 'scroll') return '#1f1810'; return '#1f1810'; } function _defaultBgOpacity(type) { if (type === 'frame') return 0.85; if (type === 'button') return 0.95; if (type === 'textbox') return 0.95; if (type === 'scroll') return 0.85; return 1; }