From ee1b7352b79a12a4f35ff055901519c3264a53be Mon Sep 17 00:00:00 2001 From: min Date: Tue, 2 Jun 2026 19:06:03 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(11):=20placement=20mode=20=E2=80=94=20?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BC=D0=B5=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20(tycoon)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Движок: 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 --- src/community/docsGames.js | 5 + src/community/docsLessons.jsx | 148 ++++++ src/editor/Hotbar.jsx | 6 + src/editor/KubikonEditor.jsx | 41 +- src/editor/MinimapOverlay.jsx | 6 +- src/editor/engine/BabylonScene.js | 30 ++ src/editor/engine/GameRuntime.js | 82 ++++ src/editor/engine/PlacementManager.js | 589 +++++++++++++++++++++++ src/editor/engine/ScriptSandboxWorker.js | 104 ++++ src/editor/engine/ShopInventoryUi.js | 132 +++++ src/editor/engine/TerrainManager.js | 19 + 11 files changed, 1160 insertions(+), 2 deletions(-) create mode 100644 src/editor/engine/PlacementManager.js create mode 100644 src/editor/engine/ShopInventoryUi.js diff --git a/src/community/docsGames.js b/src/community/docsGames.js index 1c3317a..817b899 100644 --- a/src/community/docsGames.js +++ b/src/community/docsGames.js @@ -333,4 +333,9 @@ export const GAMES = [ desc: 'Живые 3D-надписи + витрина-лутбокс: таймер над башней, ряд подиумов с вращающимися предметами и наклонными табличками-ценниками, счётчик монет (клик +10), HP над зомби. Текст крепится плоско к грани наклонного примитива.', mechanics: ['scene.bindLabel', 'scene.bindTimer', 'attachFace (текст на грани)', '5 пресетов (gameui/boss-hp/reward…)', 'richText ', 'game.format.money', 'obj.move/rotate', 'onClick объекта'], previewShot: 'guide-dynamic-label-scene.png', openProjectId: 2261, ready: true }, + { id: 'guide-zavod', num: 58, group: 'g5', stars: 2, icon: 'cart', + title: 'Мой завод — расстановка предметов (placement)', + desc: 'Tycoon-механика «купи → поставь»: магазин-инвентарь снизу, клик по слоту → полупрозрачная тень предмета летит за курсором, ЛКМ ставит на свой участок (можно стопкой), R/колесо — поворот, ПКМ/Esc — отмена. Денег мало → слот серый. Воксельный мир: трава, деревья, пруд.', + mechanics: ['game.placement.start', 'game.inventoryUi (магазин-слоты)', 'onPlace/onCancel/onMove', 'тень-превью формой модели', 'снап к сетке + стопка', 'проверка баланса (не в минус)', 'воксельные модели + ландшафт'], + previewShot: 'guide-zavod-scene.png', openProjectId: 2345, ready: true }, ]; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index c669aed..3682d17 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -8107,6 +8107,154 @@ zombie.onClick(() => { hp = Math.max(0, hp - 10); });`} ), }, + 'guide-zavod': { + body: ( + <> +

Что получится

+

+ Маленький tycoon-завод на воксельном острове: трава, холмы, + деревья, пруд. Снизу — магазин-инвентарь из трёх слотов + (ящик, дерево, печь). Кликаешь слот → за курсором летит + полупрозрачная тень предмета, повторяющая его форму. ЛКМ + ставит предмет на свой участок (можно класть стопкой), + R или колесо — поворот, ПКМ/Esc — отмена. Денег не + хватает → слот серый, поставить нельзя. Это базовый + gameplay-loop любой топ-игры: купи → поставь → развивай + (Pet Simulator, Lumber Tycoon, Build A Boat). +

+ + + +

Чему научишься

+
    +
  • game.inventoryUi.create(opts) — готовый магазин-инвентарь: + нижняя панель слотов с иконкой/ценой; клик по слоту зовёт + onSlotClick(item). Слот сам сереет, если валюты мало;
  • +
  • game.placement.start(itemKey, opts) — войти в режим + расстановки: тень предмета за курсором, снап к сетке, проверка + зоны и баланса, подсветка валидности (зелёный/красный);
  • +
  • placement.onPlace(fn) — колбэк «предмет поставлен»: + тут спавним реальный объект и списываем деньги;
  • +
  • тень-превью формой модели — для воксельных моделей тень + повторяет их геометрию (не куб) и основание точно под курсором;
  • +
  • стопка и снап — предметы ложатся друг на друга и + прилипают к сетке;
  • +
  • inventoryUi.setBalance(валюта, сумма) — обновляешь баланс → + дорогие слоты автоматически серые (нельзя уйти в минус);
  • +
  • воксельные модели + ландшафт — мир собран из voxel- + моделей и террейна (трава/деревья/вода), а не голых кубов.
  • +
+ +

Шаг 1. Магазин-инвентарь снизу

+

+ game.inventoryUi.create рисует панель слотов. Каждый + слот — это товар: ключ, название, иконка, цена и тип модели. + В onSlotClick запускаем расстановку этого товара. +

+ + {`let rubles = 1000; +function wallet() { + game.ui.set('wallet', game.format.money(rubles) + ' рубликов', + { x: 50, y: 6, anchor: 'top', color: '#ffd23a', size: 24 }); + // Обновляем баланс магазина — слоты дороже денег станут серыми: + game.inventoryUi.setBalance('rubles', rubles); +} +wallet(); + +game.after(0.4, () => { + game.inventoryUi.create({ + items: [ + { key: 'crate', name: 'Ящик', icon: 'crate', cost: 50, modelType: 'user:444' }, + { key: 'tree', name: 'Дерево', icon: 'plant', cost: 100, modelType: 'user:445' }, + { key: 'oven', name: 'Печь', icon: 'oven', cost: 500, modelType: 'user:446' }, + ], + position: 'bottom', showCost: true, showCurrency: 'rubles', + onSlotClick: (item) => startPlacing(item.key), // ← см. Шаг 2 + }); + wallet(); +});`} + +

Шаг 2. Режим расстановки (тень за курсором)

+

+ game.placement.start создаёт полупрозрачную тень и + включает режим: тень едет за курсором, снаппится к сетке, краснеет + вне зоны. previewScale для куба — размер в юнитах, а + для воксельной модели задавай modelScale = масштаб + модели — тогда тень будет точной формой предмета. +

+ + {`const SHOP = { + crate: { id: 'user:444', cost: 50, scale: 1.6 }, + tree: { id: 'user:445', cost: 100, scale: 7.0 }, + oven: { id: 'user:446', cost: 500, scale: 2.0 }, +}; + +function startPlacing(key) { + const s = SHOP[key]; + game.placement.start(key, { + previewType: s.id, // 'user:' — воксельная модель + modelScale: s.scale, // тень той же формы и размера, что предмет + surfaceMode: 'ground', // ставим на землю ИЛИ на верх другого объекта (стопка) + grid: 0.5, // снап к полусетке + cost: s.cost, currency: 'rubles', // нет денег → ставить нельзя + targetZone: game.scene.findOne('player-plot'), // свой участок + chainPlace: true, // ставить подряд, не выходя из режима + previewPulse: true, // тень слегка пульсирует + }); +}`} + + Управление в режиме расстановки: ЛКМ — поставить, + R или колесо мыши — повернуть на 90°, + ПКМ / Esc — отменить. С chainPlace: true + после установки режим не закрывается — удобно строить рядами. + + +

Шаг 3. Поставили — спавним и платим

+

+ Когда игрок жмёт ЛКМ на валидном месте, движок зовёт + onPlace с позицией и поворотом. Здесь создаём + настоящий объект (тень — лишь подсказка) и списываем монеты. +

+ + {`game.placement.onPlace(({ itemKey, position, rotationY }) => { + const s = SHOP[itemKey]; + game.scene.spawn(s.id, { + x: position.x, y: position.y, z: position.z, + rotationY: rotationY, scale: s.scale, + name: itemKey + '_' + Date.now(), + }); + rubles -= s.cost; + wallet(); // обновили счётчик и серость слотов +}); + +// (необязательно) реакция на отмену: +game.placement.onCancel(() => game.ui.set('hint', '', {}));`} + + + +

Почему это «честная» механика

+

+ Тень ≠ предмет. Пока ты в режиме расстановки — на сцене + только полупрозрачная подсказка. Реальный объект появляется + один раз в onPlace. Поэтому ничего не дублируется, + а всё, что наспавнено за игру, удаляется при выходе (Esc/Стоп) — + игровая сессия не «протекает» в сохранение. Деньги списываются только + при успешной установке, а недоступные по цене слоты серые — в минус + уйти нельзя. +

+ + + Добавь четвёртый товар в магазин (например, фонарь — модель + user:448) со своей ценой. Сделай ему дорогую стоимость + и проверь: пока денег мало — слот серый и не ставится, а как накопишь — + станет активным. Попробуй построить из ящиков башню (стопкой). + + + ), + }, + }; /** Есть ли готовый текст урока для игры с таким id. */ diff --git a/src/editor/Hotbar.jsx b/src/editor/Hotbar.jsx index 3e1cfa2..0deb3c2 100644 --- a/src/editor/Hotbar.jsx +++ b/src/editor/Hotbar.jsx @@ -16,6 +16,12 @@ import Icon from './Icon'; function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) { if (!visible) return null; + // ГЛОБАЛЬНОЕ ПРАВИЛО (для всех игр тулбокса): если в инвентаре нет ни + // одного предмета — панель инвентаря НЕ показываем вовсе. Пустой hotbar + // из 5 серых ячеек загромождает экран в играх, где инвентарь не нужен. + const hasAnyItem = Array.isArray(slots) && slots.some((s) => s != null); + if (!hasAnyItem) return null; + const SLOT_COUNT = 5; const cells = []; for (let i = 0; i < SLOT_COUNT; i++) { diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 3be0458..54842ef 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -574,6 +574,12 @@ const KubikonEditor = () => { * 0 — защита неактивна. */ const lastLoadedDecoCountRef = useRef(0); + // Guard от потери воксельных моделей и скриптов при частичной загрузке: + // terrain мог догрузиться, а userModels/scripts — нет (getProject упал по + // таймауту на середине), и автосейв затирал их. Запоминаем сколько было + // ЗАГРУЖЕНО — если стало 0 при ненулевом базовом, блокируем сейв. + const lastLoadedUserModelCountRef = useRef(0); + const lastLoadedScriptCountRef = useRef(0); // HP игрока + патроны const [playerHp, setPlayerHp] = useState({ hp: 100, maxHp: 100 }); // Скрипт через game.hud.setVisible(false) может полностью скрыть HUD @@ -1014,6 +1020,33 @@ const KubikonEditor = () => { return; } + // === ЗАЩИТА ОТ WIPE: воксельные модели и скрипты === + // terrain мог догрузиться, а userModels/scripts — нет (частичная загрузка + // из-за таймаута getProject), и автосейв затирал их пустотой. Если было + // загружено N (>0), а сейчас 0 И пользователь не редактировал — блок. + // Реальный инцидент 2026-06-02: игра «Мой завод» (2345) потеряла все + // 8 моделей и скрипт магазина после частичной загрузки. + if (!userWasEditing) { + const s2 = sceneRef.current; + const curUm = s2.userModelManager?.instances?.size ?? 0; + const curScr = (s2._scripts || []).filter(x => x && x.id !== 'demo').length; + const lostUm = lastLoadedUserModelCountRef.current > 0 && curUm === 0; + const lostScr = lastLoadedScriptCountRef.current > 0 && curScr === 0; + if (lostUm || lostScr) { + console.error( + `[KubikonEditor] SAVE BLOCKED: потеря userModels(${lastLoadedUserModelCountRef.current}→${curUm}) ` + + `или scripts(${lastLoadedScriptCountRef.current}→${curScr}) — вероятно частичная загрузка. Перезагрузите страницу.` + ); + setSaveStatus('error'); + setSaveDetail({ + phase: 'Сохранение заблокировано: пропали модели/скрипты (неполная загрузка). Перезагрузите страницу!', + pct: 0, error: true, + }); + setTimeout(() => setSaveDetail(null), 8000); + return; + } + } + isSavingRef.current = true; setSaveStatus('saving'); setSaveDetail({ phase: 'Сбор данных сцены…', pct: 5 }); @@ -1418,7 +1451,13 @@ const KubikonEditor = () => { // автосейв затирал деко (реальный инцидент с проектом 222). const deco = sceneRef.current._smoothDecoManager?.getStats?.().total ?? 0; lastLoadedDecoCountRef.current = deco; - console.log(`[KubikonEditor] guard armed: lastLoadedVoxelCount=${tm + tmesh + rt} (legacy=${tm}, tmesh=${tmesh}, roblox=${rt}), deco=${deco}`); + // Guard для воксельных моделей и скриптов (см. ref выше). + const umLoaded = sceneRef.current.userModelManager?.instances?.size ?? 0; + const scrLoaded = (sceneRef.current._scripts || []) + .filter(x => x && x.id !== 'demo').length; + lastLoadedUserModelCountRef.current = umLoaded; + lastLoadedScriptCountRef.current = scrLoaded; + console.log(`[KubikonEditor] guard armed: lastLoadedVoxelCount=${tm + tmesh + rt} (legacy=${tm}, tmesh=${tmesh}, roblox=${rt}), deco=${deco}, userModels=${umLoaded}, scripts=${scrLoaded}`); // Триггерим пересчёт hasRobloxTerrain в TerrainGenPanel if (rt > 0) setRobloxTerrainBump((n) => n + 1); } catch (e) {} diff --git a/src/editor/MinimapOverlay.jsx b/src/editor/MinimapOverlay.jsx index fa46af3..759879a 100644 --- a/src/editor/MinimapOverlay.jsx +++ b/src/editor/MinimapOverlay.jsx @@ -56,7 +56,11 @@ export default function MinimapOverlay({ scene }) { useEffect(() => { if (!scene) return; const interval = setInterval(() => { - const v = !!scene._terrainStreamingEnabled; + // 2026-06-02: миникарту показываем ТОЛЬКО если игра её явно включила + // (scene._minimapEnabled, флаг из настроек сцены). Раньше она + // появлялась автоматически на любой большой карте (streaming) и + // мешала — лишний оверлей в играх, где карта не нужна. + const v = !!scene._minimapEnabled; setVisible(prev => prev !== v ? v : prev); }, 500); return () => clearInterval(interval); diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index e278908..cfb0724 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -38,6 +38,8 @@ import { PointerEventTypes, Tools as BabylonTools, } from '@babylonjs/core'; +import { PlacementManager } from './PlacementManager'; +import { ShopInventoryUi } from './ShopInventoryUi'; import { BlockManager } from './BlockManager'; import { TerrainManager, VOXEL_SIZE as TERRAIN_VOXEL_SIZE, TERRAIN_MATERIALS as TERRAIN_MATERIAL_DEFS } from './TerrainManager'; // Этап 1 voxel-движка: новые классы chunks-based архитектуры (см. @@ -145,6 +147,14 @@ export class BabylonScene { this.npcManager = null; // управляемые скриптом NPC (Фаза 4.1) this.constraintManager = null; // связи объектов (Фаза 5, Constraints) this.beamManager = null; // лучи и следы (Фаза 5.2) + // Placement mode — drag-and-drop размещение объектов (задача 11). + // Менеджеры создаются лениво в GameRuntime при первом game.placement.start / + // game.inventoryUi.create. Классы передаём ссылкой, чтобы GameRuntime не + // импортировал их напрямую (избегаем циклических импортов). + this.placementManager = null; + this.shopInventoryUi = null; + this._PlacementManagerClass = PlacementManager; + this._ShopInventoryUiClass = ShopInventoryUi; this.spawnerManager = null; // спавнеры зомби this.environment = null; this.audioManager = null; @@ -2198,6 +2208,12 @@ export class BabylonScene { // даже если поверх есть другие listeners. const onMouseDown = (e) => { if (this._isPlaying) { + // Placement mode (задача 11): ЛКМ ставит, ПКМ отменяет. + // Перехватываем ДО обычного play-клика, пока режим активен. + if (this.placementManager && this.placementManager.isActive()) { + if (e.button === 0) { e.preventDefault(); this.placementManager.confirm(); return; } + if (e.button === 2) { e.preventDefault(); this.placementManager.cancel(); return; } + } // В Play-режиме ЛКМ — клик игрока в forward-направлении. // Pointer Lock — курсор всё равно в центре экрана. if (e.button === 0) { @@ -2398,6 +2414,11 @@ export class BabylonScene { const onWheel = (e) => { e.preventDefault(); + // Placement mode (задача 11): колесо вверх — повернуть preview. + if (this._isPlaying && this.placementManager && this.placementManager.isActive()) { + this.placementManager.rotate(); + return; + } const forward = this._getCameraForward(); const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED; this.camera.position.addInPlace(forward.scale(delta)); @@ -2445,6 +2466,11 @@ export class BabylonScene { const key = this._normalizeKey(e); this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code }); } + // Placement mode (задача 11): R — повернуть preview, Esc — отмена. + if (this._isPlaying && this.placementManager && this.placementManager.isActive()) { + if (e.code === 'KeyR') { e.preventDefault(); this.placementManager.rotate(); return; } + if (e.code === 'Escape') { e.preventDefault(); this.placementManager.cancel(); return; } + } if (e.code === 'KeyF') { this._focusOnTarget(new Vector3(0, 0, 0)); } @@ -7548,6 +7574,10 @@ export class BabylonScene { this.gameRuntime = null; } + // Placement mode (задача 11): сброс активной сессии + виджета магазина. + if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) { /* ignore */ } this.placementManager = null; } + if (this.shopInventoryUi) { try { this.shopInventoryUi.dispose(); } catch (e) { /* ignore */ } this.shopInventoryUi = null; } + // Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением) if (this.gdLevelManager) { this.gdLevelManager.stop(); diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 0a515a2..b8dca78 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -505,6 +505,12 @@ export class GameRuntime { s?.modelManager?.removeInstance(Number(rest)); } else if (kind === 'primitive') { s?.primitiveManager?.removeInstance(Number(rest)); + } else if (kind === 'usermodel') { + // Воксельные модели, наспавненные скриптом (placement и т.п.). + // БЕЗ этой ветки placed-объекты (ящик/дерево/печь) оставались + // на сцене после Stop — игровая механика «утекала» в редактор + // (баг задачи 11, 2026-06-02). + s?.userModelManager?.removeInstance(Number(rest)); } } catch (e) { /* ignore — объект мог быть уже удалён скриптом */ } } @@ -1209,6 +1215,31 @@ export class GameRuntime { return this._skinState; } + /** Ленивая инициализация PlacementManager (задача 11). */ + _ensurePlacementManager() { + if (this.scene3d?.placementManager) return this.scene3d.placementManager; + if (!this.scene3d || !this.scene3d.scene) return null; + try { + // Динамический импорт не нужен — модуль подключён в BabylonScene. + if (this.scene3d._PlacementManagerClass) { + this.scene3d.placementManager = new this.scene3d._PlacementManagerClass(this.scene3d); + } + } catch (e) { this._log('error', 'placementManager init: ' + (e?.message || e)); } + return this.scene3d.placementManager || null; + } + + /** Ленивая инициализация виджета слот-инвентаря магазина (задача 11). */ + _ensureShopInventory() { + if (this.scene3d?.shopInventoryUi) return this.scene3d.shopInventoryUi; + if (!this.scene3d) return null; + try { + if (this.scene3d._ShopInventoryUiClass) { + this.scene3d.shopInventoryUi = new this.scene3d._ShopInventoryUiClass(this.scene3d); + } + } catch (e) { this._log('error', 'shopInventoryUi init: ' + (e?.message || e)); } + return this.scene3d.shopInventoryUi || null; + } + /** slug → _modelTypeId движка. Встроенные → 'skin_', character-* как есть. */ _resolveSkinTypeId(slug) { if (!slug) return 'character-a'; @@ -1687,6 +1718,55 @@ export class GameRuntime { if (cid != null) this.scene3d?.constraintManager?.remove(cid); return; } + // === Placement mode — drag-and-drop размещение (задача 11) === + if (cmd === 'placement.start') { + const pm = this._ensurePlacementManager(); + if (pm && payload) { + // Колбэки placement → рассылаем в worker как globalEvent. + pm.setCallbacks({ + onPlace: (res) => { + for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeConfirm', ...res }); + }, + onCancel: () => { + for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeCancel' }); + }, + onMove: (mv) => { + for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeMove', ...mv }); + }, + }); + try { pm.start(payload.itemKey, payload.opts || {}); } + catch (e) { this._log('error', 'placement.start: ' + (e?.message || e)); } + } + return; + } + if (cmd === 'placement.cancel') { this.scene3d?.placementManager?.cancel(); return; } + if (cmd === 'placement.confirm') { this.scene3d?.placementManager?.confirm(); return; } + if (cmd === 'placement.rotate') { this.scene3d?.placementManager?.rotate(payload?.deg); return; } + + // === Слот-инвентарь магазина (задача 11) === + if (cmd === 'inventoryUi.create') { + const im = this._ensureShopInventory(); + if (im && payload) { + try { + im.create(payload, (item) => { + for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'invUiSlotClick', key: item.key, item }); + }); + } catch (e) { this._log('error', 'inventoryUi.create: ' + (e?.message || e)); } + } + return; + } + if (cmd === 'inventoryUi.setBalance') { + // Баланс идёт И в виджет (серые слоты), И в PlacementManager + // (чтобы нельзя было подтвердить покупку в минус). + this.scene3d?.shopInventoryUi?.setBalance(payload?.currency, payload?.amount); + this.scene3d?.placementManager?.setBalance(payload?.currency, payload?.amount); + return; + } + if (cmd === 'inventoryUi.remove') { + this.scene3d?.shopInventoryUi?.remove(); + return; + } + // === Beam / Trail — лучи и следы (Фаза 5.2) === if (cmd === 'fx.create') { // payload: { kind: 'beam'|'trail', localRef, ... } @@ -3601,6 +3681,8 @@ export class GameRuntime { const opts = payload; const p = this.scene3d?.userModelManager?.addInstance( subType, opts.x, opts.y, opts.z, opts.rotationY || 0, + // scale — воксельные модели мелкие, placement передаёт крупнее. + (opts.scale && Number(opts.scale) > 0) ? { scale: Number(opts.scale) } : {}, ); Promise.resolve(p).then((instId) => { if (instId == null) return; diff --git a/src/editor/engine/PlacementManager.js b/src/editor/engine/PlacementManager.js new file mode 100644 index 0000000..9391bb7 --- /dev/null +++ b/src/editor/engine/PlacementManager.js @@ -0,0 +1,589 @@ +/** + * 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; + } +} diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 6cfc444..99ac7fd 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -97,6 +97,12 @@ let _selfTouchHandlers = []; let _selfUntouchHandlers = []; // Подписки self.onInteract — взаимодействие по клавише E (ProximityPrompt) let _selfInteractHandlers = []; +// Подписки placement-режима (задача 11): game.placement.onPlace/onCancel/onMove. +let _placeOnPlaceHandlers = []; +let _placeOnCancelHandlers = []; +let _placeOnMoveHandlers = []; +// Подписки слот-инвентаря магазина: game.inventoryUi onSlotClick. +let _invUiSlotClickHandlers = []; // Подписки на касание/клик ПРОИЗВОЛЬНОГО объекта (через findOne(x).onTouch). // ref → { touch:[fn], untouch:[fn], click:[fn] }. Движок следит за AABB этих // объектов (cmd 'inst.watchTouch') и шлёт обратно instTouch/instUntouch/instClick. @@ -1565,6 +1571,9 @@ const game = { subType: 'user:' + subType, x, y, z, rotationY: opts.rotationY, + // scale — воксельная модель мелкая (VOXEL_SIZE 0.0625); + // для placement обычно нужен крупнее. Прокидываем в addInstance. + scale: opts.scale, name: opts.name, ref, }); @@ -3667,6 +3676,87 @@ const game = { const na = Number(a), nb = Number(b), nt = Number(t); return na + (nb - na) * nt; }, + + /** + * game.placement — drag-and-drop размещение объектов (задача 11). + * Фундамент tycoon/farm/simulator: «кликнул предмет → preview за курсором + * → ЛКМ ставит». См. 11_placement_mode.md. + * + * game.placement.start('crate', { + * previewType: 'model:crate', surfaceMode: 'ground', grid: 1, + * cost: 50, currency: 'rubles', targetZone: game.scene.findOne('plot'), + * showArrowFrom: 'player', showZoneOutline: true, chainPlace: true, + * }); + * game.placement.onPlace(({ itemKey, position, rotationY }) => { ... }); + */ + placement: { + /** Войти в режим расстановки. opts — см. 11_placement_mode.md §2.1. */ + start(itemKey, opts) { + if (typeof itemKey !== 'string' || !itemKey) return null; + const o = opts && typeof opts === 'object' ? opts : {}; + // targetZone может прийти как ref-объект findOne — нормализуем в строку. + const out = { itemKey, opts: { ...o } }; + if (o.targetZone) out.opts.targetZone = _normRef(o.targetZone) || o.targetZone; + _send('placement.start', out); + return itemKey; + }, + /** Отменить активный режим (как ПКМ/Esc). */ + cancel() { _send('placement.cancel', {}); }, + /** Поставить на текущей позиции (как ЛКМ). */ + confirm() { _send('placement.confirm', {}); }, + /** Повернуть preview на N градусов (по умолчанию rotationStep). */ + rotate(deg) { _send('placement.rotate', { deg: Number(deg) || undefined }); }, + /** fn({ itemKey, position:{x,y,z}, rotationY }) — объект размещён. */ + onPlace(fn) { if (typeof fn === 'function') _placeOnPlaceHandlers.push(fn); }, + /** fn() — режим отменён игроком. */ + onCancel(fn) { if (typeof fn === 'function') _placeOnCancelHandlers.push(fn); }, + /** fn({ position:{x,y,z}, valid }) — каждый кадр, движение preview. */ + onMove(fn) { if (typeof fn === 'function') _placeOnMoveHandlers.push(fn); }, + }, + + /** + * game.inventoryUi — готовый GUI-кит «слот-инвентарь магазина» (задача 11). + * Нижняя/боковая панель кнопок-слотов с иконкой/ценой/hover. Клик по слоту + * → onSlotClick(item) (обычно автор зовёт game.placement.start внутри). + * Слот серый и некликабельный, если валюты недостаточно (showCurrency + getBalance). + * + * game.inventoryUi.create({ + * items: [{ key:'crate', name:'Базовый ящик', icon:'crate', cost:50, modelType:'model:crate' }], + * position: 'bottom', showCost: true, showCurrency: 'rubles', + * onSlotClick: (item) => game.placement.start(item.key, {...}), + * }); + */ + inventoryUi: { + /** Создать панель слотов. См. 11_placement_mode.md §2.7. */ + create(opts) { + const o = opts && typeof opts === 'object' ? opts : {}; + const items = Array.isArray(o.items) ? o.items : []; + if (typeof o.onSlotClick === 'function') { + // Регистрируем колбэк под индексом — движок пришлёт invUiSlotClick {key}. + _invUiSlotClickHandlers.push(o.onSlotClick); + } + _send('inventoryUi.create', { + items: items.map(it => ({ + key: String(it.key || ''), + name: String(it.name || ''), + icon: it.icon || '', + cost: Number(it.cost) || 0, + modelType: it.modelType || '', + })), + position: o.position || 'bottom', + slotSize: Number(o.slotSize) || 80, + spacing: Number(o.spacing) || 4, + showCost: o.showCost !== false, + showCurrency: o.showCurrency || '', + }); + }, + /** Обновить баланс валюты (для авто-серых слотов). */ + setBalance(currency, amount) { + _send('inventoryUi.setBalance', { currency: String(currency || ''), amount: Number(amount) || 0 }); + }, + /** Скрыть/удалить панель. */ + remove() { _send('inventoryUi.remove', {}); }, + }, }; /** @@ -4059,6 +4149,20 @@ self.onmessage = (e) => { } else if (t === 'skinUnlocked') { const slug = payload && payload.slug; if (slug && _unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); + } else if (t === 'placeConfirm') { + // Задача 11: объект размещён. payload: { itemKey, position, rotationY } + const ev = { itemKey: payload.itemKey, position: payload.position, rotationY: payload.rotationY }; + for (const fn of _placeOnPlaceHandlers) _safeCall(fn, ev, 'placement.onPlace'); + } else if (t === 'placeCancel') { + for (const fn of _placeOnCancelHandlers) _safeCall(fn, undefined, 'placement.onCancel'); + } else if (t === 'placeMove') { + // payload: { position, valid } — каждый кадр placement. + const ev = { position: payload.position, valid: !!payload.valid }; + for (const fn of _placeOnMoveHandlers) _safeCall(fn, ev, 'placement.onMove'); + } else if (t === 'invUiSlotClick') { + // payload: { key, item } — клик по слоту магазина. + const item = payload.item || { key: payload.key }; + for (const fn of _invUiSlotClickHandlers) _safeCall(fn, item, 'inventoryUi.onSlotClick'); } } else if (cmd === 'sceneSnapshot') { // payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? } diff --git a/src/editor/engine/ShopInventoryUi.js b/src/editor/engine/ShopInventoryUi.js new file mode 100644 index 0000000..0f755ff --- /dev/null +++ b/src/editor/engine/ShopInventoryUi.js @@ -0,0 +1,132 @@ +/** + * ShopInventoryUi — готовый GUI-кит «слот-инвентарь магазина» (задача 11). + * + * Нижняя (или боковая) панель кнопок-слотов с иконкой/названием/ценой и hover. + * Клик по слоту → колбэк onSlotClick(item) — обычно автор вызывает внутри + * game.placement.start(...). Слот серый и некликабельный, если валюты мало + * (показывается, когда заданы showCurrency + текущий баланс через setBalance). + * + * Реализация — лёгкий DOM-оверлей поверх canvas (а не Babylon-GUI): кнопки с + * иконками/hover/disabled делаются на HTML быстрее и доступнее. Крепится к + * родителю canvas, абсолютным позиционированием. + * + * Фича-парность: идентичный модуль в rublox-player/src/engine/. + */ + +// Встроенные inline-SVG иконки слотов (без эмодзи — самописные, см. правила UI). +const SLOT_ICONS = { + crate: '', + plant: '', + oven: '', + coin: '$', + box: '', +}; + +function iconSvg(name) { + return SLOT_ICONS[name] || SLOT_ICONS.box; +} + +export class ShopInventoryUi { + constructor(scene3d) { + this.s = scene3d; + this.root = null; + this.items = []; + this.balance = {}; // currency → amount + this.currency = ''; + this.showCost = true; + this._onSlotClick = null; + this._slotEls = []; + } + + create(opts, onSlotClick) { + this.remove(); + this.items = Array.isArray(opts.items) ? opts.items : []; + this.currency = opts.showCurrency || ''; + this.showCost = opts.showCost !== false; + this._onSlotClick = typeof onSlotClick === 'function' ? onSlotClick : null; + + const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body; + // Контейнер должен быть position:relative чтобы absolute-панель легла поверх. + try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ } + + const pos = opts.position || 'bottom'; + const slotSize = Number(opts.slotSize) || 80; + const spacing = Number(opts.spacing) || 4; + + const root = document.createElement('div'); + root.className = 'kbn-shop-inv'; + const sideStyle = { + bottom: `left:50%;bottom:18px;transform:translateX(-50%);flex-direction:row;`, + top: `left:50%;top:18px;transform:translateX(-50%);flex-direction:row;`, + left: `left:18px;top:50%;transform:translateY(-50%);flex-direction:column;`, + right: `right:18px;top:50%;transform:translateY(-50%);flex-direction:column;`, + }[pos] || ''; + root.style.cssText = + `position:absolute;display:flex;gap:${spacing}px;z-index:40;` + + `padding:8px;border-radius:14px;` + + `background:rgba(20,26,38,0.82);backdrop-filter:blur(4px);` + + `box-shadow:0 6px 24px rgba(0,0,0,0.4);` + sideStyle; + + this.items.forEach((it, idx) => { + const slot = document.createElement('button'); + slot.type = 'button'; + slot.dataset.key = it.key; + slot.style.cssText = + `width:${slotSize}px;height:${slotSize}px;border:none;border-radius:11px;` + + `display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;` + + `cursor:pointer;color:#fff;font:600 12px/1.1 system-ui,sans-serif;` + + `background:linear-gradient(180deg,#3a4a66,#26324a);` + + `transition:transform .1s,box-shadow .1s,filter .1s;position:relative;`; + slot.innerHTML = + `${iconSvg(it.icon)}` + + `${it.name || ''}` + + (this.showCost && it.cost + ? `${it.cost}${this.currency ? ' ' + this._curShort() : ''}` + : ''); + slot.onmouseenter = () => { if (!slot.disabled) { slot.style.transform = 'translateY(-3px)'; slot.style.boxShadow = '0 6px 14px rgba(0,0,0,0.45)'; } }; + slot.onmouseleave = () => { slot.style.transform = ''; slot.style.boxShadow = ''; }; + slot.onclick = () => { + if (slot.disabled) return; + if (this._onSlotClick) this._onSlotClick(it); + }; + this._slotEls[idx] = slot; + root.appendChild(slot); + }); + + parent.appendChild(root); + this.root = root; + this._refreshAffordability(); + } + + _curShort() { + const map = { rubles: 'руб', coins: 'мон', diamonds: 'алм' }; + return map[this.currency] || this.currency; + } + + /** Обновить баланс валюты — слоты дороже баланса станут серыми. */ + setBalance(currency, amount) { + if (currency) this.balance[currency] = Number(amount) || 0; + this._refreshAffordability(); + } + + _refreshAffordability() { + if (!this.currency) return; // без валюты все слоты активны + const bal = this.balance[this.currency] != null ? this.balance[this.currency] : Infinity; + this.items.forEach((it, idx) => { + const slot = this._slotEls[idx]; + if (!slot) return; + const afford = (Number(it.cost) || 0) <= bal; + slot.disabled = !afford; + slot.style.filter = afford ? '' : 'grayscale(1) brightness(0.6)'; + slot.style.cursor = afford ? 'pointer' : 'not-allowed'; + slot.style.opacity = afford ? '1' : '0.7'; + }); + } + + remove() { + if (this.root) { try { this.root.remove(); } catch { /* ignore */ } this.root = null; } + this._slotEls = []; + } + + dispose() { this.remove(); this._onSlotClick = null; } +} diff --git a/src/editor/engine/TerrainManager.js b/src/editor/engine/TerrainManager.js index 7f95cfe..8bf48aa 100644 --- a/src/editor/engine/TerrainManager.js +++ b/src/editor/engine/TerrainManager.js @@ -506,6 +506,14 @@ export class TerrainManager { const mat = new StandardMaterial(name, this.scene); // Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль. mat.specularColor = new Color3(0, 0, 0); + // 2026-06-02: воксели «просвечивали» — видна была задняя грань сквозь + // переднюю. Две страховки против этого: + // 1) backFaceCulling=false — даже при инвертированном winding обе + // стороны грани рисуются, ближняя перекрывает дальнюю по depth. + // 2) hasAlpha=false ниже (RGBA-текстура не должна включать alpha-blend). + // Для прозрачных материалов (water/glacier с def.alpha<1) culling + // вернём true, чтобы blend выглядел корректно. + mat.backFaceCulling = !(def.alpha != null && def.alpha < 1) ? false : true; // Ambient ставим в белый, чтобы hemisphere-light освещал материал // с любой стороны (иначе нижние/тыловые грани выходят серыми, что // особенно заметно на светло-бежевом песке — он становится серым). @@ -529,6 +537,17 @@ export class TerrainManager { mat.diffuseTexture.hasAlpha = true; mat.useAlphaFromDiffuseTexture = true; mat.alpha = def.alpha; + } else { + // ВАЖНО (2026-06-02): наши PNG-текстуры в формате RGBA (с альфа- + // каналом, даже если он весь 255 = непрозрачный). Babylon, видя + // альфа-канал, может включить alpha-blending → грани рисуются + // без записи в depth-buffer → дальние воксели «просвечивают» + // сквозь ближние (листва/трава были полупрозрачными). Явно + // выключаем альфу для непрозрачных материалов — крона/трава + // становятся плотными. + mat.diffuseTexture.hasAlpha = false; + mat.useAlphaFromDiffuseTexture = false; + mat.transparencyMode = 0; // OPAQUE } if (Array.isArray(def.emissive)) { mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]); -- 2.47.2 From b2b545344a4acc6e61a1470224a44ae1e24917ef Mon Sep 17 00:00:00 2001 From: min Date: Tue, 2 Jun 2026 20:09:36 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(11):=20guard=20=D0=BE=D1=82=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=82=D0=B5=D1=80=D0=B8=20userModels/scripts=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=B0=D0=B2=D1=82=D0=BE=D1=81=D0=B5=D0=B9=D0=B2?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При частичной загрузке сцены (terrain догрузился, модели/скрипты ещё нет из-за таймаута) автосейв затирал scene нулями. Блокируем сейв если ранее загружено >0 объектов, а сейчас 0 и пользователь не редактировал; loadFailedRef в safety-timer блокирует автосейв при таймауте загрузки. Co-Authored-By: Claude Opus 4.8 --- src/editor/KubikonEditor.jsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 54842ef..ea8fc60 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -549,6 +549,12 @@ const KubikonEditor = () => { // не отработал, или setSceneLoading(false) попал в обход (race). const safetyTimer = setTimeout(() => { console.warn('[KubikonEditor] safety timer: forcing setSceneLoading(false) after 60s'); + // Загрузка не завершилась штатно за 60с (медленная сеть / таймаут + // getProject / частично загруженные модели) → помечаем как СБОЙ + // загрузки, чтобы автосейв НЕ затёр проект частичной/пустой сценой. + // Без этого terrain мог загрузиться частично (напр. 3 из 13173) и + // автосейв писал эту пустышку в БД (инцидент 2026-06-02). + loadFailedRef.current = true; setSceneLoading(false); }, 60000); return () => { -- 2.47.2