player/src/engine/GuiManager.js
МИН 87444ee2c8 Initial public release: Rublox Player v1.0
Open-source web player for Rublox games, dual-licensed under
AGPL-3.0 + Commercial.

Highlights:
- Babylon.js 7 + React 18 + Vite 5 stack
- Self-contained engine (~46k lines): BlockManager, ModelManager,
  PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD
  gamemodes
- Configurable backend via VITE_API_BASE and friends — works against
  staging (dev-api.rublox.pro) out of the box
- Standalone mode (VITE_STANDALONE=true) loads a bundled sample game
  for first-run without any backend
- Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG
- Lint + format scaffolding (ESLint + Prettier + EditorConfig)
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Removed before public release:
- frontend_deploy.py (contained production SSH credentials)
- ~27 admin endpoints (kept in private repo)
- Hard-coded internal URLs and IPs
- All previous git history (clean repo init)
2026-05-27 23:04:04 +03:00

304 lines
13 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.

/**
* 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<object>} */
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',
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, для frame/scroll):
// 'none' — дети как есть (по своим x/y);
// 'vertical' — дети в столбик; 'horizontal' — в строку.
layout: opts.layout ?? 'none',
// Отступ между детьми и внутреннее поле контейнера (в % размера контейнера).
layoutGap: opts.layoutGap ?? 2,
layoutPad: opts.layoutPad ?? 3,
// Текущая прокрутка scroll-контейнера (в %, только для type='scroll').
scrollY: opts.scrollY ?? 0,
// Тень под элементом (Фаза 5.4) — мягкая drop-shadow.
shadow: opts.shadow ?? false,
// Создан скриптом в 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;
}