player/src/engine/PrimitiveManager.js
МИН ae83926a5a
All checks were successful
CI / Lint (pull_request) Successful in 56s
CI / Build (pull_request) Successful in 1m31s
CI / Secret scan (pull_request) Successful in 2m30s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat(09): per-face UV studs + studDensity (паритет со студией)
faceUV для куба (кружки одного размера на всех гранях) + studDensity
(плотность кружков) — портировано из студии.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 13:26:52 +03:00

975 lines
48 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,
} 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();
}
}