studio/src/editor/engine/PlacementManager.js
min ee1b7352b7
Some checks failed
CI / Lint (pull_request) Successful in 1m15s
CI / Build (pull_request) Successful in 1m58s
CI / Secret scan (pull_request) Failing after 9s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat(11): placement mode — расстановка предметов (tycoon)
Движок: PlacementManager (тень-превью формой воксельной модели за курсором,
снап к сетке, стопка, проверка зоны и баланса, поворот R/колесо, ПКМ/Esc),
ShopInventoryUi (магазин-слоты, авто-серые при нехватке валюты); проводка
game.placement.* и game.inventoryUi.* в worker/GameRuntime/BabylonScene.

Попутные фиксы:
- TerrainManager: backFaceCulling=false — воксели не просвечивают (видна была
  задняя грань сквозь переднюю);
- KubikonEditor: guard от потери userModels/scripts при частичной загрузке
  (terrain догрузился, модели/скрипт нет → автосейв затирал) — критичный
  фикс защиты данных для ВСЕХ игр;
- Hotbar: пустой инвентарь не показывает панель (глобальное правило);
- MinimapOverlay: миникарта только по флагу игры (не авто на больших картах);
- cleanup usermodel-инстансов при Stop.

Вики: карточка #58 + статья-урок «Мой завод» (g5 Разбор готовых игр),
openProjectId=2345, скриншоты залиты на прод.

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

590 lines
30 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.

