studio/src/editor/engine/PrimitiveManager.js
min 018fce474b fix(studio): объекты больше не вываливаются из папки после Play/Stop (folderId в serialize)
Корень: serialize примитивов/моделей/userModel НЕ сохранял folderId. При
Play→Stop сцена восстанавливалась из снапшота без группировки → все части
кита (светофор/шипы/дверь) вываливались из папки в общие «Примитивы».
Добавлен folderId в serialize всех 3 менеджеров + восстановление в loadFromArray
(model/userModel явно, primitive через opts.folderId).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:00:36 +03:00

1054 lines
54 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.

/**
* 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, Texture, DynamicTexture,
} from '@babylonjs/core';
import { getPrimitiveType } from './PrimitiveTypes';
// === Материал «studs» (лего-кружки, задача 09) ===
// Серая diffuse-текстура с сеткой выпуклых кружков (multiply на цвет меша) +
// normal map для иллюзии выпуклости. 1 stud = STUD_UNIT юнитов → тайлинг
// считается из реального размера меша (куб 4×4 покажет 4×4 кружка).
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; // 1 круглый stud на 1 юнит размера
const STUDS_GRID = 4; // текстура содержит сетку 4×4 круглых studs
// Кэш текстур на сцену: чтобы не грузить один и тот же PNG для каждого меша.
// Map<scene, { diffuse: Texture, normal: Texture }>. Каждый меш получает свою
// материал-копию (свой цвет/тайлинг), но текстуры шарятся.
const _studsTexCache = new WeakMap();
function _getStudsTextures(scene) {
let c = _studsTexCache.get(scene);
if (!c) {
const diffuse = new Texture(STUDS_DIFFUSE_URL, scene);
// base — uScale=1 (для cube/trigger: тайлинг через faceUV геометрии).
// tiled — кэш клонов по ключу 'u_v' для не-кубических форм, чтобы НЕ
// плодить по текстуре на каждый меш (был источник FPS-просадки: десятки
// клонов diffuse+normal = десятки GPU-ресурсов + дорогой bump на каждом).
c = { diffuse, tiled: new Map() };
_studsTexCache.set(scene, c);
}
return c;
}
// Общая diffuse-текстура с заданным тайлингом (u,v). Клон создаётся ОДИН раз
// на пару (u,v) и переиспользуется всеми мешами с тем же тайлингом.
function _getStudsTiledTexture(scene, u, v) {
const c = _getStudsTextures(scene);
// округляем до 0.05 — близкие тайлинги шарят одну текстуру
const ru = Math.round(u * 20) / 20, rv = Math.round(v * 20) / 20;
const key = ru + '_' + rv;
let t = c.tiled.get(key);
if (!t) {
t = c.diffuse.clone();
t.uScale = ru; t.vScale = rv;
c.tiled.set(key, t);
}
return t;
}
/**
* Посчитать тайлинг (uScale/vScale) для studs по размеру меша. Чтобы кружки не
* растягивались: число кружков на грань = размер_грани / STUD_UNIT, делённое на
* число кружков в самой текстуре (STUDS_GRID).
* Для куба/плоскости тайлинг прямой; для сферы/цилиндра — приближённый.
*/
function _studsTiling(type, sx, sy, sz, density) {
// density — множитель плотности кружков (1=стандарт, 2=вдвое мельче/чаще).
const d = density && density > 0 ? density : 1;
const f = (STUD_UNIT * STUDS_GRID) / d;
// По умолчанию (cube/plane/wedge) — горизонтальный размер по U, вертикаль по V.
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;
// Тени: примитивы принимают тени от других объектов.
mesh.receiveShadows = true;
mesh.metadata = {
isPrimitive: true,
primitiveId: id,
primitiveType: type,
primitiveKind: typeDef.kind,
// canCollide в metadata нужен camera-clamp (PlayerController):
// без него камера 3-го лица цепляется за проходимые зоны/триггеры
// (canCollide:false) и прыгает к игроку внутри зоны. Баг 2026-06-05.
canCollide,
};
// 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,
// Подпись над объектом (задача 10) — восстанавливается из project_data.
label: opts.label || null,
// 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');
}
}
// === Стрелка-указатель (задача 08): сохраняем настройки в data ===
// В редакторе это маркер-сфера; реальный beam создаётся при Play через
// activatePointers (см. ниже) / при превью.
if (typeDef.kind === 'pointer') {
data.pointerPreset = opts.pointerPreset || 'guide';
data.pointerFrom = opts.pointerFrom || 'player'; // 'player' | id | ''
data.pointerTo = opts.pointerTo || ''; // id цели | ''
data.textureSpeed = Number.isFinite(opts.textureSpeed) ? opts.textureSpeed : 3;
data.curved = !!opts.curved;
data.curveHeight = Number.isFinite(opts.curveHeight) ? opts.curveHeight : 2;
if (mesh.material) {
mesh.material.emissiveColor = Color3.FromHexString(color || '#ff3a3a');
}
}
// === 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 теней).
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':
case 'pointer':
// Лампа / эмиттер / стрелка = маленькая сфера-маркер.
// Свет/частицы/beam создаются отдельно (light — в addPrimitive,
// pointer — при enterPlayMode через activatePointers).
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 {
// Lazy-import: класс Texture в babylon достаём через require.
// Babylon уже импортирован выше — используем глобал.
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-текстура с лёгкими кружками
// умножается на цвет меша → цвет остаётся СОЧНЫМ (как в Roblox).
// emissive = доля цвета → цвет «светится», не тускнеет в тени.
// Объём студов запечён в diffuse v4 (baked-тени) — bumpTexture НЕ
// используем: normal-mapping удваивает стоимость шейдера на каждом
// меше и почти не виден на маленьких студах. Текстуры ШАРЯТСЯ
// (общая для cube, кэш-клон по тайлингу для форм) — без этого
// десятки клонов роняли FPS.
const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
let dt;
if (dims.type === 'cube' || dims.type === 'trigger') {
// uScale=1 — тайлинг через faceUV геометрии. Общая текстура.
dt = _getStudsTextures(this.scene).diffuse;
} else {
const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz, dims.density);
dt = _getStudsTiledTexture(this.scene, tile.u, tile.v);
}
mat.diffuseTexture = dt;
const sc = Color3.FromHexString(color || '#cccccc');
mat.diffuseColor = sc;
// Сочность: подмешиваем цвет в emissive (45%) — Roblox-look,
// насыщенный даже без прямого света. specular убираем (он белит).
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 {
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 {
// Lazy-import DynamicTexture
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 (data.mesh?.metadata) data.mesh.metadata.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;
}
// === Стрелка-указатель (задача 08): настройки из инспектора ===
// Сохраняем в data, чтобы serialize записал их в project_data, а при
// Play _activatePointers() построил реальную стрелку с этими параметрами.
if (data.type === 'pointer') {
if (patch.pointerPreset !== undefined) data.pointerPreset = patch.pointerPreset;
if (patch.pointerFrom !== undefined) data.pointerFrom = patch.pointerFrom;
if (patch.pointerTo !== undefined) data.pointerTo = patch.pointerTo;
if (patch.textureSpeed !== undefined) {
const s = Number(patch.textureSpeed);
if (Number.isFinite(s)) data.textureSpeed = s;
}
if (patch.curved !== undefined) data.curved = !!patch.curved;
if (patch.curveHeight !== undefined) {
const h = Number(patch.curveHeight);
if (Number.isFinite(h)) data.curveHeight = h;
}
}
// Задача 10: подпись над объектом (label) — редактируется в инспекторе,
// сериализуется, при Play создаётся биндинг без скрипта.
// data.label = { enabled, binding:'static'|'timer'|'save'|'hp'|'formula',
// params:{...}, preset, height }.
if (patch.label !== undefined) {
data.label = patch.label || null;
}
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;
// studs — материал пересоздаём заново (свежий faceUV/тайлинг + текстура
// могла быть временно спрятана во время scale-drag). Иначе переносим старый.
if (data.material === 'studs') {
newMesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz, density: data.studDensity };
this._applyMaterial(newMesh, typeDef, data.color, data.material);
try { oldMat?.dispose(); } catch (e) { /* ignore */ }
} else {
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;
// _studsDims и материал studs уже выставлены выше (через _applyMaterial
// на новом меше с правильным faceUV/тайлингом). Для не-studs ничего не надо.
}
/** Удалить инстанс. */
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())
// Исключаем скриптовые спавны — они эфемерные и не должны
// попадать в project_data (иначе при каждом Play копятся дубли).
.filter(d => !d._scriptSpawned)
.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,
// folderId — принадлежность к папке. БЕЗ него примитивы вываливались
// из папки после Play/Stop (снапшот терял группировку). Баг 2026-06-05.
...(d.folderId != null ? { folderId: d.folderId } : {}),
// locked — защита от выделения/перемещения (Фаза 5.11).
...(d.locked ? { locked: true } : {}),
// id пользовательской текстуры (картинка из AssetManager).
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
// Плотность studs (если не 1) — мелкие/крупные кружки.
...(d.studDensity && d.studDensity !== 1 ? { studDensity: d.studDensity } : {}),
// Подпись над объектом (задача 10) — если включена.
...(d.label && d.label.enabled ? { label: d.label } : {}),
// Параметры лампы (только для 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();
}
}