studio/src/editor/engine/PrimitiveManager.js
МИН 65aa26996d feat(09): материал studs + окрашиваемый блок studs-block + лего-сет
Задача 09 «Studs материал» (лего-кружки):
- PrimitiveManager: material 'studs' — diffuse-текстура (серые кружки) × цвет
  меша + normal map (выпуклость). Тайлинг _studsTiling по размеру меша
  (STUD_UNIT=1, GRID=4), пересчёт в _recreateMesh при ресайзе. _studsDims на меше.
- InspectorPanel: «Studs» 5-й материал в палитре примитивов.
- BlockTypes: studs-block ('Окрашиваемые', colorable:true, normal, defaultColor).
- BlockManager: per-instance color через ThinInstance color buffer
  (thinInstanceSetBuffer('color'), useVertexColors) — тысячи блоков любых цветов
  один draw call. addBlock(x,y,z,type,color), _setBlockColorAt/setBlockColor,
  serialize/loadFromArray с color, batch flush.
- GameRuntime: scene.setColor для блока (ref 'block:x,y,z'), spawn block с color.
- ScriptSandboxWorker: spawn блока прокидывает color.
- ModelTypes: лего-сет 19 compound-моделей (кирпичи/плиты/скаты/дерево/куст/
  дом/машина/ступеньки/человечек) — все части material:'studs'.
- Вики: карточка g5 #56 «Лего-полигон» + урок guide-lego.

Текстуры: public/kubikon-assets/materials/studs_{diffuse,normal}.png (в .gitignore,
доставить на S2 build/ вручную). Проверено визуально: куб 6×6 кружков, 2×2 блока
разных цветов, лего-дом/дерево/машина.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 11:10:58 +03:00

967 lines
47 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, 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_diffuse.png';
const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_normal.png';
const STUD_UNIT = 1; // 1 кружок на 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);
const normal = new Texture(STUDS_NORMAL_URL, scene);
c = { diffuse, normal };
_studsTexCache.set(scene, c);
}
return c;
}
/**
* Посчитать тайлинг (uScale/vScale) для studs по размеру меша. Чтобы кружки не
* растягивались: число кружков на грань = размер_грани / STUD_UNIT, делённое на
* число кружков в самой текстуре (STUDS_GRID).
* Для куба/плоскости тайлинг прямой; для сферы/цилиндра — приближённый.
*/
function _studsTiling(type, sx, sy, sz) {
const f = STUD_UNIT * STUDS_GRID;
// По умолчанию (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) };
}
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');
// 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);
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,
};
// 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,
// locked — объект защищён от выделения/перемещения в редакторе
// (Фаза 5.11). На геймплей не влияет.
locked: opts.locked === true,
name: opts.name || null,
folderId: opts.folderId ?? null,
};
// Размеры для тайлинга studs-материала (читается в _applyMaterial).
mesh._studsDims = { type, sx, sy, sz };
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) {
const name = `prim_${typeDef.id}_${id}`;
switch (typeDef.id) {
case 'cube':
case 'trigger':
return MeshBuilder.CreateBox(name,
{ width: sx, height: sy, depth: sz }, 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-текстура с кружками умножается на
// цвет меша (StandardMaterial.diffuseTexture * diffuseColor), normal
// map даёт выпуклость. Тайлинг — по реальному размеру меша.
const tex = _getStudsTextures(this.scene);
// Клон-обёртки текстур: uScale/vScale per-mesh, сама картинка шарится.
const dt = tex.diffuse.clone();
const nt = tex.normal.clone();
const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz);
dt.uScale = nt.uScale = tile.u;
dt.vScale = nt.vScale = tile.v;
mat.diffuseTexture = dt;
mat.bumpTexture = nt;
// Цвет меша остаётся как multiply-tint поверх серой текстуры.
mat.diffuseColor = Color3.FromHexString(color || '#cccccc');
mat.specularColor = new Color3(0.25, 0.25, 0.25);
mat.specularPower = 24;
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; }
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;
}
// === Стрелка-указатель (задача 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;
}
}
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);
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 };
// studs-материал: пересчитать тайлинг под новый размер меша.
if (data.material === 'studs' && oldMat && oldMat.diffuseTexture) {
const tile = _studsTiling(data.type, data.sx, data.sy, data.sz);
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())
// Исключаем скриптовые спавны — они эфемерные и не должны
// попадать в 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,
// locked — защита от выделения/перемещения (Фаза 5.11).
...(d.locked ? { locked: true } : {}),
// id пользовательской текстуры (картинка из AssetManager).
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
// Параметры лампы (только для 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();
}
}