/**
* PlacementManager — drag-and-drop размещение объектов в 3D-мире (задача 11).
*
* Фундамент жанра tycoon/simulator/farm: «кликнул предмет в инвентаре →
* полупрозрачный preview летает за курсором → ЛКМ ставит, ПКМ/Esc отменяет».
* Аналог placement-системы из Roblox tycoon-игр (Pet Sim, Lumber Tycoon).
*
* Подключается из BabylonScene: `this.placementManager = new PlacementManager(this)`.
* Доступ к движку через scene3d: camera, scene, player, pick, spawn, fx.
*
* Скриптовый API игры (через GameRuntime → game.placement.*):
* start(itemKey, opts) — войти в режим расстановки
* cancel() — выйти (как ПКМ/Esc)
* confirm() — поставить на текущей позиции (как ЛКМ)
* rotate(deg) — повернуть preview (как R / колесо)
* onPlace / onCancel / onMove — колбэки (роутятся в worker как события)
*
* Фича-парность: идентичный модуль есть в rublox-player/src/engine/.
*/
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { Color3 } from '@babylonjs/core/Maths/math.color';
import { Vector3 } from '@babylonjs/core/Maths/math.vector';
const VALID_TINT = new Color3(0.30, 0.95, 0.40); // зелёный — можно ставить
const INVALID_TINT = new Color3(0.95, 0.25, 0.25); // красный — нельзя
export class PlacementManager {
constructor(scene3d) {
this.s = scene3d; // BabylonScene
this.scene = scene3d.scene;
this._active = null; // активная сессия placement или null
this._tickObs = null; // observer renderLoop
this._placementSeq = 0;
// Колбэки (вызываются движком, GameRuntime роутит их в worker как события)
this._onPlace = null;
this._onCancel = null;
this._onMove = null;
}
setCallbacks({ onPlace, onCancel, onMove } = {}) {
if (onPlace !== undefined) this._onPlace = onPlace;
if (onCancel !== undefined) this._onCancel = onCancel;
if (onMove !== undefined) this._onMove = onMove;
}
isActive() { return !!this._active; }
/**
* Войти в placement-режим.
* @param {string} itemKey — ключ предмета (передаётся обратно в onPlace)
* @param {object} opts — см. 11_placement_mode.md §2.1
* @returns {string} placementId
*/
start(itemKey, opts = {}) {
// Уже активна сессия — отменим прежнюю (без onCancel-шума автора).
if (this._active) this._teardown(false);
const o = {
previewType: opts.previewType || 'primitive:cube',
previewColor: opts.previewColor || '#a0522d',
previewScale: Number(opts.previewScale) || 1,
// modelScale — реальный scale воксельной модели для превью (чтобы
// полупрозрачная копия была того же размера, что и ставимый объект).
modelScale: Number(opts.modelScale) || Number(opts.previewScale) || 1,
ghostOpacity: opts.ghostOpacity != null ? Number(opts.ghostOpacity) : 0.5,
surfaceMode: opts.surfaceMode || 'ground', // 'ground'|'any'|'tag'
allowSurfaces: Array.isArray(opts.allowSurfaces) ? opts.allowSurfaces : null,
forbidOverlap: opts.forbidOverlap !== false,
grid: opts.grid != null ? Number(opts.grid) : 1,
rotationStep: opts.rotationStep != null ? Number(opts.rotationStep) : 90,
targetZone: opts.targetZone || null, // ref-строка примитива-зоны
showZoneOutline: opts.showZoneOutline !== false,
showArrowFrom: opts.showArrowFrom || null, // 'player' | ref
cost: Number(opts.cost) || 0,
currency: opts.currency || 'rubles',
hint: opts.hint || '',
hintError: opts.hintError || 'Разместите в отмеченном месте!',
placedType: opts.placedType || null,
chainPlace: !!opts.chainPlace,
maxDistance: opts.maxDistance != null ? Number(opts.maxDistance) : 0,
maxItems: opts.maxItems != null ? Number(opts.maxItems) : 0,
forceCameraMode: opts.forceCameraMode !== false,
freezePlayer: !!opts.freezePlayer,
previewPulse: opts.previewPulse !== false,
};
const id = 'placement_' + (++this._placementSeq);
const preview = this._createPreview(o);
this._active = {
id, itemKey, opts: o, preview,
rotationY: 0,
valid: false,
pos: new Vector3(0, 0, 0),
zoneOutline: null,
arrowFxRef: null,
placedCount: 0,
pulseT: 0,
prevCameraMode: null,
prevFrozen: null,
};
// Зона размещения — красный контур по AABB.
if (o.targetZone && o.showZoneOutline) this._createZoneOutline();
// Стрелка от игрока/объекта к зоне (переиспользует fx.pointer задачи 08).
if (o.showArrowFrom && o.targetZone) this._createArrow();
// Камера: placement требует видимый курсор — в first переводим в third.
if (o.forceCameraMode) this._forceThirdCamera();
// Заморозка игрока (опция).
if (o.freezePlayer) this._setPlayerFrozen(true);
// HUD: подсказки снизу-справа + верхний hint. Сообщаем движку.
this._emitHud(true);
this._startTick();
return id;
}
cancel() {
if (!this._active) return;
const cb = this._onCancel;
this._teardown(true);
if (typeof cb === 'function') cb();
}
/** Поставить на текущей позиции (как ЛКМ). */
confirm() {
const a = this._active;
if (!a) return false;
if (!a.valid) {
// Невалидно — звук «не получилось» + мигание preview в красный.
this._playFail();
this._flashInvalid();
return false;
}
// Позиция для spawn = точка курсора МИНУС offset центра модели (с учётом
// поворота), чтобы реальный объект встал ОСНОВАНИЕМ под курсором —
// ровно туда, где показывалось превью. Для куба-превью offset = 0.
let ox = a._modelOffsetX || 0, oz = a._modelOffsetZ || 0;
if (ox || oz) {
const c = Math.cos(a.rotationY), s = Math.sin(a.rotationY);
const rx = ox * c - oz * s;
const rz = ox * s + oz * c;
ox = rx; oz = rz;
}
const result = {
itemKey: a.itemKey,
position: { x: a.pos.x - ox, y: a.pos.y, z: a.pos.z - oz },
rotationY: a.rotationY,
};
// Списание стоимости (если задана и есть валюта-хелпер в движке).
if (a.opts.cost > 0) this._spendCurrency(a.opts.currency, a.opts.cost);
a.placedCount++;
this._playPlace();
if (typeof this._onPlace === 'function') this._onPlace(result);
if (a.opts.chainPlace) {
// Остаёмся в режиме для следующего — preview не удаляем, сбрасываем поворот? нет, сохраняем.
// Просто продолжаем тик; valid пересчитается в следующем кадре.
return true;
}
this._teardown(false);
return true;
}
/** Повернуть preview на N градусов вокруг Y. */
rotate(deg) {
const a = this._active;
if (!a) return;
const step = (deg != null ? Number(deg) : a.opts.rotationStep) || 90;
a.rotationY = (a.rotationY + step * Math.PI / 180) % (Math.PI * 2);
if (a.preview) a.preview.rotation.y = a.rotationY;
}
// ── Внутреннее ──────────────────────────────────────────────────────
_createPreview(o) {
const base = Color3.FromHexString(o.previewColor || '#a0522d');
// Для воксельной модели (user:<id>) строим ПРЕВЬЮ ИЗ РЕАЛЬНОЙ ГЕОМЕТРИИ
// модели — полупрозрачную копию. Так тень точно повторяет форму предмета
// И совпадает по позиционированию с реальным spawn (модель растёт от угла
// root, а не центрируется — куб-превью раньше центрировался → предмет
// вставал в угол превью). Здесь превью = тот же addInstance, поэтому
// угол-в-угол. Делается асинхронно (см. _buildUserModelPreview).
const pt = o.previewType || '';
if (pt.indexOf('user:') === 0 && this.s.userModelManager) {
// Временный куб-заглушка пока модель грузится (1-2 кадра), заменим.
const stub = MeshBuilder.CreateBox('placementGhostStub', { size: 0.01 }, this.scene);
stub.isPickable = false;
stub._baseColor = base;
this._buildUserModelPreview(pt, o, base);
return stub;
}
// Примитивы / прочее — полупрозрачный куб размером previewScale (юниты).
const edge = Number(o.previewScale) || 1;
const ghost = MeshBuilder.CreateBox('placementGhost', { size: edge }, this.scene);
const mat = new StandardMaterial('placementGhostMat', this.scene);
mat.diffuseColor = base;
mat.emissiveColor = base.scale(0.25);
mat.specularColor = new Color3(0, 0, 0);
mat.alpha = o.ghostOpacity;
mat.disableLighting = true;
ghost.material = mat;
ghost.isPickable = false;
ghost._baseColor = base;
return ghost;
}
/** Построить полупрозрачное превью из реальной воксельной модели (async). */
async _buildUserModelPreview(previewType, o, base) {
try {
const um = this.s.userModelManager;
// Спавним реальный инстанс модели в (0,0,0) — будем двигать как превью.
const instId = await um.addInstance(previewType, 0, 0, 0, 0, {
scale: o.modelScale || o.previewScale || 1,
canCollide: false, visible: true, anchored: true,
currentUserId: this.s._currentUserId || null,
});
if (instId == null) return;
// Сессия уже могла завершиться/смениться, пока грузилось.
const a = this._active;
if (!a) { try { um.removeInstance(instId); } catch (e) {} return; }
const inst = um.instances.get(instId);
if (!inst || !inst.rootNode) return;
// Применяем ghost-вид ко всем мешам модели: полупрозрачно, не pickable.
const ghostMat = new StandardMaterial('placementGhostMatUM', this.scene);
ghostMat.diffuseColor = base;
ghostMat.emissiveColor = base.scale(0.25);
ghostMat.specularColor = new Color3(0, 0, 0);
ghostMat.alpha = o.ghostOpacity;
ghostMat.disableLighting = true;
ghostMat.backFaceCulling = false;
for (const m of (inst.meshes || [])) {
m.isPickable = false;
m.material = ghostMat;
}
// Центр модели по X/Z (воксели растут углом от root → центр смещён).
// Вычисляем из bbox мешей в локальных координатах root (root в 0,0,0).
// Этот offset вычитаем из позиции, чтобы ОСНОВАНИЕ модели (её центр
// по X/Z) было ровно под курсором, а не угол. Применяется и к превью,
// и к реальному spawn (onPlace) — иначе предмет «уезжает» по диагонали.
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
for (const m of (inst.meshes || [])) {
m.computeWorldMatrix(true);
const bb = m.getBoundingInfo().boundingBox;
minX = Math.min(minX, bb.minimumWorld.x); maxX = Math.max(maxX, bb.maximumWorld.x);
minZ = Math.min(minZ, bb.minimumWorld.z); maxZ = Math.max(maxZ, bb.maximumWorld.z);
}
const offX = Number.isFinite(minX) ? (minX + maxX) / 2 : 0;
const offZ = Number.isFinite(minZ) ? (minZ + maxZ) / 2 : 0;
a._modelOffsetX = offX;
a._modelOffsetZ = offZ;
// Удаляем временный stub, новый root становится превью.
const old = a.preview;
a.preview = inst.rootNode;
a.preview._baseColor = base;
a.preview._userModelInstId = instId; // для teardown
a.preview._ghostMat = ghostMat;
if (old) { try { old.dispose(); } catch (e) {} }
} catch (e) {
// тихо — превью некритично, останется stub
}
}
_startTick() {
this._tickObs = this.scene.onBeforeRenderObservable.add(() => this._tick());
}
_tick() {
const a = this._active;
if (!a) return;
const scn = this.scene;
// Raycast от камеры через текущую позицию курсора.
const pick = scn.pick(scn.pointerX, scn.pointerY, (m) =>
m && m.isPickable && m !== a.preview && this._isSurface(m, a.opts));
if (pick && pick.hit && pick.pickedPoint) {
let p = pick.pickedPoint.clone();
// surfaceMode 'ground' — нормаль должна смотреть вверх.
// Поверхность валидна, если смотрит вверх (горизонтальная грань).
// Это и пол, и ВЕРХ другого объекта → можно строить стопкой.
let surfOk = true;
if (a.opts.surfaceMode === 'ground') {
const n = pick.getNormal(true);
surfOk = n && n.y > 0.6; // только грань, обращённая вверх
}
// Снэппинг к сетке (X/Z). Y берём с поверхности — чтобы объект
// лёг ровно сверху на пол ИЛИ на другой объект (стопка).
if (a.opts.grid > 0) {
p.x = Math.round(p.x / a.opts.grid) * a.opts.grid;
p.z = Math.round(p.z / a.opts.grid) * a.opts.grid;
}
a.pos.copyFrom(p);
if (a.preview) {
if (a.preview._userModelInstId != null) {
// userModel-превью: root = угол модели. Вычитаем offset центра
// по X/Z → ОСНОВАНИЕ модели (ствол/центр) точно под курсором.
// Высота p.y без сдвига (низ модели на поверхность).
a.preview.position.set(
p.x - (a._modelOffsetX || 0),
p.y,
p.z - (a._modelOffsetZ || 0),
);
} else {
// Куб-превью центрирован → поднимаем на полвысоты.
a.preview.position.set(p.x, p.y + 0.5 * a.opts.previewScale, p.z);
}
}
// Валидность. forbidOverlap теперь означает «не врезаться вбок в
// объект на ТОЙ ЖЕ высоте» — вертикальная стопка разрешена.
a.valid = surfOk
&& this._inZone(p, a.opts)
&& this._distanceOk(p, a.opts)
&& this._limitOk(a.opts)
&& this._affordable(a)
&& (!a.opts.forbidOverlap || !this._overlapsSide(p, a));
} else {
a.valid = false;
}
// Цвет preview: зелёный/красный.
this._applyTint(a, a.valid);
// Пульсация прозрачности (привлекает внимание). Материал — у куба-превью
// напрямую, у userModel-превью в _ghostMat (root — TransformNode без mat).
const pmat = a.preview && (a.preview.material || a.preview._ghostMat);
if (a.opts.previewPulse && pmat) {
a.pulseT += this.scene.getEngine().getDeltaTime() / 1000;
const k = 0.5 + 0.5 * Math.sin(a.pulseT * Math.PI); // 0..1
pmat.alpha = a.opts.ghostOpacity * (0.7 + 0.3 * k);
}
// HUD-индикатор ошибки (красный текст когда невалидно).
this._emitHudError(!a.valid);
// Стрелка к зоне — обновим конечную точку (если игрок движется).
if (a.arrowFxRef) this._updateArrow();
// onMove колбэк автору (каждый кадр).
if (typeof this._onMove === 'function') {
this._onMove({ position: { x: a.pos.x, y: a.pos.y, z: a.pos.z }, valid: a.valid });
}
}
_applyTint(a, valid) {
// Материал куба-превью напрямую, userModel-превью — в _ghostMat.
const pmat = a.preview && (a.preview.material || a.preview._ghostMat);
if (!pmat) return;
if (a._flashUntil && performance && performance.now && performance.now() < a._flashUntil) {
return; // во время flash держим красный
}
const tint = valid ? VALID_TINT : INVALID_TINT;
// Смешиваем базовый цвет с tint-ом (multiply-эффект).
const b = a.preview._baseColor || new Color3(0.6, 0.4, 0.25);
pmat.diffuseColor = new Color3(
b.r * tint.r + tint.r * 0.4,
b.g * tint.g + tint.g * 0.4,
b.b * tint.b + tint.b * 0.4,
);
pmat.emissiveColor = tint.scale(0.35);
}
_flashInvalid() {
const a = this._active;
if (!a || !a.preview || !a.preview.material) return;
try { a._flashUntil = (performance.now ? performance.now() : Date.now()) + 300; } catch { a._flashUntil = Date.now() + 300; }
a.preview.material.diffuseColor = INVALID_TINT;
a.preview.material.emissiveColor = INVALID_TINT.scale(0.6);
}
_isSurface(mesh, o) {
if (!o.allowSurfaces) return true; // любая поверхность
// Совпадение по имени или тегу.
const name = mesh.name || '';
if (o.allowSurfaces.some(s => name.includes(s))) return true;
const tags = mesh.metadata && mesh.metadata.tags;
if (Array.isArray(tags) && tags.some(t => o.allowSurfaces.includes(t))) return true;
return false;
}
_inZone(p, o) {
if (!o.targetZone) return true;
const z = this._resolveZoneMesh(o.targetZone);
if (!z) return true;
const bb = z.getBoundingInfo().boundingBox;
const min = bb.minimumWorld, max = bb.maximumWorld;
return p.x >= min.x && p.x <= max.x && p.z >= min.z && p.z <= max.z;
}
_distanceOk(p, o) {
if (!o.maxDistance || o.maxDistance <= 0) return true;
const pl = this.s.player && this.s.player._pos;
if (!pl) return true;
const dx = p.x - pl.x, dz = p.z - pl.z;
return (dx * dx + dz * dz) <= o.maxDistance * o.maxDistance;
}
_limitOk(o) {
if (!o.maxItems || o.maxItems <= 0) return true;
return (this._active.placedCount || 0) < o.maxItems;
}
_overlapsSide(p, a) {
// Боковое пересечение: объект мешает ТОЛЬКО если он на той же высоте
// (его тело пересекает уровень, куда ляжет новый объект). Объект строго
// НИЖЕ (фундамент под стопку) или строго ВЫШЕ — не мешает. Это позволяет
// строить башню из кубов, но не даёт двум кубам слипнуться вбок.
const r = Math.max(0.45, (a.opts.grid || 1) * 0.5);
const newY = p.y; // высота поверхности (низ нового объекта)
const newTop = newY + (a.opts.previewScale || 1);
for (const m of this.scene.meshes) {
if (!m.isPickable || m === a.preview) continue;
if (!m.getBoundingInfo) continue;
const bb = m.getBoundingInfo().boundingBox;
const sizeX = bb.maximumWorld.x - bb.minimumWorld.x;
if (sizeX > 8) continue; // пол/большая поверхность — не препятствие
const c = bb.centerWorld;
const dx = c.x - p.x, dz = c.z - p.z;
if (Math.abs(dx) >= r || Math.abs(dz) >= r) continue; // не под курсором
const mTop = bb.maximumWorld.y, mBot = bb.minimumWorld.y;
// Пересечение по вертикали: тела перекрываются по Y → бок в бок.
const overlapY = newY < (mTop - 0.05) && newTop > (mBot + 0.05);
if (overlapY) return true;
}
return false;
}
/** Хватает ли валюты на текущий предмет (если задан баланс). */
_affordable(a) {
const cur = a.opts.currency;
const cost = a.opts.cost || 0;
if (!cost) return true;
const bal = (this._balances && this._balances[cur] != null) ? this._balances[cur] : Infinity;
return cost <= bal;
}
/** Установить баланс валюты (для проверки «нельзя уйти в минус»). */
setBalance(currency, amount) {
if (!this._balances) this._balances = {};
if (currency) this._balances[currency] = Number(amount) || 0;
}
_resolveZoneMesh(ref) {
// ref может быть строкой ('primitive:N' / имя) или уже мешем.
if (ref && ref.getBoundingInfo) return ref;
if (typeof ref === 'string') {
// через scene3d — найти примитив/модель по ref
try {
const mesh = this.s._refStrToMesh ? this.s._refStrToMesh(ref) : null;
if (mesh) return mesh;
} catch { /* ignore */ }
// fallback — по имени
return this.scene.getMeshByName(ref) || null;
}
return null;
}
_createZoneOutline() {
const a = this._active;
const z = this._resolveZoneMesh(a.opts.targetZone);
if (!z) return;
const bb = z.getBoundingInfo().boundingBox;
const min = bb.minimumWorld, max = bb.maximumWorld;
const y = min.y + 0.06;
const pts = [
new Vector3(min.x, y, min.z), new Vector3(max.x, y, min.z),
new Vector3(max.x, y, max.z), new Vector3(min.x, y, max.z),
new Vector3(min.x, y, min.z),
];
const line = MeshBuilder.CreateLines('placementZoneOutline', { points: pts }, this.scene);
line.color = new Color3(1, 0.19, 0.19);
line.isPickable = false;
// glow-имитация: чуть приподнятая полупрозрачная плоскость
a.zoneOutline = line;
}
_createArrow() {
const a = this._active;
// Стрелка-указатель через BeamManager (задача 08: addPointer/setPointerTarget).
try {
const bm = this.s.beamManager;
if (!bm || !bm.addPointer) return;
const z = this._resolveZoneMesh(a.opts.targetZone);
if (!z) return;
const c = z.getBoundingInfo().boundingBox.centerWorld;
const from = (a.opts.showArrowFrom === 'player' && this.s.player && this.s.player._pos)
? this.s.player._pos
: this._resolveZoneMesh(a.opts.showArrowFrom);
const fromV = from && from.x != null ? new Vector3(from.x, (from.y || 0) + 1, from.z) : null;
if (!fromV) return;
a.arrowFxRef = bm.addPointer({
from: { x: fromV.x, y: fromV.y, z: fromV.z },
to: { x: c.x, y: c.y + 0.6, z: c.z },
preset: 'guide',
});
} catch { /* стрелка не критична */ }
}
_updateArrow() {
// Стрелка статична от точки старта к зоне (как в Roblox tycoon —
// указатель «куда ставить»). BeamManager не имеет setPointerOrigin,
// а пересоздавать каждый кадр дорого. Конец уже привязан к зоне.
}
_forceThirdCamera() {
const a = this._active;
try {
if (this.s.player && this.s.player.getCameraMode && this.s.player.setCameraMode) {
a.prevCameraMode = this.s.player.getCameraMode();
if (a.prevCameraMode === 'first') this.s.player.setCameraMode('third');
}
} catch { /* ignore */ }
}
_setPlayerFrozen(frozen) {
try {
if (this.s.player && this.s.player.setFrozen) {
if (this._active) this._active.prevFrozen = true;
this.s.player.setFrozen(frozen);
}
} catch { /* ignore */ }
}
_spendCurrency(currency, amount) {
// Движок не держит «кошелёк» — это делает игра через onPlace + save.
// Но если есть хелпер валюты, вызовем. Иначе — no-op (автор сам спишет).
try {
if (this.s.spendCurrency) this.s.spendCurrency(currency, amount);
} catch { /* ignore */ }
}
_playPlace() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('place'); } catch { /* ignore */ } }
_playFail() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('lose'); } catch { /* ignore */ } }
_emitHud(show) {
// Сообщаем движку показать/скрыть placement-HUD (подсказки).
try {
if (this.s.onPlacementHud) this.s.onPlacementHud({ show, hint: this._active ? this._active.opts.hint : '' });
} catch { /* ignore */ }
}
_emitHudError(isError) {
try {
if (this.s.onPlacementHudError) this.s.onPlacementHudError(isError);
} catch { /* ignore */ }
}
_teardown(emitHudOff) {
const a = this._active;
if (!a) return;
if (this._tickObs) { this.scene.onBeforeRenderObservable.remove(this._tickObs); this._tickObs = null; }
if (a.preview) {
try {
if (a.preview._userModelInstId != null && this.s.userModelManager) {
// userModel-превью — это реальный инстанс; удаляем через менеджер
// (снимет из Map + dispose мешей). + чистим ghost-материал.
try { a.preview._ghostMat && a.preview._ghostMat.dispose(); } catch (e) {}
this.s.userModelManager.removeInstance(a.preview._userModelInstId);
} else {
a.preview.material && a.preview.material.dispose();
a.preview.dispose();
}
} catch { /* ignore */ }
}
if (a.zoneOutline) { try { a.zoneOutline.dispose(); } catch { /* ignore */ } }
if (a.arrowFxRef != null && this.s.beamManager && this.s.beamManager.remove) {
try { this.s.beamManager.remove(a.arrowFxRef); } catch { /* ignore */ }
}
if (a.prevCameraMode === 'first' && this.s.player && this.s.player.setCameraMode) {
try { this.s.player.setCameraMode('first'); } catch { /* ignore */ }
}
if (a.prevFrozen && this.s.player && this.s.player.setFrozen) {
try { this.s.player.setFrozen(false); } catch { /* ignore */ }
}
this._active = null;
if (emitHudOff !== false) this._emitHud(false);
}
/** Полный сброс при Stop игры. */
dispose() {
this._teardown(true);
this._onPlace = this._onCancel = this._onMove = null;
}
}