All checks were successful
faceUV для куба (кружки одного размера на всех гранях) + studDensity (плотность кружков) — портировано из студии. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
975 lines
48 KiB
JavaScript
975 lines
48 KiB
JavaScript
/**
|
||
* PrimitiveManager — параметрические 3D-примитивы (как Part в Roblox).
|
||
*
|
||
* Каждый инстанс — отдельный mesh с настраиваемыми:
|
||
* - тип (cube/sphere/cylinder/cone/plane/torus/trigger/checkpoint)
|
||
* - позиция (x, y, z)
|
||
* - размер (sx, sy, sz)
|
||
* - цвет (#hex)
|
||
* - материал ('matte' | 'metal' | 'glass' | 'neon')
|
||
* - canCollide (bool) — участвует ли в физике коллизий
|
||
* - visible (bool) — рисуется ли (anchored — пока заготовка)
|
||
*
|
||
* Хранится в `instances: Map<id, {mesh, ...metadata}>`.
|
||
*
|
||
* Triggers — отдельный slice через kind='trigger': mesh полупрозрачный
|
||
* жёлтый куб (виден в редакторе, скрыт в Play), не участвует в коллизиях.
|
||
*
|
||
* Checkpoints — kind='checkpoint': зелёный полупрозрачный цилиндр.
|
||
* При касании игроком обновляет spawnPoint сцены.
|
||
*/
|
||
import {
|
||
MeshBuilder, StandardMaterial, Color3, Vector3, Vector4, PointLight,
|
||
Mesh, VertexData,
|
||
} from '@babylonjs/core';
|
||
// CRA→Vite адаптация: оригинальный код в setTexture()/_applyMaterial()/
|
||
// _applyAssetTexture() использует require('@babylonjs/core/.../texture').
|
||
// require в Vite/ESM не работает (только в CRA/Webpack) → throw → catch →
|
||
// текстура НЕ накладывается, у материала остаётся дефолтный
|
||
// розово-чёрный паттерн «missing texture». Из-за этого все 444 примитива
|
||
// в Squid-Game (id=45) выглядели как красно-чёрная шахматка.
|
||
// Импортим статически.
|
||
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
|
||
import { Texture } from '@babylonjs/core/Materials/Textures/texture';
|
||
import { getPrimitiveType } from './PrimitiveTypes';
|
||
|
||
// === Материал «studs» (лего-кружки, задача 09) — паритет со студией ===
|
||
const STUDS_DIFFUSE_URL = '/kubikon-assets/materials/studs_v4_diffuse.png';
|
||
const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_v4_normal.png';
|
||
const STUD_UNIT = 1;
|
||
const STUDS_GRID = 4;
|
||
const _studsTexCache = new WeakMap();
|
||
function _getStudsTextures(scene) {
|
||
let c = _studsTexCache.get(scene);
|
||
if (!c) {
|
||
c = { diffuse: new Texture(STUDS_DIFFUSE_URL, scene), normal: new Texture(STUDS_NORMAL_URL, scene) };
|
||
_studsTexCache.set(scene, c);
|
||
}
|
||
return c;
|
||
}
|
||
function _studsTiling(type, sx, sy, sz, density) {
|
||
// density — множитель плотности кружков (1=стандарт, 2=вдвое мельче/чаще).
|
||
const d = density && density > 0 ? density : 1;
|
||
const f = (STUD_UNIT * STUDS_GRID) / d;
|
||
let u = Math.max(sx, sz) / f;
|
||
let v = sy / f;
|
||
if (type === 'cylinder') { u = (Math.PI * sx) / f; v = sy / f; }
|
||
else if (type === 'sphere') { u = (Math.PI * sx) / f; v = (Math.PI * sy) / f; }
|
||
else if (type === 'plane') { u = sx / f; v = sz / f; }
|
||
return { u: Math.max(0.25, u), v: Math.max(0.25, v) };
|
||
}
|
||
/**
|
||
* faceUV для куба со studs — КАЖДАЯ грань тайлится по СВОИМ реальным размерам,
|
||
* чтобы кружки были одного размера на всех гранях (не растягивались на длинных).
|
||
* Грани CreateBox: 0=front(z-) 1=back(z+) 2=right(x+) 3=left(x-) 4=top(y+) 5=bottom(y-).
|
||
* front/back → ширина=sx, высота=sy
|
||
* left/right → ширина=sz, высота=sy
|
||
* top/bottom → ширина=sx, высота=sz
|
||
* UV-диапазон грани = (0,0)..(кол-во_studs_по_ширине, кол-во_по_высоте).
|
||
*/
|
||
function _studsCubeFaceUV(sx, sy, sz, density) {
|
||
const d = density && density > 0 ? density : 1;
|
||
const f = (STUD_UNIT * STUDS_GRID) / d;
|
||
const nx = Math.max(0.25, sx / f); // studs вдоль X
|
||
const ny = Math.max(0.25, sy / f); // studs вдоль Y
|
||
const nz = Math.max(0.25, sz / f); // studs вдоль Z
|
||
// Vector4(u0, v0, u1, v1)
|
||
return [
|
||
new Vector4(0, 0, nx, ny), // front (z-): X×Y
|
||
new Vector4(0, 0, nx, ny), // back (z+): X×Y
|
||
new Vector4(0, 0, nz, ny), // right (x+): Z×Y
|
||
new Vector4(0, 0, nz, ny), // left (x-): Z×Y
|
||
new Vector4(0, 0, nx, nz), // top (y+): X×Z
|
||
new Vector4(0, 0, nx, nz), // bottom (y-): X×Z
|
||
];
|
||
}
|
||
|
||
export class PrimitiveManager {
|
||
constructor(scene) {
|
||
this.scene = scene;
|
||
this.instances = new Map(); // id → { mesh, type, x, y, z, sx, sy, sz, color, material, canCollide, visible }
|
||
this._nextId = 1;
|
||
this._onChange = null;
|
||
}
|
||
|
||
setOnChange(cb) { this._onChange = cb; }
|
||
_notifyChange() { if (this._onChange) this._onChange(); }
|
||
|
||
/**
|
||
* Создать примитив. Все поля кроме type — опциональны.
|
||
* Возвращает id или null.
|
||
*/
|
||
addInstance(type, opts = {}) {
|
||
const typeDef = getPrimitiveType(type);
|
||
if (!typeDef) return null;
|
||
|
||
// Если в opts передан id — используем его (нужно для loadFromArray
|
||
// и шаблонов со скриптами, где target.id ссылается на конкретный id).
|
||
// Иначе берём следующий из счётчика.
|
||
let id;
|
||
if (Number.isFinite(opts.id) && opts.id > 0 && !this.instances.has(opts.id)) {
|
||
id = opts.id;
|
||
if (id >= this._nextId) this._nextId = id + 1;
|
||
} else {
|
||
id = this._nextId++;
|
||
}
|
||
const sx = opts.sx ?? typeDef.defaultScale.x;
|
||
const sy = opts.sy ?? typeDef.defaultScale.y;
|
||
const sz = opts.sz ?? typeDef.defaultScale.z;
|
||
const color = opts.color ?? typeDef.defaultColor;
|
||
// GD-сущности (порталы/шипы/финиш/монеты) — neon (светятся) и без коллизии физики.
|
||
// Логику касания обрабатывает GdPortalManager/GdLevelManager по дистанции.
|
||
const isGdKind = typeDef.kind === 'gd_portal'
|
||
|| typeDef.kind === 'gd_finish' || typeDef.kind === 'gd_coin';
|
||
const isGlowingGd = isGdKind;
|
||
const isGdSpike = typeDef.kind === 'gd_spike';
|
||
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
|
||
// studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще).
|
||
const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1;
|
||
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции)
|
||
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
|
||
const visible = opts.visible !== false;
|
||
const anchored = opts.anchored !== false; // по умолчанию заякорен
|
||
// Масса по умолчанию = объём (sx*sy*sz), округлённый до 2 знаков.
|
||
const rawMass = (opts.sx ?? typeDef.defaultScale.x)
|
||
* (opts.sy ?? typeDef.defaultScale.y)
|
||
* (opts.sz ?? typeDef.defaultScale.z);
|
||
const defaultMass = Math.max(0.1, Math.round(rawMass * 100) / 100);
|
||
const mass = opts.mass != null ? Number(opts.mass) : defaultMass;
|
||
const x = opts.x ?? 0;
|
||
const y = opts.y ?? 0.5;
|
||
const z = opts.z ?? 0;
|
||
const rotationX = opts.rotationX ?? 0;
|
||
const rotationY = opts.rotationY ?? 0;
|
||
const rotationZ = opts.rotationZ ?? 0;
|
||
|
||
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity);
|
||
mesh.position = new Vector3(x, y, z);
|
||
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
|
||
mesh.isPickable = true;
|
||
// Тени: примитивы и принимают тени от других объектов, и сами их
|
||
// отбрасывают (через addShadowCaster в refreshAllShadows).
|
||
// Без receiveShadows=true на полу/стенах не видно теней персонажа.
|
||
mesh.receiveShadows = true;
|
||
mesh.metadata = {
|
||
isPrimitive: true,
|
||
primitiveId: id,
|
||
primitiveType: type,
|
||
primitiveKind: typeDef.kind,
|
||
};
|
||
|
||
// textureAsset — id картинки из AssetManager (пользовательская
|
||
// текстура на гранях). Хранится в data, сериализуется, применяется
|
||
// на материал поверх цвета.
|
||
const textureAsset = typeof opts.textureAsset === 'string' ? opts.textureAsset : null;
|
||
|
||
const data = {
|
||
id, mesh, type, x, y, z, sx, sy, sz,
|
||
rotationX, rotationY, rotationZ,
|
||
color, material, canCollide, visible, anchored, mass,
|
||
textureAsset, studDensity,
|
||
// locked — объект защищён от выделения/перемещения в редакторе
|
||
// (Фаза 5.11). На геймплей не влияет.
|
||
locked: opts.locked === true,
|
||
name: opts.name || null,
|
||
folderId: opts.folderId ?? null,
|
||
};
|
||
// Размеры для тайлинга studs-материала (читается в _applyMaterial).
|
||
mesh._studsDims = { type, sx, sy, sz, density: studDensity };
|
||
this._applyMaterial(mesh, typeDef, color, material);
|
||
this._applyVisible(mesh, visible, typeDef);
|
||
// Пользовательская текстура — поверх базового материала.
|
||
if (textureAsset) this._applyAssetTexture(data);
|
||
|
||
// === Лампа: создаём привязанный PointLight ===
|
||
if (typeDef.kind === 'light') {
|
||
// brightness — яркость свечения, range — радиус действия (метры).
|
||
data.brightness = Number.isFinite(opts.brightness) ? opts.brightness : 1.5;
|
||
data.range = Number.isFinite(opts.range) ? opts.range : 12;
|
||
const light = new PointLight(`primLight_${id}`,
|
||
new Vector3(x, y, z), this.scene);
|
||
light.diffuse = Color3.FromHexString(color || '#ffe9a0');
|
||
light.intensity = data.brightness;
|
||
light.range = data.range;
|
||
data.light = light;
|
||
// Маркер-сфера светится (neon-вид) — чтобы лампу было видно.
|
||
if (mesh.material) {
|
||
mesh.material.emissiveColor = Color3.FromHexString(color || '#ffe9a0');
|
||
}
|
||
}
|
||
|
||
// === Эмиттер: создаём постоянную систему частиц ===
|
||
if (typeDef.kind === 'emitter') {
|
||
// effect — тип эффекта: 'fire' | 'smoke' | 'sparks' | 'magic'.
|
||
data.effect = typeof opts.effect === 'string' ? opts.effect : 'fire';
|
||
if (this.scene3d && this.scene3d.createEmitterParticles) {
|
||
data.particles = this.scene3d.createEmitterParticles(
|
||
data.effect, { x, y, z }, color);
|
||
}
|
||
if (mesh.material) {
|
||
mesh.material.emissiveColor = Color3.FromHexString(color || '#ff8833');
|
||
}
|
||
}
|
||
|
||
// === 3D-табличка (billboard): натягиваем DynamicTexture с GUI ===
|
||
if (typeDef.kind === 'billboard' && this.billboardUiManager) {
|
||
// Сохраняем настройки билборда в data.billboardOpts чтобы
|
||
// serialize мог записать их обратно в JSON проекта.
|
||
const billboardOpts = {
|
||
template: opts.template || 'shop-item',
|
||
face: opts.face || 'fixed',
|
||
content: opts.content || null,
|
||
elements: opts.elements || null,
|
||
rotationY: opts.rotationY,
|
||
};
|
||
this.billboardUiManager.applyToMesh(data, billboardOpts);
|
||
// billboardOpts хранится в data.billboard после applyToMesh.
|
||
}
|
||
|
||
this.instances.set(id, data);
|
||
// Авто-регистрация в shadow casters (Этап 4 теней).
|
||
// Когда скрипт спавнит новый объект через scene.spawn(...) — раньше он
|
||
// не отбрасывал тень пока не вызвали refreshAllShadows вручную.
|
||
try {
|
||
if (this.scene3d && typeof this.scene3d.addShadowCaster === 'function') {
|
||
this.scene3d.addShadowCaster(mesh);
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
this._notifyChange();
|
||
return id;
|
||
}
|
||
|
||
/** Создать базовый mesh нужной формы (без материала). */
|
||
_createMeshForType(typeDef, id, sx, sy, sz, material, studDensity) {
|
||
const name = `prim_${typeDef.id}_${id}`;
|
||
switch (typeDef.id) {
|
||
case 'cube':
|
||
case 'trigger': {
|
||
const boxOpts = { width: sx, height: sy, depth: sz };
|
||
// studs — per-face UV, чтобы кружки были одного размера на всех
|
||
// гранях (не растягивались на длинной стороне).
|
||
if (material === 'studs') boxOpts.faceUV = _studsCubeFaceUV(sx, sy, sz, studDensity);
|
||
return MeshBuilder.CreateBox(name, boxOpts, this.scene);
|
||
}
|
||
case 'sphere':
|
||
return MeshBuilder.CreateSphere(name,
|
||
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene);
|
||
case 'cylinder':
|
||
case 'checkpoint':
|
||
// GD-порталы: высокий тонкий цилиндр-«столб» (как в Geometry Dash)
|
||
case 'gd_cube':
|
||
case 'gd_ship':
|
||
case 'gd_ball':
|
||
case 'gd_ufo':
|
||
case 'gd_wave':
|
||
case 'gd_robot':
|
||
return MeshBuilder.CreateCylinder(name,
|
||
{ diameter: sx, height: sy, tessellation: 24 }, this.scene);
|
||
case 'cone':
|
||
case 'gd_spike':
|
||
// GD-шип = тот же cone (треугольный остриём вверх)
|
||
return MeshBuilder.CreateCylinder(name,
|
||
{ diameterTop: 0, diameterBottom: sx, height: sy, tessellation: 24 }, this.scene);
|
||
case 'gd_finish':
|
||
// GD-финиш = неоновый цилиндр-столб
|
||
return MeshBuilder.CreateCylinder(name,
|
||
{ diameter: sx, height: sy, tessellation: 24 }, this.scene);
|
||
case 'gd_coin':
|
||
// GD-монета = сфера
|
||
return MeshBuilder.CreateSphere(name,
|
||
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene);
|
||
case 'light':
|
||
case 'emitter':
|
||
// Лампа / эмиттер = маленькая сфера-маркер. Свет/частицы
|
||
// создаются отдельно в addInstance.
|
||
return MeshBuilder.CreateSphere(name,
|
||
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 12 }, this.scene);
|
||
case 'billboard': {
|
||
// 3D-табличка — плоскость с пропорциями таблички (sx × sy),
|
||
// sz — толщина рамки (визуально-незаметная).
|
||
// ВАЖНО: FRONTSIDE, не DOUBLESIDE — иначе сквозь back-side
|
||
// видно зеркальную сторону UV (текст справа-налево).
|
||
// BillboardMode разворачивает FRONT к камере.
|
||
const m = MeshBuilder.CreatePlane(name,
|
||
{ width: sx, height: sy, sideOrientation: Mesh.FRONTSIDE }, this.scene);
|
||
return m;
|
||
}
|
||
case 'plane':
|
||
return MeshBuilder.CreateBox(name,
|
||
{ width: sx, height: sy, depth: sz }, this.scene);
|
||
case 'torus':
|
||
return MeshBuilder.CreateTorus(name,
|
||
{ diameter: sx, thickness: sy * 0.5, tessellation: 24 }, this.scene);
|
||
case 'wedge':
|
||
return this._buildWedgeMesh(name, sx, sy, sz);
|
||
case 'cornerwedge':
|
||
return this._buildCornerWedgeMesh(name, sx, sy, sz);
|
||
default:
|
||
return MeshBuilder.CreateBox(name,
|
||
{ width: sx, height: sy, depth: sz }, this.scene);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Клин — куб со срезанной верхней гранью (наклонная рампа).
|
||
* Высокий край у +Z, остриё-ребро у -Z. Центрирован в (0,0,0),
|
||
* как CreateBox. У каждой грани свои вершины (плоское затенение).
|
||
*/
|
||
_buildWedgeMesh(name, sx, sy, sz) {
|
||
const hx = sx / 2, hy = sy / 2, hz = sz / 2;
|
||
// 6 уникальных точек клина:
|
||
// низ: A(-hx,-hy,-hz) B(hx,-hy,-hz) C(hx,-hy,hz) D(-hx,-hy,hz)
|
||
// верх: E(-hx,hy,hz) F(hx,hy,hz) — только у задней (+Z) грани
|
||
const positions = [];
|
||
const indices = [];
|
||
const normals = [];
|
||
const uvs = [];
|
||
let vi = 0;
|
||
// Добавить грань (массив точек [x,y,z], нормаль, uv-набор) триангуляцией веером.
|
||
const face = (pts, nrm, uv) => {
|
||
const start = vi;
|
||
for (let i = 0; i < pts.length; i++) {
|
||
positions.push(pts[i][0], pts[i][1], pts[i][2]);
|
||
normals.push(nrm[0], nrm[1], nrm[2]);
|
||
uvs.push(uv[i][0], uv[i][1]);
|
||
vi++;
|
||
}
|
||
for (let i = 1; i < pts.length - 1; i++) {
|
||
indices.push(start, start + i, start + i + 1);
|
||
}
|
||
};
|
||
// Низ (y=-hy), нормаль вниз — порядок по часовой при взгляде снизу
|
||
face(
|
||
[[-hx,-hy,-hz],[-hx,-hy,hz],[hx,-hy,hz],[hx,-hy,-hz]],
|
||
[0,-1,0], [[0,0],[0,1],[1,1],[1,0]]
|
||
);
|
||
// Задняя вертикальная грань (z=+hz), нормаль +Z
|
||
face(
|
||
[[-hx,-hy,hz],[-hx,hy,hz],[hx,hy,hz],[hx,-hy,hz]],
|
||
[0,0,1], [[0,0],[0,1],[1,1],[1,0]]
|
||
);
|
||
// Наклонная грань: от нижнего ребра (-Z) к верхнему (+Z).
|
||
// Нормаль перпендикулярна склону в плоскости YZ: (0, sz, sy) норм.
|
||
const slopeLen = Math.hypot(sy, sz) || 1;
|
||
face(
|
||
[[-hx,-hy,-hz],[hx,-hy,-hz],[hx,hy,hz],[-hx,hy,hz]],
|
||
[0, sz / slopeLen, sy / slopeLen], [[0,0],[1,0],[1,1],[0,1]]
|
||
);
|
||
// Левый бок (x=-hx) — треугольник, нормаль -X
|
||
face(
|
||
[[-hx,-hy,-hz],[-hx,hy,hz],[-hx,-hy,hz]],
|
||
[-1,0,0], [[0,0],[1,1],[1,0]]
|
||
);
|
||
// Правый бок (x=+hx) — треугольник, нормаль +X
|
||
face(
|
||
[[hx,-hy,-hz],[hx,-hy,hz],[hx,hy,hz]],
|
||
[1,0,0], [[0,0],[1,0],[1,1]]
|
||
);
|
||
const mesh = new Mesh(name, this.scene);
|
||
const vd = new VertexData();
|
||
vd.positions = positions;
|
||
vd.indices = indices;
|
||
vd.normals = normals;
|
||
vd.uvs = uvs;
|
||
vd.applyToMesh(mesh);
|
||
return mesh;
|
||
}
|
||
|
||
/**
|
||
* Угловой клин — скос по двум осям, остриё в углу (-X,-Z).
|
||
* Высокая точка у (+X,+Z). Для внутренних углов крыш.
|
||
*/
|
||
_buildCornerWedgeMesh(name, sx, sy, sz) {
|
||
const hx = sx / 2, hy = sy / 2, hz = sz / 2;
|
||
const positions = [];
|
||
const indices = [];
|
||
const normals = [];
|
||
const uvs = [];
|
||
let vi = 0;
|
||
const face = (pts, nrm, uv) => {
|
||
const start = vi;
|
||
for (let i = 0; i < pts.length; i++) {
|
||
positions.push(pts[i][0], pts[i][1], pts[i][2]);
|
||
normals.push(nrm[0], nrm[1], nrm[2]);
|
||
uvs.push(uv[i][0], uv[i][1]);
|
||
vi++;
|
||
}
|
||
for (let i = 1; i < pts.length - 1; i++) {
|
||
indices.push(start, start + i, start + i + 1);
|
||
}
|
||
};
|
||
// Низ — квадрат основания
|
||
face(
|
||
[[-hx,-hy,-hz],[-hx,-hy,hz],[hx,-hy,hz],[hx,-hy,-hz]],
|
||
[0,-1,0], [[0,0],[0,1],[1,1],[1,0]]
|
||
);
|
||
// Верх — единственная высокая точка T(hx,hy,hz). Скос идёт к ней
|
||
// из трёх нижних точек. Грани:
|
||
const T = [hx, hy, hz];
|
||
// Задняя грань (z=+hz): нижнее ребро [-hx..hx] поднимается к T справа
|
||
face(
|
||
[[-hx,-hy,hz],[hx,-hy,hz],T],
|
||
[0,0,1], [[0,0],[1,0],[1,1]]
|
||
);
|
||
// Правая грань (x=+hx): нижнее ребро [-hz..hz] поднимается к T сзади
|
||
face(
|
||
[[hx,-hy,-hz],[hx,-hy,hz],T],
|
||
[1,0,0], [[0,0],[1,0],[1,1]]
|
||
);
|
||
// Наклонная треугольная грань — от низкого угла (-X,-Z) к T.
|
||
// Нормаль = нормаль плоскости через 3 точки.
|
||
const p1 = [-hx,-hy,-hz], p2 = [hx,-hy,-hz], p3 = T;
|
||
const u = [p2[0]-p1[0], p2[1]-p1[1], p2[2]-p1[2]];
|
||
const v = [p3[0]-p1[0], p3[1]-p1[1], p3[2]-p1[2]];
|
||
let n1 = [
|
||
u[1]*v[2]-u[2]*v[1],
|
||
u[2]*v[0]-u[0]*v[2],
|
||
u[0]*v[1]-u[1]*v[0],
|
||
];
|
||
let nl = Math.hypot(n1[0],n1[1],n1[2]) || 1;
|
||
n1 = [n1[0]/nl, n1[1]/nl, n1[2]/nl];
|
||
face([p1,p2,p3], n1, [[0,0],[1,0],[0.5,1]]);
|
||
// Вторая наклонная грань — от (-X,-Z) к T со стороны -X.
|
||
const q1 = [-hx,-hy,-hz], q2 = T, q3 = [-hx,-hy,hz];
|
||
const u2 = [q2[0]-q1[0], q2[1]-q1[1], q2[2]-q1[2]];
|
||
const v2 = [q3[0]-q1[0], q3[1]-q1[1], q3[2]-q1[2]];
|
||
let n2 = [
|
||
u2[1]*v2[2]-u2[2]*v2[1],
|
||
u2[2]*v2[0]-u2[0]*v2[2],
|
||
u2[0]*v2[1]-u2[1]*v2[0],
|
||
];
|
||
let nl2 = Math.hypot(n2[0],n2[1],n2[2]) || 1;
|
||
n2 = [n2[0]/nl2, n2[1]/nl2, n2[2]/nl2];
|
||
face([q1,q2,q3], n2, [[0,0],[0.5,1],[1,0]]);
|
||
const mesh = new Mesh(name, this.scene);
|
||
const vd = new VertexData();
|
||
vd.positions = positions;
|
||
vd.indices = indices;
|
||
vd.normals = normals;
|
||
vd.uvs = uvs;
|
||
vd.applyToMesh(mesh);
|
||
return mesh;
|
||
}
|
||
|
||
/** Применить цвет и материал. */
|
||
_applyMaterial(mesh, typeDef, color, material, textureUrl) {
|
||
const matName = `${mesh.name}_mat`;
|
||
const mat = new StandardMaterial(matName, this.scene);
|
||
mat.diffuseColor = Color3.FromHexString(color || '#888888');
|
||
|
||
// Если задан textureUrl — подгружаем PNG как diffuseTexture. Это
|
||
// используется для GD-скинов куба (например /gd/skins/cube_smile.png).
|
||
// Цвет работает как multiplier для текстуры — для нейтрального эффекта
|
||
// ставим белый (или сам color, чтобы можно было тонировать).
|
||
if (textureUrl) {
|
||
try {
|
||
// Texture импортирован статически вверху файла (см. комментарий
|
||
// про CRA→Vite адаптацию). Раньше тут был require() —
|
||
// в Vite/ESM он бросал исключение, и материал оставался
|
||
// с дефолтной розово-чёрной шахматкой.
|
||
const tex = new Texture(textureUrl, this.scene, true, false);
|
||
tex.hasAlpha = true;
|
||
mat.diffuseTexture = tex;
|
||
// Для текстурных скинов цвет берём белый, чтобы не тонировать
|
||
mat.diffuseColor = new Color3(1, 1, 1);
|
||
} catch (e) {
|
||
console.warn('[PrimitiveManager] не удалось загрузить текстуру', textureUrl, e);
|
||
}
|
||
}
|
||
|
||
switch (material) {
|
||
case 'metal':
|
||
mat.specularColor = new Color3(0.7, 0.7, 0.7);
|
||
mat.specularPower = 32;
|
||
break;
|
||
case 'glass':
|
||
mat.alpha = 0.4;
|
||
mat.specularColor = new Color3(0.5, 0.5, 0.5);
|
||
break;
|
||
case 'neon':
|
||
mat.emissiveColor = Color3.FromHexString(color || '#888888');
|
||
mat.specularColor = new Color3(0, 0, 0);
|
||
break;
|
||
case 'studs': {
|
||
// Лего-материал (паритет со студией): почти-белая diffuse × цвет
|
||
// меша + normal map. emissive = доля цвета → сочность (Roblox-look).
|
||
const tex = _getStudsTextures(this.scene);
|
||
const dt = tex.diffuse.clone();
|
||
const nt = tex.normal.clone();
|
||
const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
|
||
// Куб/триггер тайлятся через faceUV геометрии (uScale=1) — кружки
|
||
// одного размера на всех гранях. Остальные формы — через uScale.
|
||
if (dims.type === 'cube' || dims.type === 'trigger') {
|
||
dt.uScale = nt.uScale = 1;
|
||
dt.vScale = nt.vScale = 1;
|
||
} else {
|
||
const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz, dims.density);
|
||
dt.uScale = nt.uScale = tile.u;
|
||
dt.vScale = nt.vScale = tile.v;
|
||
}
|
||
mat.diffuseTexture = dt;
|
||
mat.bumpTexture = nt;
|
||
const sc = Color3.FromHexString(color || '#cccccc');
|
||
mat.diffuseColor = sc;
|
||
mat.emissiveColor = new Color3(sc.r * 0.45, sc.g * 0.45, sc.b * 0.45);
|
||
mat.specularColor = new Color3(0, 0, 0);
|
||
break;
|
||
}
|
||
case 'matte':
|
||
default:
|
||
mat.specularColor = new Color3(0, 0, 0);
|
||
break;
|
||
}
|
||
|
||
// Триггеры — всегда полупрозрачные жёлтые в редакторе
|
||
if (typeDef.kind === 'trigger') {
|
||
mat.diffuseColor = new Color3(1, 0.92, 0.2);
|
||
mat.alpha = 0.35;
|
||
mat.specularColor = new Color3(0, 0, 0);
|
||
mat.emissiveColor = new Color3(0.3, 0.27, 0);
|
||
}
|
||
// Чекпоинты — зелёные полупрозрачные с эмиссией
|
||
if (typeDef.kind === 'checkpoint') {
|
||
mat.diffuseColor = new Color3(0.3, 0.95, 0.4);
|
||
mat.alpha = 0.55;
|
||
mat.emissiveColor = new Color3(0.1, 0.5, 0.15);
|
||
mat.specularColor = new Color3(0, 0, 0);
|
||
}
|
||
|
||
mesh.material = mat;
|
||
}
|
||
|
||
/** Видимость. Триггеры в Play-режиме скрываются (флаг проставляет BabylonScene). */
|
||
_applyVisible(mesh, visible, typeDef) {
|
||
mesh.setEnabled(visible);
|
||
// Доп. логика для триггеров — может ставиться в _updatePlayModeVisibility
|
||
}
|
||
|
||
/**
|
||
* Применить пользовательскую текстуру (картинку из AssetManager) на
|
||
* грани примитива. data.textureAsset — id ассета. dataURL берём из
|
||
* assetManager. Цвет материала становится белым, чтобы не тонировать.
|
||
*/
|
||
_applyAssetTexture(data) {
|
||
if (!data || !data.mesh || !data.mesh.material) return;
|
||
const assetId = data.textureAsset;
|
||
if (!assetId || !this.assetManager) return;
|
||
const dataUrl = this.assetManager.getDataUrl(assetId);
|
||
if (!dataUrl) return;
|
||
try {
|
||
// Texture импортирован статически вверху файла.
|
||
const mat = data.mesh.material;
|
||
// Снимаем прежнюю diffuseTexture, чтобы не текла память.
|
||
if (mat.diffuseTexture && mat.diffuseTexture.dispose) {
|
||
try { mat.diffuseTexture.dispose(); } catch (e) { /* ignore */ }
|
||
}
|
||
const tex = new Texture(dataUrl, this.scene, true, false);
|
||
tex.hasAlpha = true;
|
||
mat.diffuseTexture = tex;
|
||
mat.diffuseColor = new Color3(1, 1, 1);
|
||
// neon-материал светится текстурой — emissive тоже из неё.
|
||
if (data.material === 'neon') {
|
||
mat.emissiveTexture = tex;
|
||
}
|
||
} catch (e) {
|
||
console.warn('[PrimitiveManager] _applyAssetTexture failed', assetId, e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Установить динамическую текстуру примитива из dataURL (base64-PNG).
|
||
* Используется GD-скинами куба: canvas-фабрика в скрипте рисует лицо,
|
||
* посылает dataURL сюда — мы создаём DynamicTexture и подменяем
|
||
* diffuseTexture на материале примитива.
|
||
*/
|
||
setTexture(id, dataUrl) {
|
||
const data = this.instances.get(id);
|
||
if (!data || !data.mesh || !data.mesh.material) return false;
|
||
try {
|
||
// DynamicTexture импортирован статически вверху файла
|
||
// (см. комментарий про CRA→Vite адаптацию).
|
||
const mat = data.mesh.material;
|
||
|
||
// Очистка предыдущей текстуры если была
|
||
if (mat.diffuseTexture && mat.diffuseTexture.dispose) {
|
||
try { mat.diffuseTexture.dispose(); } catch (e) { /* ignore */ }
|
||
}
|
||
|
||
// Создаём 256×256 DynamicTexture, рисуем в неё через временное Image
|
||
const dt = new DynamicTexture(`dt_${id}_${Date.now()}`, { width: 256, height: 256 }, this.scene, false);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
if (dt.isDisposed && dt.isDisposed()) return;
|
||
const ctx = dt.getContext();
|
||
if (!ctx) return;
|
||
ctx.clearRect(0, 0, 256, 256);
|
||
ctx.drawImage(img, 0, 0, 256, 256);
|
||
dt.update(false);
|
||
};
|
||
img.onerror = () => { try { dt.dispose(); } catch (e) {} };
|
||
img.src = dataUrl;
|
||
// Babylon CreateBox UV: V-ось перевёрнута относительно canvas → флипаем
|
||
dt.vScale = -1;
|
||
dt.vOffset = 1;
|
||
mat.diffuseTexture = dt;
|
||
mat.diffuseColor = new Color3(1, 1, 1);
|
||
// Для neon — emissive из текстуры тоже
|
||
if (data.material === 'neon') {
|
||
mat.emissiveTexture = dt;
|
||
}
|
||
return true;
|
||
} catch (e) {
|
||
console.warn('[PrimitiveManager] setTexture failed', id, e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Скрыть/показать триггеры (вызывается при enterPlayMode). */
|
||
setTriggersVisible(visible) {
|
||
for (const data of this.instances.values()) {
|
||
if (data.type === 'trigger' && data.mesh) {
|
||
if (data.visible !== false) {
|
||
data.mesh.setEnabled(visible);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Обновить параметры примитива (позиция / размер / цвет / материал / флаги). */
|
||
updateInstance(id, patch) {
|
||
const data = this.instances.get(id);
|
||
if (!data) return;
|
||
|
||
// Позиция
|
||
if (patch.x !== undefined) data.x = patch.x;
|
||
if (patch.y !== undefined) data.y = patch.y;
|
||
if (patch.z !== undefined) data.z = patch.z;
|
||
data.mesh.position.set(data.x, data.y, data.z);
|
||
|
||
// Поворот
|
||
let rotChanged = false;
|
||
if (patch.rotationX !== undefined) { data.rotationX = patch.rotationX; rotChanged = true; }
|
||
if (patch.rotationY !== undefined) { data.rotationY = patch.rotationY; rotChanged = true; }
|
||
if (patch.rotationZ !== undefined) { data.rotationZ = patch.rotationZ; rotChanged = true; }
|
||
if (rotChanged) {
|
||
data.mesh.rotation.set(data.rotationX || 0, data.rotationY || 0, data.rotationZ || 0);
|
||
}
|
||
|
||
// Размер — только через scaling, чтобы не пересоздавать mesh
|
||
let scaleChanged = false;
|
||
if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; }
|
||
if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; }
|
||
if (patch.sz !== undefined) { data.sz = patch.sz; scaleChanged = true; }
|
||
// Плотность studs (мелкие/крупные кружки) — требует пересоздания меша
|
||
// (faceUV для куба зашит в геометрию).
|
||
if (patch.studDensity !== undefined) {
|
||
data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1;
|
||
scaleChanged = true;
|
||
}
|
||
if (scaleChanged) {
|
||
// Поскольку MeshBuilder уже создал mesh с базовыми размерами,
|
||
// изменения через scaling кажутся правильными. Простой способ —
|
||
// пересоздать mesh:
|
||
this._recreateMesh(data);
|
||
}
|
||
|
||
// Пользовательская текстура: id картинки из AssetManager или null/''
|
||
// (снять текстуру). Меняем data.textureAsset — переприменение ниже.
|
||
let textureChanged = false;
|
||
if (patch.textureAsset !== undefined) {
|
||
data.textureAsset = patch.textureAsset || null;
|
||
textureChanged = true;
|
||
}
|
||
|
||
// Цвет / материал
|
||
let matChanged = false;
|
||
if (patch.color !== undefined) { data.color = patch.color; matChanged = true; }
|
||
if (patch.material !== undefined) { data.material = patch.material; matChanged = true; }
|
||
if (matChanged) {
|
||
const typeDef = getPrimitiveType(data.type);
|
||
// Удаляем старый material
|
||
if (data.mesh.material) {
|
||
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
|
||
}
|
||
data.mesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
|
||
this._applyMaterial(data.mesh, typeDef, data.color, data.material);
|
||
}
|
||
// Текстуру переприменяем если: сменили саму текстуру, или
|
||
// пересоздали материал (matChanged) / mesh (scaleChanged) —
|
||
// в этих случаях прежняя текстура потеряна.
|
||
if (data.textureAsset && (textureChanged || matChanged || scaleChanged)) {
|
||
this._applyAssetTexture(data);
|
||
} else if (textureChanged && !data.textureAsset && matChanged === false) {
|
||
// Текстуру сняли — пересоздаём чистый материал из цвета.
|
||
const typeDef = getPrimitiveType(data.type);
|
||
if (data.mesh.material) {
|
||
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
|
||
}
|
||
data.mesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
|
||
this._applyMaterial(data.mesh, typeDef, data.color, data.material);
|
||
}
|
||
|
||
if (patch.canCollide !== undefined) data.canCollide = patch.canCollide;
|
||
if (patch.locked !== undefined) data.locked = !!patch.locked;
|
||
if (patch.visible !== undefined) {
|
||
data.visible = patch.visible;
|
||
data.mesh.setEnabled(data.visible);
|
||
}
|
||
// Прозрачность: 1 = непрозрачно, 0 = невидимо. Меняет material.alpha.
|
||
if (patch.opacity !== undefined) {
|
||
const op = Number(patch.opacity);
|
||
if (Number.isFinite(op)) {
|
||
data.opacity = Math.max(0, Math.min(1, op));
|
||
if (data.mesh.material) data.mesh.material.alpha = data.opacity;
|
||
}
|
||
}
|
||
|
||
// Billboard: пересоздать GUI-текстуру при изменении template/content/face/elements
|
||
if (patch.billboardOpts && this.billboardUiManager && data.type === 'billboard') {
|
||
this.billboardUiManager.applyToMesh(data, patch.billboardOpts);
|
||
}
|
||
|
||
// === Лампа: синхронизируем привязанный PointLight ===
|
||
if (data.light) {
|
||
// позиция света — за маркером
|
||
data.light.position.set(data.x, data.y, data.z);
|
||
if (patch.color !== undefined) {
|
||
data.light.diffuse = Color3.FromHexString(data.color || '#ffe9a0');
|
||
if (data.mesh.material) {
|
||
data.mesh.material.emissiveColor = Color3.FromHexString(data.color || '#ffe9a0');
|
||
}
|
||
}
|
||
if (patch.brightness !== undefined) {
|
||
const b = Number(patch.brightness);
|
||
if (Number.isFinite(b) && b >= 0) {
|
||
data.brightness = b;
|
||
data.light.intensity = b;
|
||
}
|
||
}
|
||
if (patch.range !== undefined) {
|
||
const r = Number(patch.range);
|
||
if (Number.isFinite(r) && r > 0) {
|
||
data.range = r;
|
||
data.light.range = r;
|
||
}
|
||
}
|
||
}
|
||
|
||
// === Эмиттер: синхронизируем систему частиц ===
|
||
if (data.effect !== undefined && this.scene3d) {
|
||
// позиция частиц — за маркером
|
||
if (data.particles && data.particles.emitter) {
|
||
data.particles.emitter.set(data.x, data.y, data.z);
|
||
}
|
||
// смена типа эффекта или цвета — пересоздаём систему
|
||
if (patch.effect !== undefined || patch.color !== undefined) {
|
||
if (patch.effect !== undefined && typeof patch.effect === 'string') {
|
||
data.effect = patch.effect;
|
||
}
|
||
// dispose(false) — НЕ удалять particleTexture: она расшарена
|
||
// между всеми эмиттерами (_particleTex). dispose(true) убил бы
|
||
// текстуру для всех остальных систем частиц.
|
||
try { if (data.particles) data.particles.dispose(false); } catch (e) {}
|
||
if (this.scene3d.createEmitterParticles) {
|
||
data.particles = this.scene3d.createEmitterParticles(
|
||
data.effect, { x: data.x, y: data.y, z: data.z }, data.color);
|
||
}
|
||
if (patch.color !== undefined && data.mesh.material) {
|
||
data.mesh.material.emissiveColor = Color3.FromHexString(data.color || '#ff8833');
|
||
}
|
||
}
|
||
}
|
||
if (patch.anchored !== undefined) data.anchored = patch.anchored;
|
||
if (patch.mass !== undefined) {
|
||
const m = Number(patch.mass);
|
||
if (Number.isFinite(m) && m > 0) data.mass = m;
|
||
}
|
||
|
||
this._notifyChange();
|
||
}
|
||
|
||
/** Пересоздать mesh при смене размера (т.к. Box-builder работает в base-size). */
|
||
_recreateMesh(data) {
|
||
const oldMesh = data.mesh;
|
||
const oldPos = oldMesh.position.clone();
|
||
const oldRot = oldMesh.rotation?.clone();
|
||
const oldMat = oldMesh.material;
|
||
|
||
const typeDef = getPrimitiveType(data.type);
|
||
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity);
|
||
newMesh.position = oldPos;
|
||
if (oldRot) newMesh.rotation = oldRot;
|
||
newMesh.material = oldMat;
|
||
newMesh.isPickable = true;
|
||
newMesh.metadata = { ...oldMesh.metadata };
|
||
newMesh.setEnabled(data.visible);
|
||
|
||
// Удаляем старый
|
||
try { oldMesh.dispose(/*doNotRecurse*/ true, /*disposeMaterial*/ false); }
|
||
catch (e) { /* ignore */ }
|
||
|
||
data.mesh = newMesh;
|
||
newMesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz, density: data.studDensity };
|
||
// studs-материал: пересчитать тайлинг под новый размер меша.
|
||
// Куб уже пересоздан с новым faceUV (тайлинг в геометрии) — uScale=1.
|
||
// Для остальных форм пересчитываем uScale/vScale по размеру.
|
||
if (data.material === 'studs' && oldMat && oldMat.diffuseTexture) {
|
||
if (data.type === 'cube' || data.type === 'trigger') {
|
||
oldMat.diffuseTexture.uScale = oldMat.diffuseTexture.vScale = 1;
|
||
if (oldMat.bumpTexture) oldMat.bumpTexture.uScale = oldMat.bumpTexture.vScale = 1;
|
||
} else {
|
||
const tile = _studsTiling(data.type, data.sx, data.sy, data.sz, data.studDensity);
|
||
oldMat.diffuseTexture.uScale = tile.u;
|
||
oldMat.diffuseTexture.vScale = tile.v;
|
||
if (oldMat.bumpTexture) {
|
||
oldMat.bumpTexture.uScale = tile.u;
|
||
oldMat.bumpTexture.vScale = tile.v;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Удалить инстанс. */
|
||
removeInstance(id) {
|
||
const data = this.instances.get(id);
|
||
if (!data) return false;
|
||
try {
|
||
// У лампы есть привязанный PointLight — освобождаем его.
|
||
if (data.light) data.light.dispose();
|
||
// У эмиттера — система частиц (dispose(false) — текстура расшарена).
|
||
if (data.particles) data.particles.dispose(false);
|
||
if (data.mesh.material) data.mesh.material.dispose();
|
||
data.mesh.dispose();
|
||
} catch (e) { /* ignore */ }
|
||
this.instances.delete(id);
|
||
this._notifyChange();
|
||
return true;
|
||
}
|
||
|
||
/** Удалить инстанс по mesh (после raycast). */
|
||
removeInstanceByMesh(mesh) {
|
||
const id = mesh?.metadata?.primitiveId;
|
||
if (id != null) return this.removeInstance(id);
|
||
return false;
|
||
}
|
||
|
||
getInstanceCount() {
|
||
return this.instances.size;
|
||
}
|
||
|
||
/** Все инстансы как массив (для Hierarchy). */
|
||
getAll() {
|
||
return Array.from(this.instances.values()).map(d => ({
|
||
id: d.id, type: d.type,
|
||
x: d.x, y: d.y, z: d.z,
|
||
sx: d.sx, sy: d.sy, sz: d.sz,
|
||
rotationX: d.rotationX || 0,
|
||
rotationY: d.rotationY || 0,
|
||
rotationZ: d.rotationZ || 0,
|
||
color: d.color, material: d.material,
|
||
canCollide: d.canCollide, visible: d.visible,
|
||
anchored: d.anchored,
|
||
mass: d.mass,
|
||
name: d.name || null,
|
||
// locked — защита от выделения/перемещения (Фаза 5.11).
|
||
...(d.locked ? { locked: true } : {}),
|
||
// id пользовательской текстуры (картинка из AssetManager).
|
||
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
|
||
// Плотность studs (если не 1) — мелкие/крупные кружки.
|
||
...(d.studDensity && d.studDensity !== 1 ? { studDensity: d.studDensity } : {}),
|
||
// Параметры лампы (только для type='light', иначе undefined)
|
||
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
|
||
// Параметр эмиттера (только для type='emitter')
|
||
...(d.effect !== undefined ? { effect: d.effect } : {}),
|
||
// Параметры билборда (только для type='billboard')
|
||
...(d.billboard ? {
|
||
template: d.billboard.template,
|
||
face: d.billboard.face,
|
||
content: d.billboard.content,
|
||
...(d.billboard.elements ? { elements: d.billboard.elements } : {}),
|
||
} : {}),
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Заморозить worldMatrix у статичных примитивов на старте Play —
|
||
* Babylon перестанет пересчитывать матрицы каждый кадр, frustum-test
|
||
* по ним идёт без world-matrix recompute. Огромный буст FPS на сценах
|
||
* с сотнями декоративных примитивов (Only Up клон, паркур-башни).
|
||
*
|
||
* Не замораживаем: примитивы с anchored=false (физика), и помеченные
|
||
* скриптом как «движущиеся» (через мета-флаг или имя в moving-списке).
|
||
* Скрипт всё равно зовёт scene.move через self.move/scene.move которые
|
||
* в GameRuntime НЕ требуют world-matrix unfreezed (сам ставит position
|
||
* без trigger reset). Но на всякий случай — пропускаем те у кого
|
||
* имя начинается с MX_/MZ_/PEND_/PIST_ (наши соглашения для движущихся).
|
||
*/
|
||
freezeStaticPrimitives() {
|
||
for (const data of this.instances.values()) {
|
||
if (data._worldMatrixFrozen) continue;
|
||
if (data.anchored === false) continue;
|
||
if (!data.mesh) continue;
|
||
const name = data.name || '';
|
||
// Пропускаем известные типы движущихся платформ
|
||
if (name.startsWith('MX_') || name.startsWith('MZ_')
|
||
|| name.startsWith('PEND_') || name.startsWith('PIST_')) continue;
|
||
// Пропускаем примитивы внутри папок — их может вращать setFolderYaw
|
||
// (например, голова куклы Squid Game в папке DollHead).
|
||
if (data.folderId != null) continue;
|
||
try {
|
||
data.mesh.computeWorldMatrix(true);
|
||
data.mesh.freezeWorldMatrix();
|
||
// НЕ ставим doNotSyncBoundingInfo=true — на больших удалённых
|
||
// примитивах (кукла Squid Game на z=90) это ломало frustum-cull
|
||
// и mesh пропадал из видимости.
|
||
data._worldMatrixFrozen = true;
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
unfreezeStaticPrimitives() {
|
||
for (const data of this.instances.values()) {
|
||
if (!data._worldMatrixFrozen) continue;
|
||
try {
|
||
data.mesh?.unfreezeWorldMatrix?.();
|
||
data._worldMatrixFrozen = false;
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
/** Найти все mesh-чекпоинты (для проверки игроком). */
|
||
getCheckpoints() {
|
||
const out = [];
|
||
for (const data of this.instances.values()) {
|
||
if (data.type === 'checkpoint') out.push(data);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
serialize() {
|
||
return this.getAll();
|
||
}
|
||
|
||
loadFromArray(arr) {
|
||
this.clear();
|
||
for (const item of arr) {
|
||
this.addInstance(item.type, item);
|
||
}
|
||
}
|
||
|
||
clear() {
|
||
const cb = this._onChange;
|
||
this._onChange = null;
|
||
const had = this.instances.size > 0;
|
||
for (const id of Array.from(this.instances.keys())) {
|
||
this.removeInstance(id);
|
||
}
|
||
this._onChange = cb;
|
||
if (had) this._notifyChange();
|
||
}
|
||
|
||
dispose() {
|
||
this.clear();
|
||
}
|
||
}
|