player/src/engine/FolderManager.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

302 lines
12 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.

/**
* FolderManager — папки/группы в иерархии сцены.
*
* Папка — логическое объединение объектов (блоков/моделей/примитивов).
* Объект помещается в папку записью `folderId` в его metadata
* (через FolderManager.assignToFolder).
*
* Структура папки:
* { id, name, parentId, visible }
*
* parentId === null → папка в корне.
* folderId === null → объект в корне сцены.
*
* Public API:
* createFolder(name, parentId?) → id
* renameFolder(id, name)
* removeFolder(id, deleteContent?) → удаляет папку (и контент опционально)
* setVisible(id, visible) — скрыть/показать вместе с объектами
* assignToFolder(kind, ref, folderId) — переместить блок/модель/прим в папку
* getFolder(id) / getAll()
* getChildrenOf(folderId) — { folders, blocks, models, primitives }
* getRootChildren() — что в корне (folderId === null)
* serialize() / loadFromArray(arr)
*/
export class FolderManager {
constructor(blockManager, modelManager, primitiveManager) {
this.blockManager = blockManager;
this.modelManager = modelManager;
this.primitiveManager = primitiveManager;
this.folders = new Map(); // id → folder data
this._nextId = 1;
this._onChange = null;
}
setOnChange(cb) { this._onChange = cb; }
_notifyChange() { if (this._onChange) this._onChange(); }
createFolder(name = 'Новая папка', parentId = null) {
const id = this._nextId++;
this.folders.set(id, { id, name, parentId, visible: true });
this._notifyChange();
return id;
}
getFolder(id) {
return this.folders.get(id) || null;
}
getAll() {
return Array.from(this.folders.values());
}
renameFolder(id, name) {
const f = this.folders.get(id);
if (!f) return;
f.name = name;
this._notifyChange();
}
/**
* Удалить папку. Если deleteContent=true — содержимое стирается со сцены,
* иначе содержимое поднимается в parent папку (или в корень).
*/
removeFolder(id, deleteContent = false) {
const f = this.folders.get(id);
if (!f) return;
// Сначала рекурсивно: подпапки.
const childFolders = this.getAll().filter(x => x.parentId === id);
for (const cf of childFolders) {
if (deleteContent) this.removeFolder(cf.id, true);
else this._setFolderParent(cf.id, f.parentId);
}
// Объекты в папке — в parent или удалить.
if (this.blockManager) {
for (const mesh of this.blockManager.blocks.values()) {
if (mesh.metadata?.folderId === id) {
if (deleteContent) {
this.blockManager.removeBlock(mesh.metadata.gridX, mesh.metadata.gridY, mesh.metadata.gridZ);
} else {
mesh.metadata.folderId = f.parentId;
}
}
}
}
if (this.modelManager) {
for (const data of this.modelManager.instances.values()) {
if (data.folderId === id) {
if (deleteContent) this.modelManager.removeInstance(data.instanceId);
else data.folderId = f.parentId;
}
}
}
if (this.primitiveManager) {
for (const data of this.primitiveManager.instances.values()) {
if (data.folderId === id) {
if (deleteContent) this.primitiveManager.removeInstance(data.id);
else data.folderId = f.parentId;
}
}
}
this.folders.delete(id);
this._notifyChange();
}
_setFolderParent(id, parentId) {
const f = this.folders.get(id);
if (!f) return;
f.parentId = parentId;
}
/** Поменять видимость папки (рекурсивно вместе с подпапками и контентом). */
setVisible(id, visible) {
const f = this.folders.get(id);
if (!f) return;
f.visible = visible;
this._applyVisibility(id, this._effectiveVisible(id));
this._notifyChange();
}
/** Эффективная видимость = AND по цепочке родителей. */
_effectiveVisible(folderId) {
let cur = this.folders.get(folderId);
while (cur) {
if (!cur.visible) return false;
cur = cur.parentId != null ? this.folders.get(cur.parentId) : null;
}
return true;
}
/** Применить видимость к содержимому (включая вложенные папки). */
_applyVisibility(folderId, visible) {
// Объекты в этой папке
if (this.blockManager) {
for (const mesh of this.blockManager.blocks.values()) {
if (mesh.metadata?.folderId === folderId) {
// Для proxy-блоков (thin instance) — через setBlockProps
if (mesh._isBlockProxy) {
this.blockManager.setBlockProps(
mesh.metadata.gridX, mesh.metadata.gridY, mesh.metadata.gridZ,
{ visible }
);
} else if (typeof mesh.setEnabled === 'function') {
mesh.setEnabled(visible);
}
}
}
}
if (this.modelManager) {
for (const data of this.modelManager.instances.values()) {
if (data.folderId === folderId && data.rootMesh) {
data.rootMesh.setEnabled(visible);
}
}
}
if (this.primitiveManager) {
for (const data of this.primitiveManager.instances.values()) {
if (data.folderId === folderId && data.mesh) {
// Учитываем собственный visible-флаг примитива
data.mesh.setEnabled(visible && data.visible !== false);
}
}
}
// Подпапки
for (const cf of this.getAll()) {
if (cf.parentId === folderId) {
this._applyVisibility(cf.id, visible && cf.visible);
}
}
}
/**
* Поместить объект в папку (или вынуть в корень при folderId=null).
* kind: 'block' | 'model' | 'primitive'
* ref:
* block → {x,y,z}
* model → instanceId
* primitive → primitive id
*/
assignToFolder(kind, ref, folderId) {
if (kind === 'block' && this.blockManager) {
const mesh = this.blockManager.blocks.get(`${ref.x},${ref.y},${ref.z}`);
if (mesh) {
if (!mesh.metadata) mesh.metadata = {};
mesh.metadata.folderId = folderId;
mesh.setEnabled(folderId == null ? true : this._effectiveVisible(folderId));
}
} else if (kind === 'model' && this.modelManager) {
const data = this.modelManager.instances.get(ref);
if (data) {
data.folderId = folderId;
if (data.rootMesh) data.rootMesh.setEnabled(folderId == null ? true : this._effectiveVisible(folderId));
}
} else if (kind === 'primitive' && this.primitiveManager) {
const data = this.primitiveManager.instances.get(ref);
if (data) {
data.folderId = folderId;
if (data.mesh) {
const folderVisible = folderId == null ? true : this._effectiveVisible(folderId);
data.mesh.setEnabled(folderVisible && data.visible !== false);
}
}
}
this._notifyChange();
}
/**
* Повернуть все примитивы папки вокруг точки pivot на угол angle (рад) по Y.
* Только примитивы (блоки и модели не вращаем — у блоков нет ротации,
* для моделей удобнее делать через ModelManager напрямую).
* pivot: {x, z} — точка вокруг которой вращаем (обычно центр группы).
* Возвращает количество повёрнутых примитивов.
*/
rotateFolderY(folderId, angle, pivot) {
if (!this.primitiveManager || !pivot) return 0;
const cosA = Math.cos(angle);
const sinA = Math.sin(angle);
let count = 0;
for (const data of this.primitiveManager.instances.values()) {
if (data.folderId !== folderId) continue;
// Поворачиваем позицию вокруг pivot.y axis (XZ-плоскость)
const dx = data.x - pivot.x;
const dz = data.z - pivot.z;
const newX = pivot.x + dx * cosA - dz * sinA;
const newZ = pivot.z + dx * sinA + dz * cosA;
data.x = newX;
data.z = newZ;
data.rotationY = (data.rotationY || 0) + angle;
if (data.mesh) {
data.mesh.position.set(newX, data.y, newZ);
data.mesh.rotation.y = data.rotationY;
if (data._worldMatrixFrozen) {
try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
data._worldMatrixFrozen = false;
}
}
count++;
}
this._notifyChange();
return count;
}
/**
* Установить АБСОЛЮТНЫЙ угол поворота папки (вокруг pivot).
* Папка хранит свой текущий yaw в `_yaw` (по умолчанию 0).
* Вращает на разницу angle - _yaw.
*/
setFolderYawY(folderId, angle, pivot) {
const f = this.folders.get(folderId);
if (!f) return 0;
const prev = f._yaw || 0;
const delta = angle - prev;
if (Math.abs(delta) < 0.0001) return 0;
const count = this.rotateFolderY(folderId, delta, pivot);
f._yaw = angle;
return count;
}
/** Найти папку по имени (regex/exact). */
findByName(name) {
const n = String(name || '').toLowerCase();
for (const f of this.folders.values()) {
if (String(f.name).toLowerCase() === n) return f;
}
return null;
}
serialize() {
return Array.from(this.folders.values()).map(f => ({
id: f.id, name: f.name, parentId: f.parentId, visible: f.visible,
}));
}
loadFromArray(arr) {
this.clear();
let maxId = 0;
for (const f of arr || []) {
this.folders.set(f.id, {
id: f.id, name: f.name,
parentId: f.parentId ?? null,
visible: f.visible !== false,
});
if (f.id > maxId) maxId = f.id;
}
this._nextId = maxId + 1;
this._notifyChange();
}
clear() {
this.folders.clear();
this._nextId = 1;
}
dispose() {
this.clear();
this._onChange = null;
}
}