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