Open-source веб-студия для создания игр Рублокса, двойная лицензия AGPL-3.0 + Коммерческая. Главное: - Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16 - Самодостаточный движок ~28к строк (66 файлов): BlockManager, TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов - Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco) - Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn) - Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt) - 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.) - Конфигурируемый бэкенд через VITE_API_BASE — работает со staging (dev-api.rublox.pro) без настройки - Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка - Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - ESLint + Prettier + EditorConfig - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Перед публикацией: - Все импорты из minecraftia заменены на локальные - Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env - Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо) - AdminKubikonModeration не публикуется (модерация — в team.rublox.pro) - 93 МБ ассетов public/kubikon-assets вынесены в .gitignore (раздаются через release artifact)
815 lines
38 KiB
JavaScript
815 lines
38 KiB
JavaScript
/**
|
||
* PrimitiveManager — параметрические 3D-примитивы (как Part в Roblox).
|
||
*
|
||
* Каждый инстанс — отдельный mesh с настраиваемыми:
|
||
* - тип (cube/sphere/cylinder/cone/plane/torus/trigger/checkpoint)
|
||
* - позиция (x, y, z)
|
||
* - размер (sx, sy, sz)
|
||
* - цвет (#hex)
|
||
* - материал ('matte' | 'metal' | 'glass' | 'neon')
|
||
* - canCollide (bool) — участвует ли в физике коллизий
|
||
* - visible (bool) — рисуется ли (anchored — пока заготовка)
|
||
*
|
||
* Хранится в `instances: Map<id, {mesh, ...metadata}>`.
|
||
*
|
||
* Triggers — отдельный slice через kind='trigger': mesh полупрозрачный
|
||
* жёлтый куб (виден в редакторе, скрыт в Play), не участвует в коллизиях.
|
||
*
|
||
* Checkpoints — kind='checkpoint': зелёный полупрозрачный цилиндр.
|
||
* При касании игроком обновляет spawnPoint сцены.
|
||
*/
|
||
import {
|
||
MeshBuilder, StandardMaterial, Color3, Vector3, PointLight,
|
||
Mesh, VertexData,
|
||
} from '@babylonjs/core';
|
||
import { getPrimitiveType } from './PrimitiveTypes';
|
||
|
||
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,
|
||
};
|
||
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');
|
||
}
|
||
}
|
||
|
||
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':
|
||
// Лампа / эмиттер = маленькая сфера-маркер. Свет/частицы
|
||
// создаются отдельно в addInstance.
|
||
return MeshBuilder.CreateSphere(name,
|
||
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 12 }, this.scene);
|
||
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 уже импортирован выше — используем глобал.
|
||
// eslint-disable-next-line global-require
|
||
const { Texture } = require('@babylonjs/core/Materials/Textures/texture');
|
||
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 '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 {
|
||
// eslint-disable-next-line global-require
|
||
const { Texture } = require('@babylonjs/core/Materials/Textures/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 {
|
||
// Lazy-import DynamicTexture
|
||
// eslint-disable-next-line global-require
|
||
const { DynamicTexture } = require('@babylonjs/core/Materials/Textures/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 */ }
|
||
}
|
||
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 */ }
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
|
||
// === Лампа: синхронизируем привязанный 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);
|
||
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;
|
||
}
|
||
|
||
/** Удалить инстанс. */
|
||
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 } : {}),
|
||
// Параметры лампы (только для type='light', иначе undefined)
|
||
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
|
||
// Параметр эмиттера (только для type='emitter')
|
||
...(d.effect !== undefined ? { effect: d.effect } : {}),
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Заморозить 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();
|
||
}
|
||
}
|