player/src/engine/PrimitiveManager.js
МИН a46829c5f7
Some checks failed
CI / Lint (pull_request) Failing after 42s
CI / Build (pull_request) Successful in 1m30s
CI / Secret scan (pull_request) Successful in 2m28s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat: синхронизация движка плеера со студией (задачи 01-07)
Плеер отстал на несколько задач — игры из студии не открывались с механиками.
Перенёс из rublox-studio в движок плеера:

Новые файлы движка:
- engine/ModalManager.js (задача 04 — модальные сцены)
- engine/BillboardUiManager.js (задача 01 — 3D-таблички)

Точечный перенос в существующие файлы:
- ScriptSandboxWorker.js: namespace game.modal/billboard/environment, скины в
  game.player, game.gui.tween, _guiHandlerKeys(localId), события
  modalOpened/modalClosed/skinChanged/billboardClick
- GameRuntime.js: команды modal.*/billboard.*/player.setSkin.*/gui.tween +
  _broadcastSkinsSnapshot/_ensureSkinState + routeGlobalEvent с localId
- PlayerController.js: non-humanoid скины (loadNonHumanoid+reloadSkin+
  процедурная анимация+pivot-центрирование), setInputBlocked/focusOnTarget,
  камера задачи 02 (zoom/shift-lock), клавиша B (магазин)
- BabylonScene.js: init modalManager/billboardUiManager, методы магазина скинов,
  чтение scene.skins, modalManager.tick, Esc-приоритет
- ScriptSandbox.js: sendSkinsSnapshot
- GuiManager.js: поля анимаций задачи 03 (синхронизирован со студией)
- PrimitiveTypes.js / PrimitiveManager.js: тип billboard + рендер

React-слой (editor-shared):
- ModalOverlay.jsx, SkinShopOverlay.jsx (новые) + подключены в KubikonPlayer
- GuiOverlay.jsx, GameHud.jsx синхронизированы со студией

eslint.config: послабления стилевых правил (no-empty off и т.п.).

Локальный build зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 03:15:43 +03:00

863 lines
42 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,
} 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';
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;
// Тени: примитивы и принимают тени от других объектов, и сами их
// отбрасывают (через 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,
// 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');
}
}
// === 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) {
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 '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 '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; }
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;
}
}
// 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);
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 } : {}),
// Параметры билборда (только для 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();
}
}