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

688 lines
33 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.

/**
* UserModelManager — менеджер пользовательских воксельных моделей в сцене проекта.
*
* Этап 5 редактора моделей: постановка собственных voxel-моделей пользователя
* (созданных через ModelEditorScreen) в сцену игры.
*
* Архитектура (упрощённый baseline-путь; greedy meshing — позже):
* - Каждый инстанс = один объединённый Mesh, собранный из всех voxel-кубов модели
* - Internal-face culling: грани между двумя соседними voxels не рисуем
* - Vertex-color per face: цвет/текстура запекается в материале на грань
* - Кэш model_data по userModelId: грузим из API один раз
*
* Формат внутри model_data:
* { version:2, size:N, voxels:[{x,y,z,c:'#RRGGBB'} | {x,y,z,t:'grass'}] }
*
* Использование:
* const mgr = new UserModelManager(scene);
* mgr.setApi({ getUserModel }); // подключение API-функции
* const instId = await mgr.addInstance('user:42', x, y, z, rotationY);
* mgr.removeInstance(instId);
*
* Изменения в БД хранилище:
* model_data → JSON с voxels
* thumbnail_b64 → preview для Toolbox
*
* При сохранении проекта (project_data) пользовательские инстансы
* сериализуются как { type: 'user:42', x, y, z, ry } — id используется
* для повторной загрузки model_data при открытии проекта.
*/
import {
Mesh, VertexData, StandardMaterial, Texture, Color3, Color4, Vector3,
TransformNode, VertexBuffer,
} from '@babylonjs/core';
import { decodeVoxelModel } from './voxelModelCodec';
/** Размер voxel-кубика модели в мире (метров).
* Должен совпадать с VOXEL_SIZE редактора моделей. */
const VOXEL_SIZE = 0.0625;
/** Базовый URL текстур — для voxel'ов с texture-id. */
const TEX_BASE = '/kubikon-assets/textures';
/** Каталог текстур — id → URL. Должен совпадать с VOXEL_TEXTURES в редакторе. */
const TEXTURES = {
grass: `${TEX_BASE}/grass_top.png`,
stone: `${TEX_BASE}/greystone.png`,
dirt: `${TEX_BASE}/dirt.png`,
sand: `${TEX_BASE}/sand.png`,
snow: `${TEX_BASE}/snow.png`,
wood: `${TEX_BASE}/wood.png`,
leaves: `${TEX_BASE}/leaves.png`,
trunk: `${TEX_BASE}/trunk_side.png`,
water: `${TEX_BASE}/water.png`,
ice: `${TEX_BASE}/ice.png`,
gravel: `${TEX_BASE}/gravel_dirt.png`,
brick_red: `${TEX_BASE}/brick_red.png`,
brick_grey: `${TEX_BASE}/brick_grey.png`,
};
/** Конвертация '#RRGGBB' → Color4(0..1, 1). */
function hexToColor4(hex) {
if (!hex || typeof hex !== 'string') return new Color4(1, 1, 1, 1);
const m = hex.trim().match(/^#?([0-9a-fA-F]{6})$/);
if (!m) return new Color4(1, 1, 1, 1);
const n = parseInt(m[1], 16);
return new Color4(
((n >> 16) & 0xff) / 255,
((n >> 8) & 0xff) / 255,
(n & 0xff) / 255,
1,
);
}
/** Префикс id пользовательских моделей. */
export const USER_MODEL_PREFIX = 'user:';
/** Извлечь numeric id из 'user:42'. */
export function parseUserModelId(modelTypeId) {
if (typeof modelTypeId !== 'string') return null;
if (!modelTypeId.startsWith(USER_MODEL_PREFIX)) return null;
const n = parseInt(modelTypeId.slice(USER_MODEL_PREFIX.length), 10);
return isNaN(n) ? null : n;
}
/**
* Описание граней куба для face-culling алгоритма.
* Для каждой стороны: смещение к соседу + 4 vertex-positions грани (CCW от +нормали)
* + нормаль для shading + shade — запечённый множитель яркости грани.
*
* shade — лёгкий запечённый множитель яркости грани (Minecraft-style):
* верх самый светлый, бока чуть темнее, низ ещё темнее. Значения близки
* к 1.0 — это даёт МЯГКИЙ объём; основной контраст даёт освещение сцены.
* Слишком тёмные значения (0.5 и ниже) при включённом освещении делали
* грани почти чёрными — поэтому диапазон сжат к светлому.
*/
const FACES = [
// +X (бок)
{ dx: 1, dy: 0, dz: 0, n: [1, 0, 0], shade: 0.90,
verts: [[1,0,0],[1,1,0],[1,1,1],[1,0,1]] },
// -X (бок)
{ dx: -1, dy: 0, dz: 0, n: [-1, 0, 0], shade: 0.90,
verts: [[0,0,1],[0,1,1],[0,1,0],[0,0,0]] },
// +Y (верх — самый светлый)
{ dx: 0, dy: 1, dz: 0, n: [0, 1, 0], shade: 1.00,
verts: [[0,1,0],[0,1,1],[1,1,1],[1,1,0]] },
// -Y (низ — чуть темнее)
{ dx: 0, dy: -1, dz: 0, n: [0, -1, 0], shade: 0.78,
verts: [[0,0,1],[0,0,0],[1,0,0],[1,0,1]] },
// +Z (бок — чуть темнее X для разницы граней)
{ dx: 0, dy: 0, dz: 1, n: [0, 0, 1], shade: 0.84,
verts: [[1,0,1],[1,1,1],[0,1,1],[0,0,1]] },
// -Z (бок)
{ dx: 0, dy: 0, dz: -1, n: [0, 0, -1], shade: 0.84,
verts: [[0,0,0],[0,1,0],[1,1,0],[1,0,0]] },
];
/**
* UV-координаты для грани куба (стандартный quad 0,0 → 1,0 → 1,1 → 0,1).
* Используется только для voxels с текстурой.
*/
const FACE_UVS = [[0,0],[1,0],[1,1],[0,1]];
/**
* Скомпилировать voxels из model_data в готовые VertexData для Babylon.
* Возвращает Map<materialKey, { positions, indices, normals, colors, uvs }>.
*
* materialKey:
* 'solid' — voxels с цветом (используется vertex color)
* 'tex:<id>' — voxels с текстурой <id>
*
* Каждая materialKey → отдельный submesh с отдельным StandardMaterial.
*/
export function compileVoxelModel(modelData) {
// Кодек читает v3 (компактный), v2 и v1 — единая точка разбора формата.
const decoded = decodeVoxelModel(modelData);
if (!decoded || decoded.voxels.length === 0) return null;
const data = { size: decoded.size, voxels: decoded.voxels };
// Карта occupancy — для быстрого face-culling
const occ = new Map(); // 'x,y,z' → voxel{c|t}
for (const v of data.voxels) {
if (typeof v.x !== 'number') continue;
occ.set(`${v.x | 0},${v.y | 0},${v.z | 0}`, v);
}
// Группируем faces по material — на каждый материал отдельный mesh
// (нельзя смешивать textured/solid faces в один меш с одним материалом).
const buckets = new Map(); // materialKey → { positions[], indices[], normals[], colors[], uvs[] }
const getBucket = (key) => {
let b = buckets.get(key);
if (!b) {
b = { positions: [], indices: [], normals: [], colors: [], uvs: [] };
buckets.set(key, b);
}
return b;
};
const S = VOXEL_SIZE;
for (const v of data.voxels) {
const x = v.x | 0, y = v.y | 0, z = v.z | 0;
const matKey = v.t ? `tex:${v.t}` : 'solid';
const color = v.t ? null : hexToColor4(v.c || '#ffffff');
const bucket = getBucket(matKey);
for (const face of FACES) {
// Face culling: если сосед в направлении нормали есть — грань скрыта.
const nx = x + face.dx, ny = y + face.dy, nz = z + face.dz;
if (occ.has(`${nx},${ny},${nz}`)) continue;
const baseIdx = bucket.positions.length / 3;
const sh = face.shade;
for (let i = 0; i < 4; i++) {
const vx = face.verts[i][0] + x;
const vy = face.verts[i][1] + y;
const vz = face.verts[i][2] + z;
bucket.positions.push(vx * S, vy * S, vz * S);
bucket.normals.push(face.n[0], face.n[1], face.n[2]);
bucket.uvs.push(FACE_UVS[i][0], FACE_UVS[i][1]);
if (color) {
// Запекаем face-shading прямо в vertex color.
bucket.colors.push(color.r * sh, color.g * sh, color.b * sh, 1);
} else {
// Текстурные voxels: vertex color = серый по shade,
// материал домножит текстуру на него (useVertexColors).
bucket.colors.push(sh, sh, sh, 1);
}
}
// Два треугольника на quad
bucket.indices.push(baseIdx, baseIdx + 1, baseIdx + 2);
bucket.indices.push(baseIdx, baseIdx + 2, baseIdx + 3);
}
}
return buckets;
}
/**
* Построить воксельную маску коллизий из model_data.
*
* Маска — это компактная occupancy-сетка по воксельным индексам модели.
* Используется PhysicsAABB для narrow-фазы коллизии: вместо одного грубого
* AABB-параллелепипеда проверяется пересечение игрока с конкретными
* занятыми вокселями. Это позволяет ходить по «дорожке» арочного моста,
* заходить в проёмы и т.п. — там где раньше был сплошной короб.
*
* Производительность:
* - Строится ОДИН раз на модель (кэшируется в _dataCache рядом с data).
* - Хранится как Uint8Array (1 байт/воксель) — для модели 64³ это 256 КБ,
* для типовой 32³ — 32 КБ. Доступ к ячейке O(1) по индексу.
* - narrow-тест в физике итерирует ТОЛЬКО воксели в пределах AABB игрока
* (обычно 1-3 слоя на касании), не всю модель.
*
* @returns {object|null} { sx, sy, sz, minX, minY, minZ, grid:Uint8Array, count }
* sx/sy/sz — размеры сетки в вокселях; minX/Y/Z — индекс нижнего угла bbox;
* grid[idx] = 1 если воксель занят; count — число занятых вокселей.
*/
export function buildVoxelMask(modelData) {
// Кодек читает v3 (компактный), v2 и v1.
const decoded = decodeVoxelModel(modelData);
if (!decoded || decoded.voxels.length === 0) return null;
const data = { size: decoded.size, voxels: decoded.voxels };
// Границы по занятым вокселям.
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (const v of data.voxels) {
if (typeof v.x !== 'number') continue;
const x = v.x | 0, y = v.y | 0, z = v.z | 0;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
if (z < minZ) minZ = z;
if (z > maxZ) maxZ = z;
}
if (!isFinite(minX)) return null;
const sx = maxX - minX + 1;
const sy = maxY - minY + 1;
const sz = maxZ - minZ + 1;
// Защита от абсурдных размеров (битый model_data).
if (sx <= 0 || sy <= 0 || sz <= 0 || sx * sy * sz > 4_000_000) return null;
const grid = new Uint8Array(sx * sy * sz);
let count = 0;
for (const v of data.voxels) {
if (typeof v.x !== 'number') continue;
const x = (v.x | 0) - minX;
const y = (v.y | 0) - minY;
const z = (v.z | 0) - minZ;
const idx = (y * sz + z) * sx + x;
if (grid[idx] === 0) {
grid[idx] = 1;
count++;
}
}
return { sx, sy, sz, minX, minY, minZ, grid, count };
}
/**
* UserModelManager — управляет инстансами пользовательских моделей в сцене.
*/
export class UserModelManager {
constructor(scene) {
this.scene = scene;
this._api = null;
// Кэш прото-данных (model_data из API) по userModelId.
this._dataCache = new Map(); // number → modelDataString
// Кэш воксельных масок коллизий по userModelId. Маска одинакова для
// всех инстансов модели — строим один раз, переиспользуем.
this._maskCache = new Map(); // number → { sx,sy,sz,minX,minY,minZ,grid,count }
// Активные инстансы. id → { rootNode, userModelId, meshes:[Mesh] }
this.instances = new Map();
this._nextInstanceId = 1;
// Кэш материалов — переиспользуем StandardMaterial между инстансами.
this._matCache = new Map(); // matKey → StandardMaterial
}
/** Получить (с кэшем) воксельную маску коллизий для userModelId.
* Строится один раз из model_data, переиспользуется всеми инстансами. */
_getVoxelMask(userModelId, modelData) {
if (this._maskCache.has(userModelId)) {
return this._maskCache.get(userModelId);
}
const mask = buildVoxelMask(modelData);
this._maskCache.set(userModelId, mask); // кэшируем и null (битый data)
return mask;
}
setApi(api) {
this._api = api;
}
/** Получить (с кэшем) model_data для userModelId. */
async _loadModelData(userModelId, currentUserId = null) {
if (this._dataCache.has(userModelId)) {
return this._dataCache.get(userModelId);
}
if (!this._api || !this._api.getUserModel) {
console.warn('[UserModelManager] API not configured');
return null;
}
try {
const res = await this._api.getUserModel(userModelId, currentUserId);
const data = res.data?.model_data;
if (!data) return null;
this._dataCache.set(userModelId, data);
return data;
} catch (err) {
console.warn('[UserModelManager] failed to load model', userModelId, err);
return null;
}
}
/** Получить (с кэшем) материал по matKey.
*
* ЯРКОСТЬ И ОБЪЁМ:
* Объём кубиков задаётся запечённым face-shading в vertex colors
* (см. FACES[].shade — верх светлее, бока темнее). Материал —
* ОБЫЧНЫЙ StandardMaterial с useVertexColors: vertex colors красят
* diffuse, освещение сцены даёт картинку. Дополнительно небольшой
* emissiveColor «подсвечивает» модель, чтобы цвета были яркие и не
* тусклые. НЕ используем disableLighting — он отключает vertex
* colors целиком (они часть lighting pipeline) → модель чернеет.
*/
_getMaterial(matKey) {
let m = this._matCache.get(matKey);
if (m) return m;
m = new StandardMaterial(`userModel_${matKey}`, this.scene);
m.specularColor = new Color3(0, 0, 0);
// ВАЖНО: двусторонний рендер — winding треугольников нашего
// ручного VertexData может оказаться "обратным" в left-handed сцене.
m.backFaceCulling = false;
m.twoSidedLighting = true;
if (matKey === 'solid') {
// diffuse=white → vertex colors (с запечённым face-shading)
// полностью определяют цвет под освещением сцены.
m.diffuseColor = new Color3(1, 1, 1);
// Лёгкая эмиссия — поднимает яркость, цвета не выглядят тускло.
m.emissiveColor = new Color3(0.32, 0.32, 0.32);
} else if (matKey.startsWith('tex:')) {
const texId = matKey.slice(4);
const url = TEXTURES[texId];
m.diffuseColor = new Color3(1, 1, 1);
m.emissiveColor = new Color3(0.32, 0.32, 0.32);
if (url) {
try {
const t = new Texture(url, this.scene);
t.updateSamplingMode(Texture.NEAREST_NEAREST_MIPNEAREST);
m.diffuseTexture = t;
// Эмиссия по той же текстуре — подсветка не «вымывает»
// рисунок, а равномерно поднимает яркость.
m.emissiveTexture = t;
} catch (e) {
m.diffuseColor = new Color3(1, 0.4, 0.8); // fallback magenta
}
} else {
m.diffuseColor = new Color3(1, 0.4, 0.8);
}
}
this._matCache.set(matKey, m);
return m;
}
/**
* Поставить инстанс пользовательской модели в сцену.
* @param {string} userModelTypeId — 'user:42'
* @param {number} x,y,z — мировые координаты НИЖНЕЙ-СЕВЕРО-ЗАПАДНОЙ точки voxel-bbox
* @param {number} rotationY — поворот вокруг Y (радианы)
* @param {object} options
* @param {number} options.currentUserId — для приватных моделей нужен (auth-check)
* @returns {Promise<number|null>} instanceId
*/
async addInstance(userModelTypeId, x, y, z, rotationY = 0, options = {}) {
const userModelId = parseUserModelId(userModelTypeId);
if (userModelId == null) {
console.warn('[UserModelManager] invalid id:', userModelTypeId);
return null;
}
const modelData = await this._loadModelData(userModelId, options.currentUserId);
if (!modelData) return null;
const buckets = compileVoxelModel(modelData);
if (!buckets || buckets.size === 0) return null;
// forceInstanceId — явный id из serialize (нужен чтобы target-скрипты
// могли стабильно ссылаться на конкретный userModel-инстанс, напр.
// животных). Если занят/не задан — берём авто-инкремент.
let instanceId;
if (Number.isFinite(options.forceInstanceId)
&& !this.instances.has(options.forceInstanceId)) {
instanceId = options.forceInstanceId;
if (instanceId >= this._nextInstanceId) {
this._nextInstanceId = instanceId + 1;
}
} else {
instanceId = this._nextInstanceId++;
}
const rootName = `userModel_${userModelId}_${instanceId}`;
const root = new TransformNode(rootName, this.scene);
root.position = new Vector3(x, y, z);
root.rotation = new Vector3(0, rotationY, 0);
const meshes = [];
for (const [matKey, geom] of buckets) {
const mesh = new Mesh(`${rootName}_${matKey}`, this.scene);
const vd = new VertexData();
vd.positions = geom.positions;
vd.indices = geom.indices;
vd.normals = geom.normals;
vd.colors = geom.colors;
vd.uvs = geom.uvs;
vd.applyToMesh(mesh, false);
mesh.material = this._getMaterial(matKey);
// Включаем per-vertex color для StandardMaterial
mesh.hasVertexAlpha = false;
mesh.useVertexColors = true;
mesh.isPickable = true;
mesh.metadata = {
isUserModel: true,
userModelId,
instanceId,
};
mesh.parent = root;
meshes.push(mesh);
}
// Воксельная маска коллизий (кэш по userModelId — одна на модель).
// Используется PhysicsAABB для narrow-фазы: точная коллизия по
// занятым вокселям вместо одного грубого AABB-короба.
const voxelMask = this._getVoxelMask(userModelId, modelData);
const data = {
instanceId,
userModelId,
userModelTypeId,
rootNode: root,
meshes,
x, y, z, rotationY,
// Свойства из Inspector — по умолчанию: сталкивается, видим, заякорен.
canCollide: options.canCollide !== false,
visible: options.visible !== false,
anchored: options.anchored !== false,
mass: typeof options.mass === 'number' ? options.mass : 1,
scale: typeof options.scale === 'number' ? options.scale : 1,
// localAABB — кэшируется ниже через _computeLocalAABB.
localAABB: null,
// voxelMask — общая для всех инстансов модели (ссылка на кэш).
voxelMask,
};
this.instances.set(instanceId, data);
// Вычисляем локальный AABB через вершины (для PhysicsAABB).
this._computeLocalAABB(data);
// Применяем visible/scale из options если они пришли при load.
if (options.visible === false) {
try { root.setEnabled(false); } catch (e) {}
for (const m of meshes) { try { m.setEnabled(false); } catch (e) {} }
}
if (typeof options.scale === 'number' && options.scale !== 1) {
root.scaling.x = options.scale;
root.scaling.y = options.scale;
root.scaling.z = options.scale;
}
try { this._onChange?.(); } catch (e) {}
return instanceId;
}
/** Посчитать локальный AABB модели через прямой обход вершин.
* data.localAABB = {minX, maxX, minY, maxY, minZ, maxZ} в локальных
* координатах (без position/rotation/scale).
* Используется PhysicsAABB._aabbIntersectsUserModel для коллизий. */
_computeLocalAABB(data) {
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
let minZ = Infinity, maxZ = -Infinity;
for (const m of data.meshes) {
if (!m || typeof m.getTotalVertices !== 'function') continue;
if (m.getTotalVertices() <= 0) continue;
let positions;
try { positions = m.getVerticesData(VertexBuffer.PositionKind); }
catch (e) { continue; }
if (!positions) continue;
// Vertices в локальных координатах меша. Меш парентится к rootNode,
// позиция meша = (0,0,0) внутри rootNode, поэтому local AABB меша
// = local AABB модели по этому материалу.
for (let i = 0; i < positions.length; i += 3) {
const x = positions[i], y = positions[i + 1], z = positions[i + 2];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
if (z < minZ) minZ = z;
if (z > maxZ) maxZ = z;
}
}
if (isFinite(minX) && isFinite(maxX)) {
data.localAABB = { minX, maxX, minY, maxY, minZ, maxZ };
} else {
// Fallback 1×1×1
data.localAABB = { minX: -0.5, maxX: 0.5, minY: 0, maxY: 1, minZ: -0.5, maxZ: 0.5 };
}
}
/** Удалить инстанс. */
removeInstance(instanceId) {
const inst = this.instances.get(instanceId);
if (!inst) return;
for (const m of inst.meshes) {
try { m.dispose(); } catch (e) {}
}
try { inst.rootNode.dispose(); } catch (e) {}
this.instances.delete(instanceId);
// Уведомляем onChange — внешний код (BabylonScene) дёрнет
// physics.setSpatialDirty() + onSceneChange.
try { this._onChange?.(); } catch (e) {}
}
/** Подписка на изменения (BabylonScene использует для setSpatialDirty / markDirty). */
setOnChange(cb) { this._onChange = cb; }
/** Инвалидировать модель после её редактирования.
* Сбрасывает кэш model_data → следующий addInstance возьмёт свежие данные.
* Если опция rebuild=true (по умолчанию) — пересоздаёт ВСЕ существующие
* инстансы этой модели в сцене с новой геометрией.
*
* @param {number} userModelId — id из БД (без 'user:'-префикса)
* @param {object} options
* @param {boolean} options.rebuild — пересоздать существующие инстансы (default true)
* @param {number} options.currentUserId — для приватных моделей
* @returns {Promise<number>} количество пересозданных инстансов
*/
async invalidateModel(userModelId, options = {}) {
const { rebuild = true, currentUserId = null } = options;
// Сбрасываем кэш data — следующий addInstance загрузит свежие данные.
// Маску коллизий тоже сбрасываем — модель изменилась, форма другая.
this._dataCache.delete(userModelId);
this._maskCache.delete(userModelId);
if (!rebuild) return 0;
// Собираем существующие инстансы ЭТОЙ модели — нужно запомнить их
// позицию/поворот/свойства, удалить, заново создать с новой геометрией.
const matches = [];
for (const inst of this.instances.values()) {
if (inst.userModelId === userModelId) {
matches.push({
instanceId: inst.instanceId,
userModelTypeId: inst.userModelTypeId,
x: inst.x, y: inst.y, z: inst.z,
rotationY: inst.rotationY,
scale: inst.scale,
canCollide: inst.canCollide,
visible: inst.visible,
anchored: inst.anchored,
mass: inst.mass,
});
}
}
if (matches.length === 0) return 0;
// Удаляем старые инстансы (это вызовет _onChange после каждого).
// Сохранять выделение бесполезно — instanceId сменится.
for (const m of matches) {
this.removeInstance(m.instanceId);
}
// Создаём заново — каждый получит новый instanceId.
// _loadModelData теперь подтянет model_data заново (кэш сброшен).
let rebuilt = 0;
for (const m of matches) {
const newId = await this.addInstance(
m.userModelTypeId, m.x, m.y, m.z, m.rotationY,
{
currentUserId,
scale: m.scale,
canCollide: m.canCollide,
visible: m.visible,
anchored: m.anchored,
mass: m.mass,
},
);
if (newId != null) rebuilt++;
}
return rebuilt;
}
/** Сериализация инстансов для сохранения в project_data. */
serialize() {
const arr = [];
for (const inst of this.instances.values()) {
arr.push({
type: inst.userModelTypeId,
x: inst.x, y: inst.y, z: inst.z,
ry: inst.rotationY,
scale: inst.scale ?? 1,
canCollide: inst.canCollide !== false,
visible: inst.visible !== false,
anchored: inst.anchored !== false,
mass: inst.mass ?? 1,
// instanceId — чтобы target-скрипты могли стабильно ссылаться
// на конкретный инстанс после перезагрузки.
instanceId: inst.instanceId,
});
}
return arr;
}
/** Восстановить инстансы из serialize-формата.
*
* ОПТИМИЗАЦИЯ ЗАГРУЗКИ: раньше инстансы грузились строго последовательно
* (await addInstance в цикле) — N моделей = N HTTP-запросов один за
* другим, на старте игры это давало секунды задержки.
*
* Теперь — две фазы:
* 1. PREFETCH: собираем уникальные userModelId, грузим их model_data
* пачками с ОГРАНИЧЕННЫМ параллелизмом (PREFETCH_CONCURRENCY).
* ВАЖНО: нельзя грузить все разом через Promise.all — браузер
* лимитирует ~6 соединений к домену, и 17 параллельных запросов
* забивают пул → другие запросы (напр. открытие модели в
* редакторе) висят до таймаута. Пачки по 4 — быстро и безопасно.
* 2. BUILD: создаём инстансы из уже-прогретого кэша — addInstance не
* делает сеть (данные в _dataCache), только строит меши.
*/
async loadFromArray(arr, options = {}) {
if (!Array.isArray(arr)) return 0;
// --- Фаза 1: prefetch model_data пачками (bounded concurrency) ---
const PREFETCH_CONCURRENCY = 4;
const uniqueIds = [];
const seenIds = new Set();
for (const item of arr) {
const uid = parseUserModelId(item.type);
if (uid != null && !seenIds.has(uid)) {
seenIds.add(uid);
uniqueIds.push(uid);
}
}
for (let i = 0; i < uniqueIds.length; i += PREFETCH_CONCURRENCY) {
const batch = uniqueIds.slice(i, i + PREFETCH_CONCURRENCY);
await Promise.all(
batch.map((uid) =>
this._loadModelData(uid, options.currentUserId)
.catch((e) => {
console.warn('[UserModelManager] prefetch failed', uid, e);
return null;
}),
),
);
}
// --- Фаза 2: создание инстансов (model_data уже в кэше) ---
let loaded = 0;
for (const item of arr) {
try {
const id = await this.addInstance(
item.type, item.x, item.y, item.z, item.ry || 0,
{
...options,
scale: item.scale,
canCollide: item.canCollide,
visible: item.visible,
anchored: item.anchored,
mass: item.mass,
forceInstanceId: item.instanceId,
},
);
if (id != null) loaded++;
} catch (e) {
console.warn('[UserModelManager] failed to load instance', item, e);
}
}
return loaded;
}
/** Полная очистка — все инстансы убраны, кэш сброшен. */
clear() {
for (const inst of this.instances.values()) {
for (const m of inst.meshes) { try { m.dispose(); } catch (e) {} }
try { inst.rootNode.dispose(); } catch (e) {}
}
this.instances.clear();
}
/** Количество активных инстансов. */
getInstanceCount() {
return this.instances.size;
}
}