/** * 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:) строим ПРЕВЬЮ ИЗ РЕАЛЬНОЙ ГЕОМЕТРИИ // модели — полупрозрачную копию. Так тень точно повторяет форму предмета // И совпадает по позиционированию с реальным 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; } }