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)
302 lines
12 KiB
JavaScript
302 lines
12 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
}
|