/** * BabylonScene — обёртка над Babylon.js Engine + Scene с Roblox-style навигацией. * * Управление камерой (как в Roblox Studio): * - ПКМ + drag : повернуть камеру (yaw/pitch вокруг своей оси) * - ПКМ + WASD : полёт (вперёд/назад/влево/вправо относительно взгляда) * - ПКМ + Q/E : вниз/вверх по миру * - ПКМ + Shift : ускоренный полёт (×2.5) * - Колесо : zoom (приближение по оси взгляда) * - Средняя кнопка drag : pan (сдвиг параллельно экрану) * - F : фокус на (0,0,0) — будет на выбранный объект позже * * Используем UniversalCamera + ручной обработчик мыши/клавиш для точной * имитации Roblox-controls (стандартные attachControl делают не то что нужно). * * Этап 1, неделя 1: только сцена, камера и пол с сеткой. Блоки и физика — позже. */ import { Engine, Scene, UniversalCamera, Vector3, Color3, Color4, HemisphericLight, DirectionalLight, ShadowGenerator, CascadedShadowGenerator, SSAORenderingPipeline, MeshBuilder, StandardMaterial, DynamicTexture, UtilityLayerRenderer, TransformNode, ParticleSystem, Texture, Ray, PointerEventTypes, Tools as BabylonTools, } from '@babylonjs/core'; import { BlockManager } from './BlockManager'; import { TerrainManager, VOXEL_SIZE as TERRAIN_VOXEL_SIZE, TERRAIN_MATERIALS as TERRAIN_MATERIAL_DEFS } from './TerrainManager'; // Этап 1 voxel-движка: новые классы chunks-based архитектуры (см. // RUBLOX_VOXEL_ENGINE_PLAN.md). Пока работают параллельно с legacy // TerrainManager как shadow-копия — для замеров статистики чанков и // готовности к Этапу 2 (greedy meshing). import { VoxelWorld } from './voxel/VoxelWorld'; import { VoxelRenderer } from './voxel/VoxelRenderer'; import { WorldGenerator, DEFAULT_GENERATOR_PARAMS } from './voxel/WorldGenerator'; // Этап 6: deco-слой 0.05м — мелкие воксельные декорации (цветы/грибы/трава). import { DecoManager } from './DecoManager'; import { GRASS_MODELS_POOL } from './DecoModels'; import { placeVoxelTree, TREE_TYPES } from './VoxelTreeBuilder'; import { UserModelManager, parseUserModelId, USER_MODEL_PREFIX } from './UserModelManager'; import { ModelManager } from './ModelManager'; import { PrimitiveManager } from './PrimitiveManager'; import { BillboardUiManager } from './BillboardUiManager'; import { getPrimitiveType } from './PrimitiveTypes'; import { FolderManager } from './FolderManager'; import { GuiManager } from './GuiManager'; import { ModalManager } from './ModalManager'; import { InventoryManager } from './InventoryManager'; import { WeaponSystem } from './WeaponSystem'; import { ZombieManager } from './ZombieManager'; import { NpcManager } from './NpcManager'; import { ConstraintManager } from './ConstraintManager'; import { BeamManager } from './BeamManager'; import { ZombieSpawnerManager } from './ZombieSpawnerManager'; import { DynamicsManager } from './DynamicsManager'; import { Environment } from './Environment'; import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager'; import { GameAudioManager } from './GameAudioManager'; import { AssetManager } from './AssetManager'; import { SoundLibrary } from './SoundLibrary'; import { SoundManager } from './SoundManager'; import { GlbLibrary } from './GlbLibrary'; import { GdLevelManager } from './GdLevelManager'; import { GdSkybox } from './GdSkybox'; import { GdGroundSkin } from './GdGroundSkin'; import { GdSpikes } from './GdSpikes'; import { GdStartArch } from './GdStartArch'; import { GdPortalArch } from './GdPortalArch'; import { GdDiamond } from './GdDiamond'; import { GdPlayerModeSkin } from './GdPlayerModeSkin'; import { GdFinish } from './GdFinish'; import { GdForest } from './GdForest'; import { GdPlayerCube } from './GdPlayerCube'; import { GdPlayerTrail } from './GdPlayerTrail'; import { GdPostFx } from './GdPostFx'; import { PhysicsAABB } from './PhysicsAABB'; import { PlayerController } from './PlayerController'; import { SelectionManager } from './SelectionManager'; import { GizmoController } from './GizmoController'; import { HistoryManager } from './HistoryManager'; import { GameRuntime } from './GameRuntime'; import { attachConsoleHook, devlogReset } from './devlog'; import { TerrainMesh, CHUNK_SIZE as TERRAIN_MESH_CHUNK } from './terrain/TerrainMesh'; import { VoxelGrid } from './terrain/VoxelGrid'; import { RobloxTerrain, CHUNK_SIZE as ROBLOX_CHUNK_SIZE } from './robloxterrain/RobloxTerrain'; import { DensityGrid as RobloxDensityGrid, CELL_SIZE as ROBLOX_CELL_SIZE } from './robloxterrain/DensityGrid'; import { SmoothDecoManager } from './robloxterrain/SmoothDecoManager'; export class BabylonScene { /** * @param {HTMLCanvasElement} canvas — DOM-элемент для рендера */ constructor(canvas) { // DevLog: на localhost подключаем перехват console.* для записи в файл // на твоей машине (c:\...\dev-tools\devlog.txt). Это даёт Claude // возможность читать свежие логи без копипасты вручную. try { devlogReset(); attachConsoleHook(); } catch (e) {} this.canvas = canvas; this.engine = null; this.scene = null; this.camera = null; // Состояние ввода. Храним КОДЫ клавиш (e.code), не key — чтобы // работало на русской раскладке: KeyW не зависит от языка ввода. this._codes = new Set(); this._shiftDown = false; this._isRotating = false; // ПКМ зажата → крутим камеру this._isPanning = false; // СКМ зажата → pan this._lastMouseX = 0; this._lastMouseY = 0; // Параметры this.MOVE_SPEED = 12; // юнитов/секунду при WASD this.SHIFT_MULTIPLIER = 2.5; this.ROTATE_SENSITIVITY = 0.0035; // радиан/пиксель this.ZOOM_SPEED = 1.5; this.PAN_SENSITIVITY = 0.025; // Состояние редактора блоков this.blockManager = null; this.modelManager = null; this.primitiveManager = null; this.folderManager = null; this.guiManager = null; // 2D-UI слой (Frame/Text/Button/Image) this.inventory = null; // инвентарь игрока (9 слотов hot-bar) this.weapons = null; // система оружия (создаётся при enterPlayMode) this.zombieManager = null; // AI зомби (создаётся при enterPlayMode) this.npcManager = null; // управляемые скриптом NPC (Фаза 4.1) this.constraintManager = null; // связи объектов (Фаза 5, Constraints) this.beamManager = null; // лучи и следы (Фаза 5.2) this.spawnerManager = null; // спавнеры зомби this.environment = null; this.audioManager = null; this.assetManager = null; // библиотека пользовательских картинок this.soundLibrary = null; // библиотека пользовательских звуков (Фаза 5.5) this.soundManager = null; // 3D-воспроизведение звука (Play-only) this.glbLibrary = null; // импортированные .glb-модели (Фаза 5.8) this.selection = null; // SelectionManager // Тач-режим (мобилки/планшеты) — выставляется снаружи через // setTouchMode() ДО enterPlayMode. Влияет на PlayerController. this._touchMode = false; this._activeTool = 'block'; // 'select' | 'block' | 'model' | 'primitive' | 'erase' this._activeBlockType = 'grass'; this._activeModelType = null; this._activePrimitiveType = 'cube'; this._ghostMesh = null; this._ghostRotationY = 0; // угол поворота ghost-модели (R = +90°) this._gizmo = null; this._gizmoLayer = null; this._gizmoDragging = false; // флаг что идёт drag гизмо this._isDragPlacing = false; // флаг drag-постановки/удаления блоков this._isTerrainBrushing = false; // флаг drag-кисти террейна this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять this._lastPlacedKey = null; // последняя клетка чтобы не ставить дважды this._dragLockAxis = null; // 'y' | 'x' | 'z' — плоскость зафиксированная первым блоком this._dragLockValue = 0; // значение по фиксированной оси // Точка спавна игрока в режиме Play (обновляется setSpawnPoint) this._spawnPoint = { x: 0, y: 5, z: 0 }; // Модель персонажа для режима Play. // Дефолт — R15-скин bacon-hair (классический Roblox-вид). // 'skin_*' грузится из characters//body.glb (R15-скелет), // 'character-*' — старые Kenney-модели. this._playerModelType = 'skin_bacon-hair'; // Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z. // По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize(). this._worldHalf = 40; // Видимость пола (можно «удалить» — пол исчезнет визуально и из физики) this._floorEnabled = true; // Множитель силы прыжка (1 = базовый, 1.5 = в 1.5 раза выше) this._jumpPowerMul = 1; // Прицел в Play: 'none' | 'dot' | 'cross' | 'circle'. По умолчанию выключен. this._crosshair = 'none'; // Скрипты пользователя (массив { id, code, target? }). // На этапе 2.1 — только один глобальный «scene script», без UI редактирования. // Хранится в проекте через serialize/loadFromState. this._scripts = []; this.gameRuntime = null; // GameRuntime создаётся при enterPlayMode // Режим Play this.player = null; // PlayerController когда играем this.physics = null; // PhysicsAABB this._editorCameraSnapshot = null; // запоминаем позицию редактор-камеры this._isPlaying = false; // Drag-detection: чтобы не ставить блок при rotate (mouseup без movement // = клик; с movement = drag). this._mouseDownTime = 0; this._mouseDownX = 0; this._mouseDownY = 0; this._mouseDownButton = -1; // Слушатели — храним чтобы корректно отписаться this._listeners = []; this._resizeHandler = null; } init() { // На тач-устройствах сразу отключаем anti-aliasing — это даёт // заметный буст FPS на мобилах. Anti-aliasing полезен только на // больших мониторах с низким DPR. const isTouchDevice = (typeof window !== 'undefined') && ( 'ontouchstart' in window || (navigator.maxTouchPoints || 0) > 0 ); const isSmallScreen = (typeof window !== 'undefined') && window.innerWidth <= 1024; const useAA = !(isTouchDevice && isSmallScreen); // MOBILE-OPT (этап 1): флаг для всех мобильных оптимизаций // Можно принудительно отключить через ?desktop=1 в URL (для отладки). const forceDesktop = (typeof window !== 'undefined') && new URLSearchParams(window.location.search).has('desktop'); this._isMobileMode = (isTouchDevice && isSmallScreen) && !forceDesktop; this.engine = new Engine(this.canvas, useAA, { preserveDrawingBuffer: true, stencil: true, // Parallel shader compile — критично для устранения фризов при // повороте камеры. Когда новый material попадает во frustum, // Babylon без этого синхронно компилит shader и блокирует UI. // С параллельным compile рендер использует fallback shader и // переключается на оптимизированный когда тот готов. useHighPrecisionFloats: false, powerPreference: 'high-performance', }, true); // MOBILE-OPT (этап 1.5): hardware scaling ОТКЛЮЧЁН. // Логи показали что узкое место — CPU (draw calls растут до 60k), // а не GPU fillrate. Скейлинг ухудшал картинку и не помогал FPS. // PERF-METRICS: счётчики для perf-overlay. Накопительно за окно // сэмплинга 5сек, потом overlay читает и сбрасывает. this._perfMetrics = { render_ms_sum: 0, render_count: 0, physics_ms_sum: 0, physics_count: 0, script_ms_sum: 0, script_count: 0, // Замер idle-времени между концом prev-render и началом next-render. // Если idle ≈ frame_ms - render_ms — значит мы GPU-bound (JS-поток // ждёт GPU/V-Sync). Если idle мал — CPU-bound (что-то ещё в JS ест). idle_ms_sum: 0, idle_count: 0, _lastRenderEnd: 0, }; this.scene = new Scene(this.engine); this.scene.clearColor = new Color4(0.5, 0.7, 0.9, 1.0); // ambient: материалы TerrainManager ставят mat.ambientColor=(1,1,1), // но без scene.ambientColor != 0 это умножается на 0 и боковые грани // вокселей остаются чёрными при тёмных пресетах (sunset/night). this.scene.ambientColor = new Color3(0.3, 0.3, 0.3); // Глобальный хендл для отладки из консоли: window.__BS — это инстанс // BabylonScene; window.__SC — Babylon scene; window.__ENG — engine. // window.__BJS — набор Babylon-классов для dev-инструментов // (@babylonjs/core модульный, window.BABYLON не существует) — // им пользуется съёмщик hero-кадров dev-tools/wiki-shots/shoot-hero.js. if (typeof window !== 'undefined') { window.__BS = this; window.__SC = this.scene; window.__ENG = this.engine; window.__BJS = { UniversalCamera, Vector3, Tools: BabylonTools }; } // ВАЖНО: blockMaterialDirtyMechanism НЕ включаем здесь. // Когда true — ставим свойства материала (emissiveColor/disableLighting/ // alpha) у новых мешей (трейсеры выстрелов, debris при смерти, // муззл-флэш, импакт), но шейдер пересчитывается с дефолтами и эти // свойства не применяются. Эффект: трейсер/дебрис создаются, но // НЕ ВИДНЫ. Включать только локально вокруг массовых операций // (если когда-то появится нужда), сразу выключая обратно. // Skip pointer-move picking — не делаем raycast от мыши на каждый // mousemove. Игроку важны клик и hover-через-canvas, а не каждый move. this.scene.skipPointerMovePicking = true; // Параллельная компиляция шейдеров — фоновая компиляция новых // материалов без блокировки рендера (если поддержано WebGL2). if (this.engine.getCaps?.()?.parallelShaderCompile !== undefined) { try { // Babylon 6+ — это просто флаг capability, выставляется // автоматически при поддержке. Логируем для отладки. // eslint-disable-next-line no-console console.log('[BabylonScene] parallel shader compile:', !!this.engine.getCaps().parallelShaderCompile); } catch (e) {} } // Возвращаем detachControl — наши mousedown-listeners на canvas с // capture=true должны работать без вмешательства Babylon-pointerHandler. // Гизмо запустим вручную через прямые pointerdown/move/up на utility-сцене. this.scene.detachControl(); this._createCamera(); this._createLights(); this._createGroundGrid(); this._createGhostBlock(); this._createSpawnMarker(); this._setupInputControls(); // Менеджеры объектов this.blockManager = new BlockManager(this.scene); // При создании нового proto-меша блока — сразу регистрируем его // как shadow caster (если генератор уже создан). this.blockManager.setOnProtoCreated((proto) => { this.addShadowCaster(proto); }); // Менеджер декораций — Этап 6 voxel-движка. // Мини-воксели 0.05м для цветов/грибов/травы. Без коллизий. this.decoManager = new DecoManager(this.scene); this.decoManager.setOnChange(() => { if (this._onSceneChange) this._onSceneChange(); }); // Менеджер ландшафта — отдельный voxel-слой 1×1×1, рисуемый кистями. // Использует thin-instances per материал, как BlockManager. this.terrainManager = new TerrainManager(this.scene); // ОПТИМИЗАЦИЯ: НЕ регистрируем terrain как shadow caster. Большая // карта с 150K voxel'ов в shadow renderList даёт +50-100% нагрузки // на GPU. Тени от деревьев на земле выглядят не критично, а receiveShadows // оставлен — тени от других объектов (моделей) показываются. // this.terrainManager.setOnProtoCreated((proto) => { // this.addShadowCaster(proto); // }); this.terrainManager.setOnChange(() => { // Пометить сцену как изменённую — автосохранение подхватит. // Имя коллбэка — _onSceneChange (то же что у blockManager/ // modelManager/primitiveManager). Раньше тут было _onChange — // несуществующее поле, из-за чего террейн не сохранялся // автоматически. Только ручная кнопка «Сохранить» дёргает // serialize() напрямую и попадала в БД. if (this._onSceneChange) this._onSceneChange(); }); // === Этап 1 voxel-движка: shadow-копия террейна в новой архитектуре === // Параллельно с TerrainManager работает VoxelWorld с теми же voxel'ами, // но в формате chunks 32×32×32. Пока БЕЗ рендера (флаг useVoxelWorld= // false) — только структура данных для замера chunk-статистики и // подготовки к Этапу 2. // window.__voxelWorldStats() — выведет в консоль текущую статистику. // window.__voxelWorldRender(true/false) — переключит рендер на новый // (когда будет готов greedy). Сейчас рендерит дублирующиеся mesh'и // поверх старых — для визуальной валидации. this.voxelWorld = new VoxelWorld(); this.voxelWorld.setOnChange(() => { // Авто-rebuild dirty чанков при изменении (только если рендер включён) if (this._voxelRenderEnabled && this.voxelRenderer) { this.voxelRenderer.rebuildDirty(); } }); this.voxelRenderer = new VoxelRenderer(this.voxelWorld, this.scene); this.voxelRenderer.setOnMeshCreated((m) => this.addShadowCaster(m)); /** Включить/выключить рендер VoxelWorld. По умолчанию false — только * loadFromArray в VoxelWorld для статистики, без отображения. */ this._voxelRenderEnabled = false; /** Этап 4 streaming: рендерить только чанки в радиусе от камеры. * false по умолчанию — рендерим все чанки (для маленьких карт). * Включается через window.__voxelWorldStreaming(true, 64). */ this._voxelStreamingEnabled = false; this._voxelStreamingRadius = 64; // метров this._voxelStreamingLastUpdate = 0; this._voxelStreamingInterval = 250; // мс между проверками if (typeof window !== 'undefined') { window.__voxelWorldStats = () => { const s = this.voxelWorld.stats(); console.log('[VoxelWorld stats]', s); return s; }; // Диагностика FPS bottleneck'ов на больших картах. // Запускать в консоли когда лагает: window.__voxelPerfReport() // Debug-команды для диагностики FPS-проблем. // Запускать в консоли — увидим что реально жрёт CPU/GPU. window.__toggleShadows = (on) => { this.setShadowQuality(on === false ? 'off' : 'soft'); console.log('[Debug] shadows:', on === false ? 'OFF' : 'ON'); }; window.__togglePostProcess = (on) => { if (this.scene && this.scene.postProcessRenderPipelineManager) { // Babylon не умеет тривиально выключать pipeline, поэтому // просто отключаем все pipelines const enabled = on !== false; if (this._postProcessPipelines) { for (const p of this._postProcessPipelines) { try { p.setEnabled(enabled); } catch (e) {} } } } console.log('[Debug] post-process:', on === false ? 'OFF' : 'ON'); }; window.__toggleSceneOptim = (on) => { // Глобальные оптимизации Babylon const scn = this.scene; if (on !== false) { scn.freezeActiveMeshes(); scn.skipFrustumClipping = true; scn.blockfreeActiveMeshesAndRenderingGroups = true; console.log('[Debug] scene optim: freezeActiveMeshes + skipFrustumClipping ON'); } else { scn.unfreezeActiveMeshes(); scn.skipFrustumClipping = false; scn.blockfreeActiveMeshesAndRenderingGroups = false; console.log('[Debug] scene optim: OFF'); } }; window.__voxelPerfReport = () => { const tm = this.terrainManager; if (!tm) return console.warn('no terrainManager'); const scn = this.scene; const eng = scn.getEngine(); const totalMeshes = scn.meshes.length; let activeMeshes = 0; let activeRegionMeshes = 0; let activeDecoMeshes = 0; for (const m of scn.meshes) { if (m.isEnabled() && m.material) activeMeshes++; } if (tm._regionMeshes) { for (const m of tm._regionMeshes.values()) { if (m.isEnabled()) activeRegionMeshes++; } } if (this.decoManager?._chunkMeshes) { for (const colorMap of this.decoManager._chunkMeshes.values()) { for (const m of colorMap.values()) { if (m.isEnabled()) activeDecoMeshes++; } } } // FPS: Babylon engine.getFps() даёт усреднённый, instantaneous // (1000/getDeltaTime) скачет хаотично из-за GC. const stableFps = eng.getFps?.() ?? (1000 / eng.getDeltaTime()); const instFps = 1000 / eng.getDeltaTime(); // Подсчёт активных треугольников и draw calls. // Babylon хранит sceneInstrumentation, но он opt-in. // Считаем вручную из активных мешей. let activeTriangles = 0; let activeVertices = 0; let activeDrawCalls = 0; if (this.scene && this.scene.meshes) { for (const m of this.scene.meshes) { if (!m.isEnabled() || !m.material) continue; // Frustum cull skip const idxCount = m.getTotalIndices?.() ?? 0; if (idxCount === 0) continue; // thin-instances умножают const instCount = m.thinInstanceCount > 0 ? m.thinInstanceCount : 1; activeTriangles += (idxCount / 3) * instCount; activeVertices += (m.getTotalVertices?.() ?? 0) * instCount; // 1 draw call на меш (multimat = +submeshes) const subMeshes = m.subMeshes ? m.subMeshes.length : 1; activeDrawCalls += subMeshes; } } // Frame time: целевые значения // 60 FPS = 16.6мс/кадр // 30 FPS = 33.3мс/кадр // 23 FPS = 43.5мс/кадр ← наша проблема const frameMs = eng.getDeltaTime(); // Visibility — Chrome даёт throttle до 20 FPS если таб неактивен const docHidden = typeof document !== 'undefined' && document.hidden; const winFocused = typeof document !== 'undefined' && document.hasFocus?.(); // PERF-DIAG: где теряется время? // render_ms — сколько занимает scene.render() (GPU + babylon) // idle_ms — промежуток между концом render и началом // следующего кадра (если велик — GPU-bound // ИЛИ браузер throttle; если мал, а frame_ms // большой — узкое место в нашем JS до render). const pm = this._perfMetrics; const renderMsAvg = pm && pm.render_count ? (pm.render_ms_sum / pm.render_count) : 0; const idleMsAvg = pm && pm.idle_count ? (pm.idle_ms_sum / pm.idle_count) : 0; // Сбрасываем накопители — следующий отчёт за свежий период. if (pm) { pm.render_ms_sum = 0; pm.render_count = 0; pm.idle_ms_sum = 0; pm.idle_count = 0; } console.log('[PerfReport]', { fps_stable: stableFps.toFixed(1), fps_instant: instFps.toFixed(1), frame_ms: frameMs.toFixed(1), render_ms: renderMsAvg.toFixed(1), idle_ms: idleMsAvg.toFixed(1), isPlaying: this._isPlaying, triangles_K: (activeTriangles / 1000).toFixed(0) + 'K', drawCalls: activeDrawCalls, tab_hidden: docHidden, win_focused: winFocused, voxelCount: tm.voxels?.size ?? 0, sceneMeshes: totalMeshes, activeMeshes, regionMeshes: tm._regionMeshes?.size ?? 0, activeRegionMeshes, decoMeshes: this.decoManager?._chunkMeshes ? this._decoMeshCount() : 0, activeDecoMeshes, streamingRadius: this._terrainStreamingRadius, // Новый TerrainMesh (Roblox-style, voxel) tmesh_chunks: this._terrainMesh?.chunks?.size ?? 0, tmesh_pending: this._terrainMesh?._pendingChunks?.size ?? 0, tmesh_tris: this._terrainMesh ? this._terrainMesh.getActiveTriangles() : 0, // Roblox Smooth Terrain rt_chunks: this._robloxTerrain?.chunks?.size ?? 0, rt_pending: this._robloxTerrain?._pendingChunks?.size ?? 0, rt_tris: this._robloxTerrain ? this._robloxTerrain.getStats().triangles : 0, }); }; this._decoMeshCount = () => { let n = 0; if (!this.decoManager?._chunkMeshes) return 0; for (const m of this.decoManager._chunkMeshes.values()) n += m.size; return n; }; // Автомониторинг FPS — каждые 2 сек пишет PerfReport в devlog. // Активируется автоматически на localhost. На прод не работает. window.__perfMonitorStart = (interval = 2000) => { if (window.__perfMonitorTimer) { clearInterval(window.__perfMonitorTimer); } window.__perfMonitorTimer = setInterval(() => { try { window.__voxelPerfReport?.(); } catch (e) {} }, interval); console.log(`[PerfMonitor] started, interval=${interval}ms`); }; window.__perfMonitorStop = () => { if (window.__perfMonitorTimer) { clearInterval(window.__perfMonitorTimer); window.__perfMonitorTimer = null; console.log('[PerfMonitor] stopped'); } }; // Автостарт мониторинга на localhost — Claude читает devlog.txt if (typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) { setTimeout(() => { try { window.__perfMonitorStart?.(2000); } catch (e) {} }, 1000); } // === Тест нового TerrainMesh (Roblox/Minecraft-style) === // // Создаёт VoxelGrid и заполняет его holmistym ландшафтом из // sin-волн. Рендерится через Greedy Meshing. // Использование в DevTools: // __terrainTest(64) — небольшая карта 64×16×64м // __terrainTest(150) — большая 150×24×150м // __terrainTest(250) — целевая 250×32×250м window.__terrainTest = (sizeMeters = 64) => { if (!this._terrainMesh) { this._terrainMesh = new TerrainMesh(this.scene); } const tm = this._terrainMesh; // Удалим старый legacy terrain — он перекрывает картинку try { if (this.terrainManager) this.terrainManager.clear(); if (this.decoManager) this.decoManager.clear(); } catch (e) {} tm.disposeAll(); const t0 = performance.now(); const sx = sizeMeters, sz = sizeMeters; const sy = 32; const grid = new VoxelGrid({ origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 }, size: { x: sx, y: sy, z: sz }, }); // Заполняем heightmap-картой: y = базовый + sin(x)*cos(z) for (let z = 0; z < sz; z++) { for (let x = 0; x < sx; x++) { const fx = (x - sx / 2) / sx; const fz = (z - sz / 2) / sz; const h = Math.floor( 6 + Math.sin(fx * Math.PI * 3) * 4 + Math.cos(fz * Math.PI * 4) * 3 + Math.sin((fx + fz) * Math.PI * 6) * 2, ); for (let y = 0; y < h && y < sy; y++) { let mat; if (y === h - 1) mat = 'grass'; else if (y >= h - 3) mat = 'dirt'; else mat = 'rock'; grid.set(x, y, z, mat); } } } const tFill = performance.now() - t0; const solid = grid.countSolid(); console.log(`[TerrainTest] filled grid ${sx}×${sy}×${sz} (${solid} solid voxels) in ${tFill.toFixed(0)}ms`); tm.loadFromGrid(grid); // Сразу материализуем ВСЕ chunks (для теста, не lazy) const t1 = performance.now(); const camX = this.camera?.position.x || 0; const camZ = this.camera?.position.z || 0; const r = tm.updateStreaming(camX, camZ, 9999, { maxBuild: 9999 }); const tBuild = performance.now() - t1; const tris = tm.getActiveTriangles(); console.log(`[TerrainTest] built ${r.built} chunks in ${tBuild.toFixed(0)}ms — ${tris} triangles total`); console.log(`[TerrainTest] open DevTools → __voxelPerfReport() через 2 сек → должно быть 60+ FPS`); }; // Удалить тестовый terrain mesh window.__terrainTestClear = () => { if (this._terrainMesh) { this._terrainMesh.disposeAll(); console.log('[TerrainTest] cleared'); } }; // ============================================================ // Roblox-style Smooth Terrain test // ============================================================ // // Использование в DevTools: // __robloxTest(50) — карта 50×16×50 ячеек = 200×64×200 м // __robloxTest(125) — 500×64×500 м (огромная, ОК для smooth) // // Создаёт holmistyy ландшафт через density-функцию и рендерит // через Surface Nets. Проверка что архитектура работает. window.__robloxTest = async (gridSize = 50, userParams = null) => { if (!this._robloxTerrain) { this._robloxTerrain = new RobloxTerrain(this.scene); // Подключить к физике — иначе игрок проваливается в smooth terrain if (this.physics?.setRobloxTerrain) { this.physics.setRobloxTerrain(this._robloxTerrain); } } const rt = this._robloxTerrain; try { if (this.terrainManager) this.terrainManager.clear(); if (this.decoManager) this.decoManager.clear(); if (this.voxelWorld) { const layer = this.voxelWorld.getLayer?.('terrain'); if (layer && layer.clear) layer.clear(); } this._terrainStreamingEnabled = false; } catch (e) {} rt.disposeAll(); const t0 = performance.now(); const sx = gridSize, sz = gridSize; const sy = 24; // высота карты в cells: 24 × 4м = 96м (для гор) const grid = new RobloxDensityGrid({ origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 }, size: { x: sx, y: sy, z: sz }, }); // === Используем тот же WorldGenerator что и voxel-генератор === // 1 smooth-cell = 4м = 16 voxel-units. // sampleHeight возвращает высоту в voxel-units (0.25м). // sampleBiome → объект {topMaterial,softMaterial,hardMaterial,...}. // // userParams приходит из UI (TerrainGenPanel buildParams). // Если null — берём дефолтные. const params = userParams ? JSON.parse(JSON.stringify(userParams)) : JSON.parse(JSON.stringify(DEFAULT_GENERATOR_PARAMS)); console.log(`[RobloxTest] params: amp=${params.heightmap.amplitude}, scale=${params.heightmap.scale}, exp=${params.heightmap.exponent}, biomes=${params.biomes?.length}`); const gen = new WorldGenerator(params); // Маппинг материалов voxel-генератора → smooth (DensityGrid // поддерживает только grass/rock/sand/snow). // dirt → grass, остальные пропускаются. const matMap = (m) => { if (m === 'dirt') return 'grass'; if (m === 'grass' || m === 'rock' || m === 'sand' || m === 'snow') return m; return 'grass'; }; // Шаг 1: heightmap + biome для каждой smooth-cell. // x,z в smooth-grid — переводим в voxel-units: vx = (x + origin.x) * 16 const CELL_VOXELS = 16; // 4м / 0.25м per voxel = 16 const heightMap = new Float32Array(sx * sz); const topMats = new Array(sx * sz); const softMats = new Array(sx * sz); const hardMats = new Array(sx * sz); for (let z = 0; z < sz; z++) { for (let x = 0; x < sx; x++) { const vx = (x + grid.origin.x) * CELL_VOXELS + CELL_VOXELS / 2; const vz = (z + grid.origin.z) * CELL_VOXELS + CELL_VOXELS / 2; const hVoxels = gen.sampleHeight(vx, vz); const biome = gen.sampleBiome(vx, vz); const hCells = hVoxels / CELL_VOXELS; heightMap[z * sx + x] = hCells; topMats[z * sx + x] = matMap(biome.topMaterial); softMats[z * sx + x] = matMap(biome.softMaterial); hardMats[z * sx + x] = matMap(biome.hardMaterial); } } // Шаг 2: density + материалы. // Топ-слой: topMaterial биома. // Средний (1..3 cells вглубь): softMaterial. // Глубокий (>3 cells): hardMaterial. for (let z = 0; z < sz; z++) { for (let x = 0; x < sx; x++) { const h = heightMap[z * sx + x]; const topMat = topMats[z * sx + x]; const softMat = softMats[z * sx + x]; const hardMat = hardMats[z * sx + x]; const h1 = x > 0 ? heightMap[z * sx + (x - 1)] : h; const h2 = x < sx - 1 ? heightMap[z * sx + (x + 1)] : h; const h3 = z > 0 ? heightMap[(z - 1) * sx + x] : h; const h4 = z < sz - 1 ? heightMap[(z + 1) * sx + x] : h; const slope = Math.max( Math.abs(h - h1), Math.abs(h - h2), Math.abs(h - h3), Math.abs(h - h4), ); // На очень крутых обрывах (>3 cells = 12м перепад) — // обнажение rock даже на травяных склонах. const useRockSlope = slope > 3.0 && topMat !== 'sand' && topMat !== 'snow'; for (let y = 0; y < sy; y++) { const delta = h - y; let densityF; if (delta > 2) densityF = 1; else if (delta < -2) densityF = 0; else densityF = (delta + 2) / 4; const density = (densityF * 255) | 0; if (density > 0) { let mat; if (useRockSlope) mat = 'rock'; else if (delta < 1) mat = topMat; else if (delta < 3) mat = softMat; else mat = hardMat; grid.set(x, y, z, density, mat); } } } } const tFill = performance.now() - t0; console.log(`[RobloxTest] filled grid ${sx}×${sy}×${sz} (${grid.countSolid()} solid cells) in ${tFill.toFixed(0)}ms`); rt.loadFromGrid(grid); // Материализуем ВСЕ chunks сразу для теста. const t1 = performance.now(); const camX = this.camera?.position.x || 0; const camZ = this.camera?.position.z || 0; const r = rt.updateStreaming(camX, camZ, 99999, { maxBuild: 9999 }); const tBuild = performance.now() - t1; const stats = rt.getStats(); console.log(`[RobloxTest] built ${r.built} chunks in ${tBuild.toFixed(0)}ms — ${stats.triangles} triangles`); // Мини-карта для свежесгенерированного гладкого ландшафта. this._setupMinimapForRobloxTerrain(); // === Авто-спавн над поверхностью === // Находим в grid самую верхнюю solid-ячейку в столбце x=0, z=0. // Spawn = top_y + 2м над ней. const CS = 4; // CELL_SIZE const cellX0 = 0 - grid.origin.x; // мировые (0,_,0) → cell const cellZ0 = 0 - grid.origin.z; let topCellY = -1; for (let cy = sy - 1; cy >= 0; cy--) { if (grid.isSolid(cellX0, cy, cellZ0)) { topCellY = cy; break; } } if (topCellY >= 0) { const surfaceY = (grid.origin.y + topCellY + 1) * CS; this._spawnPoint = { x: 0, y: surfaceY + 2, z: 0 }; this._updateSpawnMarker?.(); console.log(`[RobloxTest] auto-spawn at y=${surfaceY + 2} (surface at y=${surfaceY})`); } // Отключаем baseplate-пол — иначе он закрывает обзор и // создаёт коллизии под smooth-ландшафтом. try { this.setFloorEnabled(false); } catch (e) {} // === Декорации (цветы / трава / грибы) === // Размещаем 3D-модели Kenney Nature Kit через thin-instances. // Используем те же sampleHeight/sampleBiome из WorldGenerator // что и для terrain — биомы определят какие декорации куда идут. const decoOpts = userParams?.smoothDeco ?? { flowersDensity: 0.025, grassDensity: 0.10, treesDensity: 0.4, }; // Сохраняем параметры для сериализации (при load воссоздадим) this._smoothDecoParams = { flowersDensity: decoOpts.flowersDensity, grassDensity: decoOpts.grassDensity, treesDensity: decoOpts.treesDensity ?? 0.4, seed: params.seed || 1337, bbox: { minX: -(sx * CS) / 2, maxX: (sx * CS) / 2, minZ: -(sz * CS) / 2, maxZ: (sz * CS) / 2, }, // Параметры WorldGenerator нужны для воссоздания biome-маппинга genParams: params, }; if (decoOpts.flowersDensity > 0 || decoOpts.grassDensity > 0 || (decoOpts.treesDensity ?? 0) > 0) { try { if (!this._smoothDecoManager) { this._smoothDecoManager = new SmoothDecoManager(this.scene); } const tDeco0 = performance.now(); await this._smoothDecoManager.loadAll(); const tDecoLoad = performance.now() - tDeco0; // bbox в мировых координатах (метры) const halfMeters = (sx * CS) / 2; const bbox = { minX: -halfMeters, maxX: halfMeters, minZ: -halfMeters, maxZ: halfMeters, }; // Хелпер для surface raycast (использует физику) const sampleSurfaceY = (x, z) => { if (!this.physics?._sampleRobloxSurface) return null; return this.physics._sampleRobloxSurface(x, z); }; const sampleBiomeId = (x, z) => { // x,z в метрах → voxel-units (×4) const vx = x * 4; const vz = z * 4; const biome = gen.sampleBiome(vx, vz); return biome?.id; }; const tDeco1 = performance.now(); const r = this._smoothDecoManager.placeDecorations({ sampleSurfaceY, sampleBiomeId, bbox, densityFlowers: decoOpts.flowersDensity, densityGrass: decoOpts.grassDensity, densityTrees: decoOpts.treesDensity ?? 0, seed: params.seed || 1337, }); const tDecoPlace = performance.now() - tDeco1; console.log(`[RobloxTest] decorations: load ${tDecoLoad.toFixed(0)}ms + place ${tDecoPlace.toFixed(0)}ms → ${r.total} instances`); // Регистрация tree-AABB в физике — игрок не пройдёт сквозь стволы. if (this.physics?.setSmoothDecoTrees && r.treeColliders) { this.physics.setSmoothDecoTrees(r.treeColliders); } } catch (e) { console.error('[RobloxTest] decorations failed:', e); } } // Перемещаем редактор-камеру повыше чтобы видеть весь рельеф if (this.camera && topCellY >= 0) { const surfaceY = (grid.origin.y + topCellY + 1) * CS; this.camera.position.x = sx * CS * 0.3; this.camera.position.y = surfaceY + 30; this.camera.position.z = sz * CS * 0.3; this.camera.setTarget?.(new Vector3(0, surfaceY, 0)); } }; window.__robloxTestClear = () => this.clearRobloxTerrain(); // Этап 7a: процедурный генератор // window.__voxelGenerate({size:160, params:{...}}) — генерирует // террейн в bbox [-size..+size] и заменяет существующий terrain. window.__voxelGenerate = async (opts = {}) => { // ГЛОБАЛЬНЫЙ лок (на window, не на this!). // Без него при HMR (hot module reload в dev) каждая копия // BabylonScene имеет свой this._voxelGenerating, и команда // в консоли вызывает все копии параллельно. // window.__voxelGenLock виден ВСЕМ копиям сцены. if (window.__voxelGenLock) { console.warn('[VoxelGen] already running, ignoring duplicate call'); return null; } window.__voxelGenLock = true; // size — half-size в voxel-units (0.25м/voxel). // Картa = size × 2 × 0.25м. // size=160 → 80×80м (~200K voxels, FPS 27) ← по умолчанию // size=200 → 100×100м (~400K voxels, FPS 25) ← МАКСИМУМ // Жёсткий лимит — 200 (карта 100×100м максимум). // Для больших карт используйте Roblox-style smooth terrain. try { const MAX_SIZE = 200; let size = opts.size ?? 160; if (size > MAX_SIZE) { console.warn(`[VoxelGen] size=${size} превышает лимит ${MAX_SIZE} (карта >100м). Обрезаю до ${MAX_SIZE}.`); size = MAX_SIZE; } const params = opts.params ?? DEFAULT_GENERATOR_PARAMS; // Сохраняем для мини-карты (MinimapOverlay читает window.__lastGenParams) window.__lastGenParams = params; window.__lastGenSize = size; console.log(`[VoxelGen] generating ${size*2}×${size*2} voxel-units (${(size * 2 * 0.25).toFixed(0)}m × ${(size * 2 * 0.25).toFixed(0)}m)…`); // ВАЖНО: пишем в LEGACY TerrainManager — он рендерит правильно // (MultiCube для grass:top/side/bottom работает, текстуры // настроены корректно). VoxelWorld остаётся как shadow-copy // для RLE-сжатия в БД, но не для рендера. // // VoxelRenderer (новый) пока что выключен — он показывал // серую кашу из-за проблем с MultiCube. this._voxelRenderEnabled = false; if (this.voxelRenderer) { this.voxelRenderer.dispose(); this.voxelRenderer = new VoxelRenderer(this.voxelWorld, this.scene); this.voxelRenderer.setOnMeshCreated((m) => this.addShadowCaster(m)); } // Progress callback — UI подхватывает через window.__voxelGenProgress. const onProgress = (done, total, phase) => { const pct = Math.min(100, Math.round((done / total) * 100)); if (window.__voxelGenProgress) { try { window.__voxelGenProgress(pct, phase); } catch (e) {} } }; onProgress(0, 100, 'starting'); // Этап C оптимизации: генерация в Web Worker'е. // Main thread не блокируется, UI отзывчив, progress-bar плавный. const { getTerrainGenWorkerUrl } = await import('./TerrainGenWorker'); const workerUrl = getTerrainGenWorkerUrl(); const worker = new Worker(workerUrl); let voxels, decorations, treesPlaced, statsTimeMs; try { await new Promise((resolve, reject) => { worker.onmessage = (e) => { const m = e.data; if (m.type === 'progress') { onProgress(m.done, m.total, m.phase); } else if (m.type === 'done') { voxels = m.voxels; decorations = m.decorations; treesPlaced = m.treesPlaced; statsTimeMs = m.timeMs; resolve(); } else if (m.type === 'error') { reject(new Error('[Worker] ' + m.message)); } }; worker.onerror = (err) => { reject(new Error('[Worker] crash: ' + err.message)); }; worker.postMessage({ type: 'generate', params, bbox: { x0: -size, z0: -size, x1: size, z1: size }, }); }); } finally { worker.terminate(); URL.revokeObjectURL(workerUrl); } console.log(`[VoxelGen] generated ${voxels.length} voxels in ${statsTimeMs}ms (worker), ${treesPlaced} trees, ${decorations?.length || 0} decorations`); onProgress(95, 100, 'render'); await new Promise(r => setTimeout(r, 0)); // Заливаем в legacy TerrainManager (он отрендерит правильно). // Очищаем TerrainMesh если был — на новых генерациях не нужен. if (this._terrainMesh) { try { this._terrainMesh.disposeAll(); } catch (e) {} } if (this.terrainManager) { this.terrainManager.clear(); if (this.terrainManager.loadFromArray.constructor.name === 'AsyncFunction') { await this.terrainManager.loadFromArray(voxels); } else { this.terrainManager.loadFromArray(voxels); } console.log(`[VoxelGen] loaded into legacy TerrainManager`); // Также пишем в VoxelWorld для RLE-сжатия в БД try { const vwLayer = this.voxelWorld.getOrCreateLayer('terrain', 0.25); vwLayer.clear(); vwLayer.loadFromArray(voxels); } catch (e) { /* ignore */ } // АВТОВКЛЮЧЕНИЕ STREAMING для больших карт. const regionCount = this.terrainManager.getRegionCount?.() ?? 0; if (regionCount > 0) { this._terrainStreamingEnabled = true; // Адаптивный radius по количеству вокселей: чем больше // карта, тем меньше radius (иначе слишком много рендерится). // <300K voxels → 40м (норма для маленьких карт) // 300K-1M → 36м // 1M-2M → 32м // >2M → 28м (очень большие) const vc = voxels.length; let radius = 40; if (vc > 2_000_000) radius = 28; else if (vc > 1_000_000) radius = 32; else if (vc > 300_000) radius = 36; this._terrainStreamingRadius = radius; this._terrainStreamingLastUpdate = 0; const cam = this.camera; if (cam) { const r = this.terrainManager.updateStreaming(cam.position.x, cam.position.z, this._terrainStreamingRadius); console.log(`[VoxelGen] streaming ON: radius=${this._terrainStreamingRadius}m (${vc} voxels), ${r.enabled}/${r.total} regions enabled`); } } else { this._terrainStreamingEnabled = false; } } // Этап 6: загружаем decorations (мини-воксельные цветы/грибы). if (this.decoManager && decorations) { this.decoManager.loadFromArray(decorations); // Этап D: первый pass LOD streaming для деко. // maxBuild=2 — деко достроятся плавно через updateStreaming. if (this.camera && this.decoManager.updateStreaming) { const decoRadius = Math.max(18, (this._terrainStreamingRadius || 40) * 0.35); this.decoManager.updateStreaming( this.camera.position.x, this.camera.position.z, decoRadius, { maxBuild: 2 }, ); } } onProgress(100, 100, 'done'); return { voxels: voxels.length, treesPlaced, decorations: decorations?.length || 0, timeMs: statsTimeMs }; } finally { // Снять глобальный лок ОБЯЗАТЕЛЬНО. window.__voxelGenLock = false; } }; // Готовые пресеты для быстрого теста window.__voxelPresets = { default: DEFAULT_GENERATOR_PARAMS, mountains: { ...DEFAULT_GENERATOR_PARAMS, heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 50, exponent: 2.0 }, }, flat: { ...DEFAULT_GENERATOR_PARAMS, heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 3, exponent: 1.0 }, }, islands: { ...DEFAULT_GENERATOR_PARAMS, heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 15, exponent: 2.5 }, }, forest: { ...DEFAULT_GENERATOR_PARAMS, biomes: DEFAULT_GENERATOR_PARAMS.biomes.map(b => b.id === 'plain' || b.id === 'forest' ? { ...b, features: { ...b.features, trees: 1.5 } } : b, ), }, }; // Этап 4: streaming контроль window.__voxelWorldStreaming = (enabled, radius = 64) => { this._voxelStreamingEnabled = !!enabled; this._voxelStreamingRadius = radius; if (!this._voxelRenderEnabled) { console.log('[VoxelWorld] streaming on, но render выключен. Включи render: window.__voxelWorldRender(true)'); return; } if (!this._voxelStreamingEnabled) { // Сброс: загрузить все чанки обратно this.voxelRenderer.rebuildAll(); console.log('[VoxelWorld] streaming OFF — все чанки видимы'); return; } // Стартовый update вокруг камеры const cam = this.camera; const center = { x: cam.position.x, z: cam.position.z }; const r = this.voxelRenderer.updateStreaming(center, radius); console.log(`[VoxelWorld] streaming ON, radius=${radius}m: ${r.loaded} loaded, ${r.unloaded} unloaded, ${this.voxelRenderer.getActiveChunkCount()}/${r.total} active`); }; // Этап 3 benchmark: сравнить размер legacy JSON vs RLE+base64 window.__voxelWorldBenchmarkRLE = () => { const t0 = performance.now(); const rleData = this.voxelWorld.serialize(); const t1 = performance.now(); const rleJson = JSON.stringify(rleData); const t2 = performance.now(); const rleBytes = new Blob([rleJson]).size; // Legacy формат для сравнения — массив {x,y,z,m} const legacyVoxels = []; const layer = this.voxelWorld.getLayer('terrain'); if (layer) { for (const ch of layer.chunks.values()) { const ox = ch.voxelOriginX(); const oy = ch.voxelOriginY(); const oz = ch.voxelOriginZ(); for (let i = 0; i < 32768; i++) { const idx = ch.data[i]; if (idx === 0) continue; const m = layer.matIdxToId(idx); const lx = i % 32; const lz = ((i / 32) | 0) % 32; const ly = (i / 1024) | 0; legacyVoxels.push({ x: ox + lx, y: oy + ly, z: oz + lz, m }); } } } const t3 = performance.now(); const legacyJson = JSON.stringify(legacyVoxels); const legacyBytes = new Blob([legacyJson]).size; const t4 = performance.now(); const ratio = (legacyBytes / rleBytes).toFixed(1); const sizes = this.voxelWorld.measureSize(); console.log('[RLE Benchmark]'); console.log(` Legacy JSON: ${(legacyBytes / 1024).toFixed(0)} KB (serialize: ${(t4 - t2).toFixed(0)} ms)`); console.log(` RLE+base64: ${(rleBytes / 1024).toFixed(0)} KB (serialize: ${(t2 - t0).toFixed(0)} ms)`); console.log(` Уменьшение: ${ratio}× меньше`); console.log(' Подробно:', sizes); return { legacy: legacyBytes, rle: rleBytes, ratio }; }; // Чистый benchmark mesh-build без создания Babylon-meshей. Это // показывает скорость алгоритма greedy в отрыве от GPU. window.__voxelWorldBenchmark = async () => { const { buildChunkGeometryGreedy } = await import('./voxel/GreedyMesher'); const { buildChunkGeometry } = await import('./voxel/ChunkMesher'); const layer = this.voxelWorld.getLayer('terrain'); if (!layer) { console.warn('no terrain layer'); return; } const neighborMatIdx = (gx, gy, gz) => layer.getMatIdx(gx, gy, gz); // Surface culling (Этап 1) let totalFacesNonGreedy = 0; const t1 = performance.now(); for (const ch of layer.chunks.values()) { const r = buildChunkGeometry(ch, layer, neighborMatIdx); totalFacesNonGreedy += r.totalFaces; } const dt1 = performance.now() - t1; // Greedy (Этап 2) let totalFacesGreedy = 0; const t2 = performance.now(); for (const ch of layer.chunks.values()) { const r = buildChunkGeometryGreedy(ch, layer, neighborMatIdx); totalFacesGreedy += r.totalFaces; } const dt2 = performance.now() - t2; const reduction = ((1 - totalFacesGreedy / totalFacesNonGreedy) * 100).toFixed(1); console.log(`[Benchmark] Surface culling: ${totalFacesNonGreedy} quads in ${dt1.toFixed(0)}ms`); console.log(`[Benchmark] Greedy meshing: ${totalFacesGreedy} quads in ${dt2.toFixed(0)}ms — на ${reduction}% меньше квадров`); return { surfaceCulling: { quads: totalFacesNonGreedy, ms: dt1 }, greedy: { quads: totalFacesGreedy, ms: dt2 }, reduction: `${reduction}%` }; }; window.__voxelWorldRender = (enabled) => { this._voxelRenderEnabled = !!enabled; if (this._voxelRenderEnabled) { // Прячем legacy TerrainManager mesh'и for (const proto of this.terrainManager._protoMeshes?.values?.() ?? []) { proto.setEnabled(false); } // Если streaming ON — грузим только видимые чанки // (rebuildAll потом бы их сразу half-выгрузил, лишняя работа). if (this._voxelStreamingEnabled && this.camera) { const cam = this.camera; const r = this.voxelRenderer.updateStreaming( { x: cam.position.x, z: cam.position.z }, this._voxelStreamingRadius, ); console.log(`[VoxelWorld] render ENABLED (streaming): ${r.loaded} loaded, ${this.voxelRenderer.getActiveChunkCount()}/${r.total} active`); } else { this.voxelRenderer.rebuildAll(); console.log('[VoxelWorld] render ENABLED, legacy hidden'); } } else { // Показываем legacy обратно, скрываем новый for (const proto of this.terrainManager._protoMeshes?.values?.() ?? []) { proto.setEnabled(true); } this.voxelRenderer.dispose(); console.log('[VoxelWorld] render DISABLED, legacy restored'); } }; } // Состояние brush'а ландшафта, обновляется из TerrainPanel. // tool — 'select'|'transform'|'fill'|'sealevel'|'draw'|'sculpt'|'smooth'|'paint'|'flatten' // material — id из TERRAIN_MATERIALS // brushSize — радиус кисти в voxel'ах // strength — 1..100 // shape — 'sphere'|'cube'|'cylinder' this._terrainBrush = { tool: 'sculpt', material: 'grass', brushSize: 4, strength: 50, shape: 'sphere', // terrainMode: 'voxel' (по умолчанию) | 'smooth'. // В smooth-режиме кисти редактируют DensityGrid через SmoothBrushes, // в voxel — TerrainManager.voxels (как раньше). terrainMode: 'voxel', }; // Полупрозрачный preview-меш под курсором (показывает где будет кисть) this._terrainBrushPreview = null; this.modelManager = new ModelManager(this.scene); this.modelManager.setScene3D(this); // Делаем ModelManager доступным через scene — MultiplayerSync.js // подхватывает его для shared-кэша GLB-прототипов. this.scene._kubikonModelManager = this.modelManager; // Этап 5 редактора моделей: менеджер пользовательских voxel-моделей. // API подключается отдельно через setUserModelsApi (см. ниже), // потому что Kubikon3DService импортируется через ES-modules. this.userModelManager = new UserModelManager(this.scene); // Глобальная функция для отладки: window.__kubikonDebugColliders() // выводит в консоль все коллайдеры моделей и примитивов. if (typeof window !== 'undefined') { window.__kubikonDebugColliders = () => { const out = []; if (this.modelManager) { for (const data of this.modelManager.instances.values()) { const a = data.localAABB; if (a) { const w = (a.maxX - a.minX).toFixed(2); const h = (a.maxY - a.minY).toFixed(2); const d = (a.maxZ - a.minZ).toFixed(2); out.push({ kind: 'model', id: data.modelTypeId, pos: [data.x.toFixed(1), data.y.toFixed(1), data.z.toFixed(1)], size: [w, h, d], canCollide: data.canCollide, }); } } } if (this.primitiveManager) { for (const data of this.primitiveManager.instances.values()) { out.push({ kind: 'primitive', type: data.type, pos: [data.x.toFixed(1), data.y.toFixed(1), data.z.toFixed(1)], size: [data.sx, data.sy, data.sz], canCollide: data.canCollide, }); } } console.table(out); return out; }; } this.primitiveManager = new PrimitiveManager(this.scene); // Ссылка на обёртку BabylonScene — нужна для эмиттеров частиц // (createEmitterParticles живёт на обёртке). this.primitiveManager.scene3d = this; // BillboardUiManager — отдельный модуль, рисует GUI на DynamicTexture // для билбордов. Нужен PrimitiveManager-у чтобы при создании billboard // (type='billboard') сразу применить текстуру с дефолтным пресетом. this.billboardUiManager = new BillboardUiManager(this.scene); this.primitiveManager.billboardUiManager = this.billboardUiManager; this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager); this.guiManager = new GuiManager(); this.modalManager = new ModalManager(); this.modalManager.attachScene(this); this.modalManager.attachGui(this.guiManager); this.inventory = new InventoryManager(); this.physics = new PhysicsAABB(this.blockManager); this.physics.setPrimitiveManager(this.primitiveManager); this.physics.setModelManager(this.modelManager); this.physics.setUserModelManager(this.userModelManager); // Voxel-террейн тоже участвует в физике. У террейна свой размер // ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно. this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE); // Этап 4 voxel-движка: подключаем новый chunk-based VoxelWorld к физике. // Физика проверяет коллизии в обоих источниках (legacy terrainManager + // voxelWorld), что позволяет постепенно мигрировать без поломки. if (this.physics.setVoxelWorld && this.voxelWorld) { this.physics.setVoxelWorld(this.voxelWorld); } this.dynamics = new DynamicsManager(this); this.environment = new Environment(this.scene, this._hemiLight, this._sunLight); this.audioManager = new AudioManager(); this.assetManager = new AssetManager(); // PrimitiveManager должен уметь брать dataURL картинки по id ассета, // чтобы применять пользовательскую текстуру на грани примитива. this.primitiveManager.assetManager = this.assetManager; // Библиотека пользовательских звуков (Фаза 5.5) — постоянная. this.soundLibrary = new SoundLibrary(); // Библиотека импортированных .glb-моделей (Фаза 5.8) — постоянная. this.glbLibrary = new GlbLibrary(); this.selection = new SelectionManager(this.scene, this.blockManager, this.modelManager); this.selection.setPrimitiveManager(this.primitiveManager); this.selection.setUserModelManager(this.userModelManager); this.selection.setScene3D(this); // === Обработка кликов по 3D-табличкам (billboard) в Play-режиме === // При клике луч из позиции курсора (либо из центра экрана, если игрок // в pointer-lock) → ищем под ним меш типа billboard → переводим точку // пересечения в UV → BillboardUiManager.pickButtonAt → fireClick. // Работает только когда _isPlaying=true (в редакторе клик гизмо или ничего). // Прямой capture-phase mousedown на canvas — раньше PlayerController. // Babylon onPointerObservable не получает события в pointer-lock, // поэтому ловим сами и стреляем лучом по табличкам в Play. const canvasEl = this.canvas; const onBillboardMouseDown = (e) => { if (!this._isPlaying) return; if (e.button !== 0) return; const inLock = (document.pointerLockElement != null); let px, py; if (inLock) { px = this.engine.getRenderWidth() / 2; py = this.engine.getRenderHeight() / 2; } else { const rect = canvasEl.getBoundingClientRect(); px = e.clientX - rect.left; py = e.clientY - rect.top; } const pi = this.scene.pick(px, py, (m) => { return m.metadata?.isPrimitive && this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard'; }); if (!pi || !pi.hit || !pi.pickedMesh) return; const meta = pi.pickedMesh.metadata; const data = this.primitiveManager.instances.get(meta.primitiveId); if (!data || data.type !== 'billboard') return; const uv = pi.getTextureCoordinates ? pi.getTextureCoordinates() : null; if (!uv) return; const buttonId = this.billboardUiManager.pickButtonAt(data, uv.x, uv.y); if (buttonId) { this.billboardUiManager.fireClick(data, buttonId); // Предотвращаем PlayerController-обработчик (pointer-lock и т.д.) e.stopPropagation(); e.preventDefault(); } }; canvasEl.addEventListener('mousedown', onBillboardMouseDown, true /* capture */); // GizmoController — управляет 3 типами гизмо (move/rotate/scale). // UtilityLayer — отдельный слой, чтобы гизмо рисовалось поверх сцены. // Babylon автоматически активирует pointer-observable utility-сцены // когда родительская scene control включён (мы убрали detachControl). this._gizmoLayer = new UtilityLayerRenderer(this.scene); this._gizmo = new GizmoController(this._gizmoLayer, this.scene); this._gizmo.setMode('select'); // по умолчанию — без манипулятора this._gizmo.setSnap(1.0); // снэп для блоков // При окончании drag — синхронизируем this._gizmo.setOnDragEnd(() => this._onGizmoDragEnd()); // Привязка гизмо к выделенному this.selection.setOnSelectionChange((sel) => this._updateGizmoForSelection(sel)); // History (Undo/Redo). Сериализатор и восстановитель — методы этой сцены. this.history = new HistoryManager( () => { try { return JSON.stringify(this.serialize()); } catch (e) { return null; } }, async (state) => { // При undo/redo — снимаем выделение (mesh может быть пересоздан) this.selection?.clear(); await this.loadFromState(state); } ); // На любые изменения сцены — markChange (debounced) this.blockManager.setOnChange(() => { this.history?.markChange(); if (this._onSceneChange) this._onSceneChange(); }); this.modelManager.setOnChange(() => { this.history?.markChange(); // Сбрасываем spatial-индекс физики — модели могли двигаться/добавляться. this.physics?.setSpatialDirty?.(); if (this._onSceneChange) this._onSceneChange(); }); this.primitiveManager.setOnChange(() => { this.history?.markChange(); this.physics?.setSpatialDirty?.(); if (this._onSceneChange) this._onSceneChange(); }); // Этап 5: подписка на изменения user-моделей. this.userModelManager.setOnChange(() => { this.history?.markChange(); this.physics?.setSpatialDirty?.(); if (this._onSceneChange) this._onSceneChange(); }); this.folderManager.setOnChange(() => { this.history?.markChange(); if (this._onSceneChange) this._onSceneChange(); }); this.guiManager.setOnChange(() => { this.history?.markChange(); if (this._onSceneChange) this._onSceneChange(); if (this._onGuiChange) this._onGuiChange(); // Если в Play — обновляем зеркало в Worker'ах сразу if (this._isPlaying && this.gameRuntime) { this.gameRuntime.scheduleGuiSnapshot(); } }); this.inventory.setOnChange(() => { this.history?.markChange(); if (this._onSceneChange) this._onSceneChange(); if (this._onInventoryChange) this._onInventoryChange(); }); // Запоминаем начальное (пустое) состояние как точку для undo. this.history.initialize(); this.engine.runRenderLoop(() => { // Если рендер на паузе (например, активен таб скрипта или вкладка // браузера в фоне) — пропускаем тик целиком. Освобождаем CPU/GPU // для Monaco, который иначе лагает на ввод. if (this._renderingPaused) return; if (this.scene && this.scene.activeCamera) { this._updateCameraMovement(); this._updateGhostPosition(); const dt = this.engine.getDeltaTime() / 1000; // Физика unanchored-объектов в Play-режиме if (this._isPlaying && this.dynamics?.isEnabled()) { this.dynamics.tick(dt); } // Цикл дня/ночи (только в Play-режиме, чтобы редактор не «убегал») if (this._isPlaying && this.environment) { this.environment.tick(dt); } // Анимация жидкостей — работает всегда (и в редакторе) if (this.blockManager) { this.blockManager.tick(dt); } // LOD/culling далёких моделей (раз в 5 кадров — экономим CPU) this._lodFrameCounter = (this._lodFrameCounter || 0) + 1; if (this._lodFrameCounter % 5 === 0) { this._updateModelLOD(); // Примитивы НЕ култим по дистанции — на компактных сценах // (Squid Game) это убирает куклу/охранников вдали и пользователь // видит пустое поле. Лучшее решение — пусть Babylon // frustum-cull'ит сам, у нас уже freezeWorldMatrix. } // Этап 4 voxel-streaming: подгрузка/выгрузка чанков по радиусу // от игрока (в Play) или камеры (в редакторе). Дёргаем раз в // 250мс — этого достаточно при ходьбе. // VoxelWorld streaming (новый рендер) — disabled by default, // используется TerrainManager streaming ниже (legacy подход). if (this._voxelStreamingEnabled && this._voxelRenderEnabled && this.voxelRenderer) { const nowMs = performance.now(); if (nowMs - this._voxelStreamingLastUpdate > 200) { this._voxelStreamingLastUpdate = nowMs; let cx, cz; if (this._isPlaying && this.player && this.player._pos) { cx = this.player._pos.x; cz = this.player._pos.z; } else if (this.camera && this.camera.position) { cx = this.camera.position.x; cz = this.camera.position.z; } if (cx !== undefined) { this.voxelRenderer.updateStreaming({ x: cx, z: cz }, this._voxelStreamingRadius); } } } // === LEGACY TerrainManager streaming (region-meshes) === // Главный механизм производительности для больших карт: // enable/disable region-meshes legacy террейна по радиусу // от игрока/камеры. Регионы за пределами radius — disabled, // не рендерятся GPU. if (this._terrainStreamingEnabled && this.terrainManager?.updateStreaming) { const nowMs2 = performance.now(); // 200мс — реже чем раньше (было 80мс). Streaming = тяжёлая // операция (обход всех region-meshes), не нужна каждые 80мс. if (nowMs2 - (this._terrainStreamingLastUpdate || 0) > 200) { this._terrainStreamingLastUpdate = nowMs2; let cx, cz; let radius = this._terrainStreamingRadius || 60; if (this._isPlaying && this.player && this.player._pos) { cx = this.player._pos.x; cz = this.player._pos.z; } else if (this.camera && this.camera.position) { cx = this.camera.position.x; cz = this.camera.position.z; const camY = this.camera.position.y || 0; // Editor radius = play × 1.3 + height bonus. Capped 60м. // Раньше было ×1.6 + 30 = до 85м (47 регионов в кадре = 14M trianglов). // Сейчас 32 × 1.3 + 20 = до 60м (~20-25 регионов = ~5M trianglов). const heightBonus = Math.max(0, Math.min(20, camY * 0.3)); radius = Math.min(60, radius * 1.3 + heightBonus); } // SKIP если камера не сдвинулась >3м с прошлого пересчёта const prevX = this._terrainStreamingPrevX; const prevZ = this._terrainStreamingPrevZ; if (cx !== undefined && prevX !== undefined) { const ddx = cx - prevX, ddz = cz - prevZ; if (ddx * ddx + ddz * ddz < 9) { // < 3м cx = undefined; // отменяет следующий блок } } if (cx !== undefined) { this._terrainStreamingPrevX = cx; this._terrainStreamingPrevZ = cz; } if (cx !== undefined) { this.terrainManager.updateStreaming(cx, cz, radius); // Этап D: deco streaming с МЕНЬШИМ радиусом. // Декорации видны только вблизи. Минимум 35м чтобы // chunk 64м не пропадал — иначе видны «дыры». if (this.decoManager?.updateStreaming) { const decoRadius = Math.max(18, radius * 0.35); this.decoManager.updateStreaming(cx, cz, decoRadius); } } } } // Задача 04: modalManager.tick — независимо от runtime'а if (this._isPlaying && this.modalManager?.tick) { try { this.modalManager.tick(dt); } catch (e) {} } // Tick пользовательских скриптов: в Play-режиме или в solo-debug if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) { this.gameRuntime.tick(dt); // Детекция touch-событий — раз в 3 кадра (для script onTouch). // Это O(N×M) = скрипты × примитивы, на мобиле просаживало FPS // при повороте. 3 кадра ≈ 50мс при 60fps — хватает для UX. if (this._isPlaying) { this._touchDetectFrame = (this._touchDetectFrame || 0) + 1; if (this._touchDetectFrame >= 3) { this._touchDetectFrame = 0; this._detectTouchEvents(); } } } // Анимация полоски перезарядки оружия if (this._isPlaying && this.weapons) this.weapons.tick(); // PERF-METRICS: замеряем render() — обычно самая толстая часть. const _rt0 = performance.now(); // Замер idle-времени = промежуток между концом предыдущего // render и началом текущего. Большое idle = GPU-bound. if (this._perfMetrics && this._perfMetrics._lastRenderEnd > 0) { const idle = _rt0 - this._perfMetrics._lastRenderEnd; if (idle > 0 && idle < 1000) { this._perfMetrics.idle_ms_sum += idle; this._perfMetrics.idle_count++; } } this.scene.render(); if (this._perfMetrics) { const _rt1 = performance.now(); this._perfMetrics.render_ms_sum += _rt1 - _rt0; this._perfMetrics.render_count++; this._perfMetrics._lastRenderEnd = _rt1; } } }); // resize при изменении окна this._resizeHandler = () => this.engine.resize(); window.addEventListener('resize', this._resizeHandler); // Главное: ResizeObserver на canvas. React сначала рендерит canvas // размером 0, потом раскладка применяет 100%/100%. Engine, созданный // в момент init, считал размер 0 и backbuffer был пуст. Через RO // ловим финальный размер и вызываем resize. if (typeof ResizeObserver !== 'undefined') { this._ro = new ResizeObserver(() => { if (this.engine) this.engine.resize(); }); this._ro.observe(this.canvas); } // Принудительный resize чуть позже — на случай если RO не сработал setTimeout(() => { if (this.engine) this.engine.resize(); }, 100); } /** * UniversalCamera — позволяет ручное управление позицией и yaw/pitch. * Стартовая позиция: смотрим на (0,0,0) сверху-сбоку. */ _createCamera() { const camera = new UniversalCamera( 'editorCamera', new Vector3(15, 15, -20), this.scene ); camera.setTarget(new Vector3(0, 0, 0)); camera.minZ = 0.1; camera.maxZ = 1000; camera.fov = 0.9; // ОТКЛЮЧАЕМ стандартное управление — будем писать своё. camera.inputs.clear(); this.camera = camera; } _createLights() { const hemi = new HemisphericLight( 'hemiLight', new Vector3(0, 1, 0), this.scene ); hemi.intensity = 0.65; hemi.groundColor = new Color3(0.3, 0.3, 0.4); const sun = new DirectionalLight( 'sunLight', new Vector3(-0.5, -1, -0.3), this.scene ); sun.intensity = 0.8; sun.position = new Vector3(20, 40, 20); // Сохраняем ссылки чтобы Environment мог менять их свойства this._hemiLight = hemi; this._sunLight = sun; // Тени — по умолчанию мягкие. Создаётся ShadowGenerator при первом // вызове setShadowQuality, либо сразу через _ensureShadowGenerator. // MOBILE-OPT (этап 1.5): на мобильном тени = 'hard' (жёсткие — без // soft-blur, дешевле). 'off' давало плоскую картинку, теперь // компромисс — есть тени но дешевле soft. this._shadowQuality = this._isMobileMode ? 'hard' : 'soft'; this._shadowGenerator = null; this._ensureShadowGenerator(); // SSAO2 — контактные тени (по умолчанию off, тяжёлый эффект). this._ssaoPipeline = null; this._ssaoEnabled = false; } /** Создаёт ShadowGenerator (если ещё нет) и применяет текущее качество. * * Уровни (Этап 2+5 теней, 2026-05-27): * - 'off' — теней нет * - 'hard' — резкие, 512px * - 'soft' — мягкие, 1024px (на mobile 512), blurKernel 24 * - 'medium' — CSM 1024 × 3 каскада * - 'high' — CSM 2048 × 4 каскада */ _ensureShadowGenerator() { const q = this._shadowQuality; if (q === 'off') { if (this._shadowGenerator) { try { this._shadowGenerator.dispose(); } catch (e) { /* ignore */ } this._shadowGenerator = null; } return null; } const wantCsm = (q === 'medium' || q === 'high'); const haveCsm = this._shadowGenerator instanceof CascadedShadowGenerator; if (this._shadowGenerator && wantCsm !== haveCsm) { try { this._shadowGenerator.dispose(); } catch (e) { /* ignore */ } this._shadowGenerator = null; } // PCF = Percentage Closer Filtering. Это правильная техника мягких // теней (контурное сглаживание по shadow-map). // // bias/normalBias подобраны под воксельную графику: // bias = 0.0005 — сдвиг по глубине, убирает acne на flat-граней // normalBias = 0.005 — сдвиг вдоль нормали, убирает acne на острых // кромках. БОЛЬШИЕ значения дают peter-panning // (тень отрывается от объекта и улетает в // сторону солнца). Было 0.012 → длинная // "уехавшая" тень на скрине пользователя // 2026-05-27. 0.005 — золотая середина для // кубов 1м с прямыми гранями. const PCF_BIAS = 0.0005; const PCF_NORMAL_BIAS = 0.005; if (!this._shadowGenerator) { if (wantCsm) { // CSM: больше разрешение каскадов — тени детальнее. const size = (q === 'high') ? 4096 : 2048; const numCascades = (q === 'high') ? 4 : 3; const csm = new CascadedShadowGenerator(size, this._sunLight); csm.numCascades = numCascades; csm.stabilizeCascades = true; csm.lambda = 0.8; csm.cascadeBlendPercentage = 0.07; csm.shadowMaxZ = (q === 'high') ? 200 : 120; csm.bias = PCF_BIAS; csm.normalBias = PCF_NORMAL_BIAS; csm.usePercentageCloserFiltering = true; csm.filteringQuality = (q === 'high') ? ShadowGenerator.QUALITY_HIGH : ShadowGenerator.QUALITY_MEDIUM; csm.darkness = 0.4; csm.autoCalcDepthBounds = true; this._shadowGenerator = csm; } else { // Обычный ShadowGenerator. Поднял разрешение для soft до 2048. let shadowSize; if (q === 'hard') { shadowSize = this._isMobileMode ? 512 : 1024; } else { // soft shadowSize = this._isMobileMode ? 1024 : 2048; } const gen = new ShadowGenerator(shadowSize, this._sunLight); gen.bias = PCF_BIAS; gen.normalBias = PCF_NORMAL_BIAS; if (gen.getShadowMap) { const rtt = gen.getShadowMap(); if (rtt) rtt.refreshRate = 2; } this._shadowGenerator = gen; } } const gen = this._shadowGenerator; if (q === 'medium' || q === 'high') { // параметры CSM выставлены при создании (PCF + high quality) } else if (q === 'soft') { // PCF medium quality — мягкие тени без шумного блюра gen.usePercentageCloserFiltering = true; gen.filteringQuality = ShadowGenerator.QUALITY_MEDIUM; gen.useBlurExponentialShadowMap = false; gen.useKernelBlur = false; gen.usePoissonSampling = false; gen.darkness = 0.4; } else { // hard gen.usePercentageCloserFiltering = false; gen.useBlurExponentialShadowMap = false; gen.useKernelBlur = false; gen.usePoissonSampling = false; gen.darkness = 0.55; } return gen; } /** Изменить качество теней. */ setShadowQuality(q) { const allowed = ['off', 'hard', 'soft', 'medium', 'high']; if (!allowed.includes(q)) return; this._shadowQuality = q; this._ensureShadowGenerator(); const ground = this.scene.getMeshByName('editorGround'); if (ground) ground.receiveShadows = q !== 'off'; if (q !== 'off') { try { this.refreshAllShadows(); } catch (e) { /* ignore */ } } } getShadowQuality() { return this._shadowQuality || 'soft'; } /** Включить/выключить SSAO пост-эффект (контактные тени). * * Используем SSAORenderingPipeline v1 (не v2). v2 требует * GeometryBufferRenderer (G-buffer для нормалей), что ломает рендер * thin-instance мешей (BlockManager, TerrainManager) — блоки пропадают * при включении. v1 использует только depthRenderer (одна depth-текстура), * совместим со всеми типами мешей. Качество чуть хуже, но стабильно. */ setSsaoEnabled(on) { const want = !!on; if (this._ssaoEnabled === want && (!want || this._ssaoPipeline)) return; if (!want) { this._disposeSsaoPipeline(); this._ssaoEnabled = false; return; } if (!this.scene.activeCamera) { console.warn('[BabylonScene] SSAO: нет активной камеры'); return; } try { // ratio: render-resolution для AO-пасса (0.5 = half-res, дешевле). // combineRatio: финальная композиция всегда 1.0 (full-res). const ratio = { ssaoRatio: 0.5, combineRatio: 1.0 }; const pipeline = new SSAORenderingPipeline( 'ssaopipeline', this.scene, ratio, [this.scene.activeCamera] ); pipeline.fallOff = 0.000001; pipeline.area = 0.0075; pipeline.radius = 0.0001; pipeline.totalStrength = 1.0; // насколько тёмные углы pipeline.base = 0.5; // базовая яркость (1.0 = без AO) this._ssaoPipeline = pipeline; this._ssaoEnabled = true; } catch (e) { console.warn('[BabylonScene] SSAO не запустился:', e?.message || e); this._disposeSsaoPipeline(); this._ssaoEnabled = false; } } /** Полностью убрать SSAO пайплайн (detach + remove + dispose). * Только dispose() оставляет null-объект в postProcessRenderPipelineManager * → следующий кадр падает в null.isSupported. */ _disposeSsaoPipeline() { if (!this._ssaoPipeline) return; const mgr = this.scene.postProcessRenderPipelineManager; const name = this._ssaoPipeline.name || 'ssaopipeline'; try { if (mgr && this.scene.activeCamera) { mgr.detachCamerasFromRenderPipeline?.(name, this.scene.activeCamera); } } catch (e) { /* ignore */ } try { // Удаляем из менеджера ДО dispose — иначе на следующем кадре // менеджер обходит уже задиспозенный пайплайн. if (mgr && typeof mgr.removePipeline === 'function') { mgr.removePipeline(name); } } catch (e) { /* ignore */ } try { this._ssaoPipeline.dispose(); } catch (e) { /* ignore */ } this._ssaoPipeline = null; } getSsaoEnabled() { return this._ssaoEnabled; } /** * Установить свойства глобального освещения. Вызывается из Inspector * (selection.type === 'lighting'). * patch: { sunIntensity?, hemiIntensity?, hemiGround?, fogEnabled?, * fogDensity?, fogColor?, shadowQuality? } */ setLightingProps(patch) { if (!patch) return; // Время суток — пресет / минуты день/ночь if (patch.envPreset && this.environment) { try { this.environment.setPreset(patch.envPreset); } catch (e) { /* ignore */ } } if (typeof patch.dayDurationMin === 'number' && patch.dayDurationMin > 0 && this.environment) { this.environment.setCycleDuration(patch.dayDurationMin, this.environment.nightDurationMin); } if (typeof patch.nightDurationMin === 'number' && patch.nightDurationMin > 0 && this.environment) { this.environment.setCycleDuration(this.environment.dayDurationMin, patch.nightDurationMin); } if (typeof patch.sunIntensity === 'number' && this._sunLight) { this._sunLight.intensity = Math.max(0, patch.sunIntensity); } if (typeof patch.hemiIntensity === 'number' && this._hemiLight) { this._hemiLight.intensity = Math.max(0, patch.hemiIntensity); } if (this.environment && typeof this.environment.setFog === 'function') { // Текущие значения берём из Environment, поверх накладываем patch const enabled = (typeof patch.fogEnabled === 'boolean') ? patch.fogEnabled : this.environment.fogEnabled; let color = this.environment.fogColor; if (patch.fogColor && /^#[0-9a-fA-F]{6}$/.test(patch.fogColor)) { color = [ parseInt(patch.fogColor.substr(1, 2), 16) / 255, parseInt(patch.fogColor.substr(3, 2), 16) / 255, parseInt(patch.fogColor.substr(5, 2), 16) / 255, ]; } const density = (typeof patch.fogDensity === 'number') ? patch.fogDensity : this.environment.fogDensity; if ('fogEnabled' in patch || 'fogDensity' in patch || 'fogColor' in patch) { this.environment.setFog(enabled, color, density); } } if (patch.shadowQuality) { this.setShadowQuality(patch.shadowQuality); this.refreshAllShadows(); } if (typeof patch.ssaoEnabled === 'boolean') { this.setSsaoEnabled(patch.ssaoEnabled); } // Обновить selection чтобы Inspector сразу показывал новые значения if (this.selection?._selection?.type === 'lighting') { this.selection.selectLighting(); } } /** * Сгруппировать текущие выделенные объекты в новую папку (Ctrl+G). * Если выделен один — кладёт его одного. Если ничего — no-op. */ groupSelected(name = null) { if (!this.folderManager || !this.selection) return null; const multi = this.selection.getMultiSelection(); const items = []; if (multi.length > 0) { for (const it of multi) items.push(it); } else { const s = this.selection.getSelection(); if (!s) return null; if (s.type === 'block') items.push({ kind: 'block', ref: { x: s.gridX, y: s.gridY, z: s.gridZ } }); else if (s.type === 'model') items.push({ kind: 'model', ref: s.instanceId }); else if (s.type === 'primitive') items.push({ kind: 'primitive', ref: s.id }); else return null; } if (items.length === 0) return null; const folderName = name || `Группа ${this.folderManager.getAll().length + 1}`; const folderId = this.folderManager.createFolder(folderName, null); for (const it of items) { this.folderManager.assignToFolder(it.kind, it.ref, folderId); } return folderId; } /** * Зарегистрировать меш как «отбрасывающий тень». Безопасно вызывать многократно. * ВАЖНО: только настоящие Mesh (с геометрией), а не TransformNode-узлы. * ShadowGenerator вызывает getBoundingInfo()/getVerticesData() — у TransformNode * этих методов нет, что приводит к runtime-крашу. */ /** Удалено: пытались через ShadowGenerator, не сработало. * Теперь тени делает GdGroundSkin через синтетические «тени-кружки». */ _enableGdShadows() { /* no-op */ } addShadowCaster(mesh) { if (!this._shadowGenerator || !mesh) return; // TransformNode не имеет getBoundingInfo/getVerticesData if (typeof mesh.getBoundingInfo !== 'function') return; if (typeof mesh.getTotalVertices !== 'function') return; if (mesh.getTotalVertices() <= 0) return; try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ } } /** * Зарегистрировать все текущие блоки/модели/примитивы как shadow casters. * Полезно вызвать после loadFromState или смены качества теней. */ refreshAllShadows() { if (!this._shadowGenerator) return; if (this.blockManager) { // Для thin-instance proto-мешей: один addShadowCaster на тип = все инстансы if (this.blockManager._protoMeshes) { for (const proto of this.blockManager._protoMeshes.values()) { this.addShadowCaster(proto); } } // Жидкости/legacy mesh for (const m of this.blockManager.blocks.values()) { if (m && typeof m.getBoundingInfo === 'function') this.addShadowCaster(m); } } if (this.modelManager) { for (const inst of this.modelManager.instances.values()) { const root = inst.rootMesh; if (!root) continue; // root обычно TransformNode → пропускаем сам root, добавляем только child-mesh'ы if (typeof root.getChildMeshes === 'function') { for (const cm of root.getChildMeshes()) this.addShadowCaster(cm); } } } if (this.primitiveManager) { for (const inst of this.primitiveManager.instances.values()) { if (inst.mesh) this.addShadowCaster(inst.mesh); } } } _createGroundGrid() { // Размер мира — настраивается через setWorldSize(). Пол идёт от -WORLD_HALF до +WORLD_HALF. const WORLD_HALF = this._worldHalf; const ground = MeshBuilder.CreateGround( 'editorGround', { width: WORLD_HALF * 2, height: WORLD_HALF * 2, subdivisions: 1 }, this.scene ); // Baseplate-текстура (как в Roblox Studio): процедурный клетчатый паттерн. // Рисуем в DynamicTexture — каждая «плитка» 1×1 на грани соответствует // 1 единице мира. Делаем 64×64 пикселей — каждый пиксель = 1 квадрат. const TEX_SIZE = 64; const baseTex = new DynamicTexture('baseplateTex', { width: TEX_SIZE, height: TEX_SIZE }, this.scene, false); baseTex.wrapU = 1; // wrap baseTex.wrapV = 1; baseTex.uScale = WORLD_HALF * 2 / 4; // одна повторка покрывает 4 клетки baseTex.vScale = WORLD_HALF * 2 / 4; baseTex.updateSamplingMode(DynamicTexture.NEAREST_SAMPLINGMODE); const ctx = baseTex.getContext(); // Серая основа ctx.fillStyle = '#7a8071'; ctx.fillRect(0, 0, TEX_SIZE, TEX_SIZE); // Тёмные «рейки» по периметру плитки (Roblox-style) ctx.strokeStyle = '#5d6358'; ctx.lineWidth = 4; ctx.strokeRect(2, 2, TEX_SIZE - 4, TEX_SIZE - 4); // Тонкая внутренняя сетка 4 на плитке ctx.strokeStyle = '#6c7268'; ctx.lineWidth = 1; for (let i = 1; i < 4; i++) { const p = (i * TEX_SIZE) / 4; ctx.beginPath(); ctx.moveTo(p, 2); ctx.lineTo(p, TEX_SIZE - 2); ctx.moveTo(2, p); ctx.lineTo(TEX_SIZE - 2, p); ctx.stroke(); } baseTex.update(); const mat = new StandardMaterial('groundMat', this.scene); mat.diffuseTexture = baseTex; mat.specularColor = new Color3(0, 0, 0); ground.material = mat; ground.receiveShadows = true; // Только две осевые линии (X и Z), цветные — для ориентации в редакторе. // Сетку делает сама baseplate-текстура. const axisMatX = new StandardMaterial('axisMatX', this.scene); axisMatX.diffuseColor = new Color3(0.85, 0.25, 0.25); axisMatX.emissiveColor = new Color3(0.5, 0.1, 0.1); axisMatX.specularColor = new Color3(0, 0, 0); const axisMatZ = new StandardMaterial('axisMatZ', this.scene); axisMatZ.diffuseColor = new Color3(0.25, 0.4, 0.85); axisMatZ.emissiveColor = new Color3(0.1, 0.2, 0.5); axisMatZ.specularColor = new Color3(0, 0, 0); // Ось X (красная) — линия вдоль X на z=0 const axisX = MeshBuilder.CreateBox('axisX', { width: WORLD_HALF * 2, height: 0.02, depth: 0.1 }, this.scene); axisX.position = new Vector3(0, 0.011, 0); axisX.material = axisMatX; axisX.isPickable = false; this._gridLines = [axisX]; // Ось Z (синяя) — линия вдоль Z на x=0 const axisZ = MeshBuilder.CreateBox('axisZ', { width: 0.1, height: 0.02, depth: WORLD_HALF * 2 }, this.scene); axisZ.position = new Vector3(0, 0.011, 0); axisZ.material = axisMatZ; axisZ.isPickable = false; this._gridLines.push(axisZ); } /** * Изменить размер пола (worldSize × worldSize). Пересоздаёт пол и осевые линии. * @param {number} worldSize — полный размер стороны пола в юнитах (например 100, 200, 500). */ setWorldSize(worldSize) { const half = Math.max(10, Math.round(worldSize / 2)); if (half === this._worldHalf) return; this._worldHalf = half; // Удалить старый пол + осевые линии const oldGround = this.scene.getMeshByName('editorGround'); if (oldGround) try { oldGround.dispose(); } catch (e) { /* ignore */ } if (Array.isArray(this._gridLines)) { for (const line of this._gridLines) { try { line.dispose(); } catch (e) { /* ignore */ } } } this._gridLines = []; this._createGroundGrid(); } /** Текущий размер пола в юнитах (worldSize, не worldHalf). */ getWorldSize() { return this._worldHalf * 2; } /** Включить/выключить пол (визуально и физически). */ setFloorEnabled(enabled) { this._floorEnabled = !!enabled; if (!this.scene) return; const ground = this.scene.getMeshByName('editorGround'); if (ground) ground.setEnabled(this._floorEnabled); // Линии осей тоже if (Array.isArray(this._gridLines)) { for (const line of this._gridLines) { if (line && line.setEnabled) line.setEnabled(this._floorEnabled); } } // Физика: отключаем коллизию с baseplate, чтобы игрок проваливался if (this.physics) this.physics.floorEnabled = this._floorEnabled; } isFloorEnabled() { return this._floorEnabled !== false; } /** * Очистить гладкий ландшафт (RobloxTerrain) — убирает все chunks, * отвязывает от физики, возвращает baseplate-пол, ставит spawn по умолчанию. * Вызывается из UI (кнопка «✖» в Генератор-панели). */ clearRobloxTerrain() { let hadTerrain = false; if (this._robloxTerrain) { try { this._robloxTerrain.disposeAll(); hadTerrain = true; } catch (e) {} // ВАЖНО: обнуляем ссылку, иначе __robloxTest при новой генерации // решит что terrain уже есть и НЕ переподключит его к physics. this._robloxTerrain = null; } // Декорации тоже чистим (thin-instances очищаются, prototype остаётся // в памяти для следующего применения). if (this._smoothDecoManager) { try { this._smoothDecoManager.clear(); } catch (e) {} } this._smoothDecoParams = null; if (this.physics?.setRobloxTerrain) { this.physics.setRobloxTerrain(null); } if (this.physics?.setSmoothDecoTrees) { this.physics.setSmoothDecoTrees(null); } try { this.setFloorEnabled(true); } catch (e) {} this._spawnPoint = { x: 0, y: 5, z: 0 }; try { this._updateSpawnMarker?.(); } catch (e) {} // Прячем мини-карту гладкого ландшафта — grid больше нет. if (window.__robloxMinimapGrid) { window.__robloxMinimapGrid = null; this._terrainStreamingEnabled = false; } // Помечаем dirty чтобы автосейв записал пустой robloxTerrain try { this._onSceneChange?.(); } catch (e) {} console.log(`[BabylonScene] clearRobloxTerrain (hadTerrain=${hadTerrain})`); } /** * Множитель силы прыжка игрока. 1 = базовый (~8 у/с), 1.5 = в 1.5 раза выше. * Применяется при enterPlayMode и через player.setJumpPower. */ setPlayerJumpPower(mul) { const m = Math.max(0.2, Math.min(5, Number(mul) || 1)); this._jumpPowerMul = m; if (this.player) this.player._jumpPowerMul = m; } getPlayerJumpPower() { return this._jumpPowerMul ?? 1; } /** Тип прицела в Play: 'none' | 'dot' | 'cross' | 'circle'. */ setCrosshair(type) { const allowed = ['none', 'dot', 'cross', 'circle']; if (!allowed.includes(type)) return; this._crosshair = type; } getCrosshair() { return this._crosshair || 'none'; } /** * LOD/culling для моделей: модели дальше LOD_FREEZE замораживают мировую * матрицу (экономия CPU), модели дальше LOD_CULL — отключаются от рендера. * Запускается в render-loop из tick(). */ _updateModelLOD() { if (!this.modelManager || !this.camera) return; const cam = this.camera.position; const LOD_FREEZE = 60; // юниты — за этим расстоянием freezeWorldMatrix const LOD_FREEZE_SQ = LOD_FREEZE * LOD_FREEZE; const LOD_CULL = 600; // юниты — за этим расстоянием полностью скрываем // (было 200; увеличено чтобы модели на удалённых уровнях не пропадали при editor-камере) const LOD_CULL_SQ = LOD_CULL * LOD_CULL; for (const data of this.modelManager.instances.values()) { const root = data.rootMesh; if (!root) continue; // Динамические объекты (зомби, спавнеры, runtime-спавнутые) НЕ // подвергаем LOD-freeze — за ними двигает свой менеджер. const gp = data.gameplay; if (gp?.isZombie || gp?.isZombieSpawner || data._spawnedAtRuntime) continue; const dx = root.position.x - cam.x; const dy = root.position.y - cam.y; const dz = root.position.z - cam.z; const distSq = dx * dx + dy * dy + dz * dz; // Cull const shouldCull = distSq > LOD_CULL_SQ && data.visible !== false; if (shouldCull && root._kubikonCulled !== true) { root.setEnabled(false); root._kubikonCulled = true; } else if (!shouldCull && root._kubikonCulled === true) { root.setEnabled(data.visible !== false); root._kubikonCulled = false; } // Freeze const shouldFreeze = distSq > LOD_FREEZE_SQ; if (shouldFreeze && root._kubikonFrozen !== true) { try { root.freezeWorldMatrix(); } catch (e) { /* ignore */ } root._kubikonFrozen = true; } else if (!shouldFreeze && root._kubikonFrozen === true) { try { root.unfreezeWorldMatrix(); } catch (e) { /* ignore */ } root._kubikonFrozen = false; } } } /** * LOD-cull для примитивов: далёкие декорации скрываем, ближние видны. * Только в Play-режиме (в редакторе пользователь должен видеть всю сцену * чтобы редактировать). На больших проектах (Only Up: 568 примитивов на * вертикальной башне) это критично — без LOD при повороте камеры Babylon * frustum-cull'ит сотни мешей и FPS падает в пол. */ _updatePrimitiveLOD() { if (!this._isPlaying) return; if (!this.primitiveManager || !this.camera) return; const cam = this.camera.position; const CULL = 120; const CULL_SQ = CULL * CULL; for (const data of this.primitiveManager.instances.values()) { const m = data.mesh; if (!m) continue; // Не трогаем явно скрытые/невидимые скриптом if (data.visible === false) continue; const dx = data.x - cam.x; const dy = data.y - cam.y; const dz = data.z - cam.z; const distSq = dx * dx + dy * dy + dz * dz; const shouldCull = distSq > CULL_SQ; if (shouldCull && m._kubikonPrimCulled !== true) { m.setEnabled(false); m._kubikonPrimCulled = true; } else if (!shouldCull && m._kubikonPrimCulled === true) { m.setEnabled(true); m._kubikonPrimCulled = false; } } } /** * Roblox-style input handlers. * Мышиные события — на canvas (только когда мышь над сценой). * Клавиатурные — на window (работают при любом фокусе, как в реальных * 3D-редакторах). Используем e.code (KeyW, KeyA, KeyS, KeyD, KeyQ, KeyE, KeyF) * чтобы клавиши работали на любой раскладке (русская/английская). */ _setupInputControls() { const canvas = this.canvas; // === МЫШЬ === // mousedown на canvas в capture-фазе → срабатывает первым, // даже если поверх есть другие listeners. const onMouseDown = (e) => { if (this._isPlaying) { // В Play-режиме ЛКМ — клик игрока в forward-направлении. // Pointer Lock — курсор всё равно в центре экрана. if (e.button === 0) this._handlePlayClick(); return; } // Обновляем pointer координаты для raycast и Gizmo const r = canvas.getBoundingClientRect(); this.scene.pointerX = e.clientX - r.left; this.scene.pointerY = e.clientY - r.top; // Если это ЛКМ — пробуем pickнуть гизмо. Если попали в гизмо — // отдаём событие Babylon GizmoManager и выходим (не ставим блок). // Проверка attachedMesh || attachedNode — у разных версий Babylon // и при attachToMesh vs attachToNode заполняется разное поле. const hasAttachment = this._gizmo && (this._gizmo.manager.attachedMesh || this._gizmo.manager.attachedNode); if (e.button === 0 && hasAttachment) { const ulScene = this._gizmoLayer?.utilityLayerScene; if (ulScene) { const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY); if (ulPick && ulPick.hit) { // Симулируем pointer-events для GizmoManager на utility-scene ulScene.simulatePointerDown(ulPick); this._gizmoDragging = true; e.preventDefault(); return; } } } // Запоминаем стартовую точку любого нажатия — для drag-detection. this._mouseDownButton = e.button; this._mouseDownX = e.clientX; this._mouseDownY = e.clientY; this._mouseDownTime = Date.now(); // ЛКМ + tool=block/erase → активируем drag-постановку. // Сразу же ставим первый блок в клетке под курсором. if (e.button === 0 && !e.shiftKey && (this._activeTool === 'block' || this._activeTool === 'erase')) { this._isDragPlacing = true; this._lastPlacedKey = null; this._dragLockAxis = null; this._dragPlaceTick(false, /*isFirst*/ true); e.preventDefault(); } // ЛКМ + tool=terrain → активируем drag-кисть террейна. // Shift модификатор обрабатывается внутри _terrainBrushTick (стирание). else if (e.button === 0 && this._activeTool === 'terrain') { this._isTerrainBrushing = true; this._terrainDragLockY = null; this._terrainHistoryOpen(); // снапшот для undo this._setTerrainBrushPreviewActive(true); this._terrainBrushTick(e.shiftKey, /*isFirst*/ true); e.preventDefault(); } // Shift+ЛКМ — drag-удаление (даже если tool=block) else if (e.button === 0 && e.shiftKey) { this._isDragPlacing = true; this._lastPlacedKey = null; this._dragLockAxis = null; this._dragPlaceTick(true, /*isFirst*/ true); e.preventDefault(); } if (e.button === 2) { this._isRotating = true; this._lastMouseX = e.clientX; this._lastMouseY = e.clientY; canvas.style.cursor = 'grabbing'; e.preventDefault(); e.stopPropagation(); } else if (e.button === 1) { this._isPanning = true; this._lastMouseX = e.clientX; this._lastMouseY = e.clientY; canvas.style.cursor = 'move'; e.preventDefault(); e.stopPropagation(); } // ЛКМ (button === 0) ничего не запускает сразу — обрабатывается на mouseup // только если был "клик" (не drag). }; // mouseup и mousemove — на window, чтобы drag работал даже когда // курсор вышел за пределы canvas (стандартное поведение для drag). const onMouseMove = (e) => { // Babylon без detachControl сам не пишет в scene.pointerX/Y — // делаем это руками. Нужны для raycast (scene.pick) и для гизмо. const r = canvas.getBoundingClientRect(); this.scene.pointerX = e.clientX - r.left; this.scene.pointerY = e.clientY - r.top; // Если идёт drag гизмо — проксируем move в utility-scene if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) { const ulScene = this._gizmoLayer.utilityLayerScene; const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY); ulScene.simulatePointerMove(ulPick); return; } // Если идёт drag-постановка блоков — пытаемся поставить в новой клетке if (this._isDragPlacing) { this._dragPlaceTick(e.shiftKey); return; } // Если идёт drag-кисть террейна — продолжаем рисовать. // Двигаем preview-меш под курсор ВНУТРИ drag тоже, иначе сфера // зависает в точке первого клика, пока юзер водит мышью. if (this._isTerrainBrushing) { this._terrainBrushTick(e.shiftKey, /*isFirst*/ false); this._updateTerrainBrushPosition(); return; } // Когда tool=terrain без drag'а — подвигаем preview-меш под курсор if (this._activeTool === 'terrain') { this._updateTerrainBrushPosition(); } if (!this._isRotating && !this._isPanning) return; const dx = e.clientX - this._lastMouseX; const dy = e.clientY - this._lastMouseY; this._lastMouseX = e.clientX; this._lastMouseY = e.clientY; if (this._isRotating) { this.camera.rotation.y += dx * this.ROTATE_SENSITIVITY; this.camera.rotation.x += dy * this.ROTATE_SENSITIVITY; const limit = Math.PI / 2 - 0.05; if (this.camera.rotation.x > limit) this.camera.rotation.x = limit; if (this.camera.rotation.x < -limit) this.camera.rotation.x = -limit; } else if (this._isPanning) { const right = this._getCameraRight(); const up = this._getCameraUp(); this.camera.position.addInPlace(right.scale(-dx * this.PAN_SENSITIVITY)); this.camera.position.addInPlace(up.scale(dy * this.PAN_SENSITIVITY)); } }; const onMouseUp = (e) => { // Если идёт drag гизмо — отдаём pointerup и завершаем if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) { const ulScene = this._gizmoLayer.utilityLayerScene; const r = canvas.getBoundingClientRect(); this.scene.pointerX = e.clientX - r.left; this.scene.pointerY = e.clientY - r.top; const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY); ulScene.simulatePointerUp(ulPick); this._gizmoDragging = false; this._mouseDownButton = -1; return; } // Если был drag-кисть террейна — сбрасываем флаг if (this._isTerrainBrushing) { this._isTerrainBrushing = false; this._terrainDragLockY = null; this._smoothBrushLockY = null; this._smoothBrushLastPos = null; this._terrainHistoryClose(); // фиксируем undo-снапшот this._setTerrainBrushPreviewActive(false); this._mouseDownButton = -1; return; } // Если был drag-place — просто сбрасываем флаг, клик не обрабатываем // (первая постановка уже сделана при mousedown). if (this._isDragPlacing) { this._isDragPlacing = false; this._lastPlacedKey = null; this._dragLockAxis = null; this._mouseDownButton = -1; return; } // Если это была ЛКМ и НЕ drag (курсор не сдвинулся существенно) // → это клик; обрабатываем как редактор-клик (поставить/удалить блок). if (e.button === 0 && this._mouseDownButton === 0) { const dx = Math.abs(e.clientX - this._mouseDownX); const dy = Math.abs(e.clientY - this._mouseDownY); const dt = Date.now() - this._mouseDownTime; if (dx < 4 && dy < 4 && dt < 400) { this._handleEditorClick(e.shiftKey, e.ctrlKey || e.metaKey); } } this._mouseDownButton = -1; if (e.button === 2) { this._isRotating = false; canvas.style.cursor = 'default'; } else if (e.button === 1) { this._isPanning = false; canvas.style.cursor = 'default'; } }; const onWheel = (e) => { e.preventDefault(); const forward = this._getCameraForward(); const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED; this.camera.position.addInPlace(forward.scale(delta)); }; const onContextMenu = (e) => { e.preventDefault(); }; // === КЛАВИАТУРА === // Используем e.code (KeyW, KeyA, ...) — независимо от раскладки. // ВАЖНО: e.key на русской раскладке возвращает кириллицу ('ц', 'ы', ...), // поэтому надёжно использовать только e.code. /** * Игнорировать события клавиатуры если фокус в input/textarea/contenteditable. * Иначе пробел/буквы из ввода в модалке двигают камеру и блокируют ввод. * Также игнорируем когда открыта модалка (z-index overlay). */ const _isInEditableEl = (el) => { if (!el) return false; const tag = (el.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; if (el.isContentEditable) return true; // Monaco-редактор скриптов — ввод идёт через скрытую textarea.inputarea // внутри .monaco-editor. Принадлежность дереву Monaco = идёт набор. if (typeof el.closest === 'function' && el.closest('.monaco-editor')) return true; return false; }; const isTypingTarget = (target) => { // Проверяем И e.target, И document.activeElement. Ctrl+V в Monaco // иногда всплывает до window с target == document.body (фокус живёт // на скрытой inputarea), поэтому одной проверки target мало — без // activeElement глобальный Ctrl+V (pasteFromClipboard) перехватывал // вставку кода в редактор скриптов. return _isInEditableEl(target) || _isInEditableEl(document.activeElement); }; const onKeyDown = (e) => { if (isTypingTarget(e.target)) return; this._codes.add(e.code); if (e.shiftKey) this._shiftDown = true; // Маршрутизация game.onKey в Play-режиме if (this._isPlaying && this.gameRuntime) { const key = this._normalizeKey(e); this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code }); } if (e.code === 'KeyF') { this._focusOnTarget(new Vector3(0, 0, 0)); } // Ctrl+D — дублировать выделенное if (e.code === 'KeyD' && (e.ctrlKey || e.metaKey) && !this._isPlaying) { e.preventDefault(); this.duplicateSelected(); return; } // Ctrl+C — копировать выделенное в буфер (Фаза 5.10). if (e.code === 'KeyC' && (e.ctrlKey || e.metaKey) && !this._isPlaying) { e.preventDefault(); this.copySelected(); return; } // Ctrl+V — вставить из буфера (работает и между проектами). if (e.code === 'KeyV' && (e.ctrlKey || e.metaKey) && !this._isPlaying) { e.preventDefault(); this.pasteFromClipboard(); return; } // Ctrl+Z — undo, Ctrl+Shift+Z или Ctrl+Y — redo if ((e.ctrlKey || e.metaKey) && !this._isPlaying) { if (e.code === 'KeyZ' && !e.shiftKey) { e.preventDefault(); this.undo?.(); return; } if ((e.code === 'KeyZ' && e.shiftKey) || e.code === 'KeyY') { e.preventDefault(); this.redo?.(); return; } // Ctrl+G — сгруппировать выделенное в новую папку if (e.code === 'KeyG') { e.preventDefault(); this.groupSelected(); return; } // Ctrl+A — выделить всё if (e.code === 'KeyA') { e.preventDefault(); this.selection?.selectAll(); return; } } // R — повернуть ghost-модель на 90° (или выделенную модель) if (e.code === 'KeyR' && !this._isPlaying) { const sel = this.selection?.getSelection(); if (sel?.type === 'model') { const newAngle = (sel.rotationY || 0) + Math.PI / 2; this.selection.rotateSelectedModel(newAngle); } else if (this._activeTool === 'model') { this._ghostRotationY = (this._ghostRotationY + Math.PI / 2) % (Math.PI * 2); } } // Delete / Backspace — удалить выделенный if ((e.code === 'Delete' || e.code === 'Backspace') && !this._isPlaying) { // Приоритет: выбранная инструментом «Выбрать деко» декорация. if (this._decoSelection) { this._deleteSelectedDeco(); e.preventDefault(); } else if (this.selection?.getSelection()) { this.selection.deleteSelected(); e.preventDefault(); } } // Escape — снять выделение + переключиться на инструмент «Выделить» // (в режиме игры Esc обрабатывает PlayerController — выход из Play). if (e.code === 'Escape' && !this._isPlaying) { this.selection?.clear(); if (this._onEditorEscape) { try { this._onEditorEscape(); } catch (err) { /* ignore */ } } } if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) { e.preventDefault(); } }; const onKeyUp = (e) => { if (isTypingTarget(e.target)) return; this._codes.delete(e.code); if (!e.shiftKey) this._shiftDown = false; if (this._isPlaying && this.gameRuntime) { const key = this._normalizeKey(e); this.gameRuntime.routeGlobalEvent('keyup', { key, code: e.code }); } }; const onBlur = () => { this._codes.clear(); this._shiftDown = false; this._isRotating = false; this._isPanning = false; canvas.style.cursor = 'default'; }; // Регистрация: // - mousedown/move/up на CANVAS в capture-фазе. Это самое надёжное место // для перехвата мыши над сценой; наш обработчик отрабатывает первым, // до Babylon-овских стандартных listeners. // - keydown/keyup — на window (клавиатуру всегда слушаем глобально). // - wheel/contextmenu — на canvas в capture. canvas.addEventListener('mousedown', onMouseDown, true); canvas.addEventListener('wheel', onWheel, { passive: false, capture: true }); canvas.addEventListener('contextmenu', onContextMenu, true); // mousemove/mouseup на window — для drag за пределами canvas. window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); window.addEventListener('blur', onBlur); this._listeners = [ { target: canvas, type: 'mousedown', fn: onMouseDown, opts: true }, { target: canvas, type: 'wheel', fn: onWheel, opts: { capture: true } }, { target: canvas, type: 'contextmenu', fn: onContextMenu, opts: true }, { target: window, type: 'mousemove', fn: onMouseMove }, { target: window, type: 'mouseup', fn: onMouseUp }, { target: window, type: 'keydown', fn: onKeyDown }, { target: window, type: 'keyup', fn: onKeyUp }, { target: window, type: 'blur', fn: onBlur }, ]; } /** * Двигаем камеру по WASDQE — работает всегда, не требует зажатой ПКМ * (Minecraft Creative-style — удобнее чем Roblox для редактирования сцены). * ПКМ нужна только для поворота камеры. * Вызывается каждый кадр из render loop. * Используем e.code — независимо от раскладки клавиатуры. */ _updateCameraMovement() { if (this._isPlaying) return; // в режиме игры редактор-камера не движется const c = this._codes; if (c.size === 0) return; const dt = this.engine.getDeltaTime() / 1000; const speed = this.MOVE_SPEED * dt * (this._shiftDown ? this.SHIFT_MULTIPLIER : 1); const forward = this._getCameraForward(); const right = this._getCameraRight(); const worldUp = new Vector3(0, 1, 0); const move = Vector3.Zero(); if (c.has('KeyW') || c.has('ArrowUp')) move.addInPlace(forward.scale(speed)); if (c.has('KeyS') || c.has('ArrowDown')) move.addInPlace(forward.scale(-speed)); if (c.has('KeyD') || c.has('ArrowRight')) move.addInPlace(right.scale(speed)); if (c.has('KeyA') || c.has('ArrowLeft')) move.addInPlace(right.scale(-speed)); if (c.has('KeyE') || c.has('Space')) move.addInPlace(worldUp.scale(speed)); if (c.has('KeyQ')) move.addInPlace(worldUp.scale(-speed)); if (move.lengthSquared() > 0) { this.camera.position.addInPlace(move); } } /** * Единичный вектор «вперёд» камеры (с учётом её поворота). */ _getCameraForward() { const yaw = this.camera.rotation.y; const pitch = this.camera.rotation.x; return new Vector3( Math.sin(yaw) * Math.cos(pitch), -Math.sin(pitch), Math.cos(yaw) * Math.cos(pitch) ).normalize(); } /** * Единичный вектор «вправо» камеры (перпендикуляр forward в горизонтальной плоскости). */ _getCameraRight() { const yaw = this.camera.rotation.y; return new Vector3(Math.cos(yaw), 0, -Math.sin(yaw)).normalize(); } /** * Единичный вектор «вверх» относительно камеры. */ _getCameraUp() { const forward = this._getCameraForward(); const right = this._getCameraRight(); return Vector3.Cross(right, forward).normalize(); } /** * Фокус на точке: ставим камеру в 15 единицах от target по текущему направлению. * Будет использоваться для F — focus on selected. */ _focusOnTarget(target) { const offset = this._getCameraForward().scale(-15); this.camera.position = target.add(offset); this.camera.setTarget(target); } // === ИНСТРУМЕНТЫ И БЛОКИ =========================================== /** * Создать "призрачный" блок — полупрозрачный преview, показывает где * появится блок при клике. */ _createGhostBlock() { const ghost = MeshBuilder.CreateBox('ghostBlock', { size: 1.02 }, this.scene); const mat = new StandardMaterial('ghostMat', this.scene); mat.diffuseColor = new Color3(0.4, 0.9, 0.4); mat.alpha = 0.35; mat.specularColor = new Color3(0, 0, 0); mat.disableLighting = true; ghost.material = mat; ghost.isPickable = false; // raycast его игнорирует ghost.setEnabled(false); this._ghostMesh = ghost; } /** * Создать видимый маркер точки спавна — полупрозрачный жёлтый цилиндр со * светящейся вершиной. Виден только в редакторе, скрывается в Play. */ _createSpawnMarker() { // Базовый цилиндр-подставка const base = MeshBuilder.CreateCylinder( 'spawnMarkerBase', { diameterTop: 1.0, diameterBottom: 1.2, height: 0.15, tessellation: 24 }, this.scene ); const baseMat = new StandardMaterial('spawnBaseMat', this.scene); baseMat.diffuseColor = new Color3(0.95, 0.75, 0.2); baseMat.emissiveColor = new Color3(0.3, 0.2, 0); baseMat.specularColor = new Color3(0, 0, 0); baseMat.alpha = 0.85; base.material = baseMat; // Внутренний светящийся столб const beam = MeshBuilder.CreateCylinder( 'spawnMarkerBeam', { diameter: 0.4, height: 2.5, tessellation: 16 }, this.scene ); const beamMat = new StandardMaterial('spawnBeamMat', this.scene); beamMat.diffuseColor = new Color3(1, 0.9, 0.3); beamMat.emissiveColor = new Color3(1, 0.85, 0.2); beamMat.specularColor = new Color3(0, 0, 0); beamMat.alpha = 0.4; beamMat.disableLighting = true; beam.material = beamMat; beam.position.y = 1.3; // Группируем base+beam в TransformNode чтобы двигать как одно const root = new TransformNode('spawnMarker', this.scene); base.parent = root; beam.parent = root; root.position = new Vector3(this._spawnPoint.x, this._spawnPoint.y, this._spawnPoint.z); // Делаем маркер pickable, чтобы можно было кликнуть и выделить. // Метаданные для SelectionManager: { isSpawn: true }. base.isPickable = true; beam.isPickable = true; base.metadata = { isSpawn: true }; beam.metadata = { isSpawn: true }; this._spawnMarker = root; this._spawnMarkerMeshes = [base, beam]; } /** Обновить позицию визуального маркера спавна. */ _updateSpawnMarker() { if (!this._spawnMarker) return; this._spawnMarker.position.set( this._spawnPoint.x, this._spawnPoint.y, this._spawnPoint.z ); } /** Скрыть/показать маркер спавна. */ _setSpawnMarkerVisible(visible) { if (!this._spawnMarker) return; this._spawnMarker.setEnabled(visible); // КРИТИЧНО: при скрытии маркера в Play также делаем его непикаемым. // Babylon `pickWithRay` ловит меши даже при `setEnabled(false)` если // disabled у parent TransformNode. Без isPickable=false луч стрельбы // попадает в столб маркера в 5м перед игроком. if (this._spawnMarkerMeshes) { for (const m of this._spawnMarkerMeshes) { if (m) m.isPickable = visible; } } } /** * Raycast от курсора в сцену. * Возвращает { mesh, point, normal } либо null если ни во что не попали. * Игнорирует ghost-блок и линии сетки. */ /** * Нормализация клавиши из KeyboardEvent в простую строку для game.onKey. * KeyW → 'w', Space → 'space', ArrowUp → 'arrowup', ShiftLeft → 'shift', ... */ _normalizeKey(e) { const code = e.code || ''; // Буквы KeyA..KeyZ → 'a'..'z' if (/^Key[A-Z]$/.test(code)) return code.charAt(3).toLowerCase(); // Цифры Digit0..Digit9 → '0'..'9' if (/^Digit\d$/.test(code)) return code.charAt(5); // Спецклавиши const map = { Space: 'space', Enter: 'enter', NumpadEnter: 'enter', Escape: 'escape', Tab: 'tab', Backspace: 'backspace', ShiftLeft: 'shift', ShiftRight: 'shift', ControlLeft: 'ctrl', ControlRight: 'ctrl', AltLeft: 'alt', AltRight: 'alt', ArrowUp: 'arrowup', ArrowDown: 'arrowdown', ArrowLeft: 'arrowleft', ArrowRight: 'arrowright', }; if (map[code]) return map[code]; // Fallback — сам key в lower-case return String(e.key || code).toLowerCase(); } /** * Pick по центру экрана (для Play-режима где курсор залочен). * Используется для game.self.onClick — клик луч-форвард игрока. */ _pickFromCenter() { const w = this.engine?.getRenderWidth?.() || this.canvas.width; const h = this.engine?.getRenderHeight?.() || this.canvas.height; const pi = this.scene.pick(w / 2, h / 2, (mesh) => { if (!mesh.isPickable) return false; if (mesh === this._ghostMesh) return false; if (mesh.name && mesh.name.startsWith('gridLine')) return false; return true; }); if (!pi || !pi.hit) return null; let mesh = pi.pickedMesh; if (mesh?.metadata?._isBlockProto && this.blockManager) { const proxy = this.blockManager.findProxyByPickInfo(pi); if (proxy) mesh = proxy; } return { mesh, point: pi.pickedPoint, pickInfo: pi }; } /** * Извлечь target {kind, ref} из mesh (proxy/прим/модель). * Используется при клике/touch в Play. */ _meshToTarget(mesh) { if (!mesh || !mesh.metadata) return null; const md = mesh.metadata; if (md.isBlock) { return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } }; } if (md.isModel) return { kind: 'model', id: md.instanceId }; if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId }; return null; } /** * Детекция касания игроком объектов с target-скриптами. * Для каждого target-скрипта проверяем AABB-overlap с игроком. * Событие 'touch' эмитится один раз на «вход» (на rising edge) — пока * игрок не выйдет из объекта и не вернётся, повторно touch не вызывается. */ _detectTouchEvents() { const rt = this.gameRuntime; if (!rt || !this.player?._pos) return; const scripts = this._scripts || []; if (scripts.length === 0) return; // Кэш «контакта»: scriptId → true если сейчас касается if (!this._touchState) this._touchState = new Map(); const seen = new Set(); const px = this.player._pos.x; const py = this.player._pos.y; // центр капсулы const pz = this.player._pos.z; const phw = this.player.HALF_W ?? 0.3; const phh = this.player.HALF_H ?? 0.9; const phd = this.player.HALF_D ?? 0.3; // EPS — допуск касания. Когда игрок СТОИТ на объекте сверху, // низ его капсулы строго совпадает с верхом объекта (зазор 0), // и строгое сравнение AABB даёт «не пересекаются». Расширяем // зону на EPS, чтобы «стоит на объекте/вплотную» = касание. // Без этого onTouch финиша/плитки не срабатывает (игрок встал). const EPS = 0.25; // 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId) for (const s of scripts) { if (!s.target) continue; const key = 's:' + s.id; seen.add(key); const aabb = this._targetAABB(s.target); if (!aabb) continue; const overlap = px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS && py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS && pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS; const wasTouching = this._touchState.get(key); if (overlap && !wasTouching) { this._touchState.set(key, true); rt.routeEvent(s.target, 'touch', {}); rt.routeGlobalEvent('playerTouch', { target: s.target }); } else if (!overlap && wasTouching) { this._touchState.set(key, false); rt.routeEvent(s.target, 'untouch', {}); } } // 2) Касания примитивов-триггеров (type === 'trigger') БЕЗ скрипта — // шлём глобальное playerTouch с target. Это позволяет писать // логику чек-поинтов в одном глобальном скрипте без скриптов на каждом // триггере. Ключ: 'p:'+id, чтобы не пересекаться со скриптами. // Сюда же — примитивы, заспавненные скриптом (data._scriptSpawned): // для них тоже шлём playerTouch, чтобы игры «поймай объект» // могли ловить падающие кубы через game.onPlayerTouch. const prims = this.primitiveManager?.instances; if (prims && prims.size > 0) { for (const data of prims.values()) { const isTrigger = data?.type === 'trigger'; const isSpawned = data?._scriptSpawned === true; if (!isTrigger && !isSpawned) continue; const id = data.id; // Если на этот примитив УЖЕ повешен target-скрипт — он // обработан в блоке выше, чтобы не дублировать события. const hasScript = scripts.some(s => s.target?.kind === 'primitive' && (s.target.id ?? s.target.ref) === id ); if (hasScript) continue; const key = 'p:' + id; seen.add(key); const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2; const overlap = px + phw > data.x - hx - EPS && px - phw < data.x + hx + EPS && py + phh > data.y - hy - EPS && py - phh < data.y + hy + EPS && pz + phd > data.z - hz - EPS && pz - phd < data.z + hz + EPS; const wasTouching = this._touchState.get(key); if (overlap && !wasTouching) { this._touchState.set(key, true); // target — строка-ref 'primitive:': её можно // передать в game.scene.delete и сравнивать. rt.routeGlobalEvent('playerTouch', { target: 'primitive:' + id, }); } else if (!overlap && wasTouching) { this._touchState.set(key, false); } } } // 3) Касания объектов, на которые подписан ГЛОБАЛЬНЫЙ скрипт через // findOne(x).onTouch(...) (rt._watchedTouchRefs). Это объекты без // собственного скрипта-target и не триггеры — например цели туториала. // Ключ touchState = 'w:'+ref, событие — адресное (routeInstEvent по ref). const watched = rt._watchedTouchRefs; if (watched && watched.size > 0) { for (const ref of watched) { const target = this._refToTarget(ref); if (!target) continue; // Не дублируем, если на этот же объект уже висит target-скрипт // (он обработан в блоке 1 и сам отправит touch своему скрипту — // но адресное instTouch всё равно нужно глобальному подписчику, // поэтому НЕ пропускаем, просто используем отдельный ключ 'w:'). const aabb = this._targetAABB(target); if (!aabb) continue; const key = 'w:' + ref; seen.add(key); const overlap = px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS && py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS && pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS; const wasTouching = this._touchState.get(key); if (overlap && !wasTouching) { this._touchState.set(key, true); rt.routeInstEvent(ref, 'instTouch', {}); } else if (!overlap && wasTouching) { this._touchState.set(key, false); rt.routeInstEvent(ref, 'instUntouch', {}); } } } // Чистим устаревшие записи (удалённые скрипты/триггеры) for (const id of this._touchState.keys()) { if (!seen.has(id)) this._touchState.delete(id); } } /** Ref-строка 'primitive:NN' | 'model:NN' → {kind,id} для _targetAABB. */ _refToTarget(ref) { if (typeof ref !== 'string') return null; const colon = ref.indexOf(':'); if (colon < 0) return null; const kind = ref.slice(0, colon); const rest = ref.slice(colon + 1); if (kind === 'primitive') { // через runtime — корректно разрешает и local-ref, и числовой id const id = this.gameRuntime?._resolvePrimitiveId ? this.gameRuntime._resolvePrimitiveId(rest) : (Number.isFinite(Number(rest)) ? Number(rest) : rest); return { kind: 'primitive', id }; } if (kind === 'model') { const n = Number(rest); return { kind: 'model', id: Number.isFinite(n) ? n : rest }; } return null; } /** Получить мировой AABB target-объекта (для touch-детекции). */ _targetAABB(target) { if (!target) return null; try { if (target.kind === 'block') { const r = target.ref || target; return { minX: r.x - 0.5, maxX: r.x + 0.5, minY: r.y, maxY: r.y + 1, minZ: r.z - 0.5, maxZ: r.z + 0.5, }; } if (target.kind === 'model') { const id = target.id ?? target.ref; return this.modelManager?.getInstanceAABB?.(id) || null; } if (target.kind === 'primitive') { const id = target.id ?? target.ref; const data = this.primitiveManager?.instances?.get(id); if (!data) return null; const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2; return { minX: data.x - hx, maxX: data.x + hx, minY: data.y - hy, maxY: data.y + hy, minZ: data.z - hz, maxZ: data.z + hz, }; } } catch (e) { /* ignore */ } return null; } /** * Обработка клика в Play-режиме. * Делает forward-pick и роутит click-событие: * - в self-обработчики скриптов (routeEvent с target) * - в глобальные обработчики (game.onClick) с event.target */ _handlePlayClick() { if (!this._isPlaying) return; // Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу. // Используем forward-вектор игрока (XZ-плоскость) — куда смотрит, // туда и выстрел. На сервере дальше идёт raycast по другим игрокам. if (this._mpSync && this.player?._pos) { try { const yaw = this.player._yaw || 0; // forward в плоскости XZ: yaw=0 — смотрим в +Z const dirX = Math.sin(yaw); const dirZ = Math.cos(yaw); this._mpSync.sendShoot( this.player._pos.x, this.player._pos.z, dirX, dirZ, ); } catch (e) { /* room closed / mpSync disposed */ } } if (!this.gameRuntime) return; const pick = this._pickFromCenter(); const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null; const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null; // 1) Self-onClick — только если target есть if (target) { this.gameRuntime.routeEvent(target, 'click', { point }); } // 2) Глобальный onClick — всегда (даже если попали в пустоту) this.gameRuntime.routeGlobalEvent('click', { point, target }); // 3) toolUse — если в активном слоте инвентаря есть инструмент/оружие. // Для game.player.onToolUse(fn) из скриптов (Фаза 4.2). // Phase 6.4: прокидываем customToolId если это кастомный tool из game.tools.create. try { const active = this.inventory?.getActive?.(); if (active) { const tool = { kind: active.kind, modelTypeId: active.modelTypeId, name: active.name, }; if (active.params && active.params._customToolId) { tool.customToolId = active.params._customToolId; } this.gameRuntime.routeGlobalEvent('toolUse', { tool, point, target }); } } catch (e) { /* ignore */ } } /** * Установить мультиплеер-синхронизатор. Вызывается из KubikonPlayer * после joinOrCreate. При null — отключаем мультиплеер. */ /** Тач-режим — управление через виртуальный джойстик и тач-свайп камеры. * Должно вызываться ДО enterPlayMode, иначе PlayerController создастся * с дефолтным mouse/keyboard-управлением. */ setTouchMode(enabled) { this._touchMode = !!enabled; // Если уже в Play и есть player — пробрасываем if (this.player && typeof this.player.setTouchMode === 'function') { this.player.setTouchMode(this._touchMode); } // На тач-устройствах (= мобила/планшет) включаем low-perf-режим // автоматически: уменьшаем разрешение рендера, отключаем тени, // увеличиваем dt физики. Это даёт ×2-3 прирост FPS. if (this._touchMode) this.applyLowPerfMode(); } /** * Включить «лёгкий» режим рендера для слабых устройств / мобилок. * Можно вызвать вручную и на десктопе если FPS проседает. * * Главная идея: не пикселим картинку, а уменьшаем нагрузку безболезненно: * 1. DPR-нормализация: рендерим в DPR=1 (а не ×2-3 как Retina по умолчанию). * Это даёт х2-х9 буст FPS без видимой потери качества — глаз * телефонного экрана не различает разницу между DPR=2 и DPR=1. * В отличие от scalingLevel=2 (рендер в половину родного), здесь * текстуры остаются чёткими — рендерим в реальное число css-пикселей. * 2. Отключаем тени — ShadowGenerator самый дорогой эффект. * 3. skipPointerMovePicking — не делаем raycast от мыши на каждый move. * 4. maxZ=200 — урезаем дальность рендера. * * НЕ делаем (после теста — слишком ухудшало картинку и плавность): * - hardwareScalingLevel > 1 (давало пикселизацию текстур) * - FPS-cap 30 через frame-skip (давало дёрганье движения) * - ZombieManager тик через кадр (тоже дёрганье) */ applyLowPerfMode() { if (this._lowPerfApplied) return; this._lowPerfApplied = true; // НЕ трогаем hardwareScalingLevel — оставляем нативное разрешение // экрана (включая DPR). На современных телефонах GPU справляется, // а текстуры и текст ника остаются чёткими. Прирост FPS даём за // счёт отключённых теней / AA / maxZ=200, а не уменьшения буфера. // Тени — выключаем try { if (this._shadowGenerator) { this._shadowGenerator.dispose(); this._shadowGenerator = null; } this._shadowQuality = 'off'; } catch (e) { /* ignore */ } // Скип pointer-move picking — каждый кадр не делаем raycast от мыши try { this.scene.skipPointerMovePicking = true; } catch (e) {} // НЕ включаем blockMaterialDirtyMechanism — он ломает свойства // материалов трейсеров/дебриса (создаются после старта, шейдер не // пересчитывается, emissiveColor/alpha/disableLighting не применяются). try { if (this.scene) { this.scene.autoClear = true; this.scene.autoClearDepthAndStencil = true; } } catch (e) {} // Уменьшаем дальность рендера если камера далеко смотрит try { if (this.camera && this.camera.maxZ) { this.camera.maxZ = Math.min(this.camera.maxZ, 200); } } catch (e) {} // FPS-cap НЕ ставим — лучше нативные 60 FPS если устройство тянет. this._lowPerfFrameSkip = false; // eslint-disable-next-line no-console console.log('[BabylonScene] low-perf mode applied (DPR-normalized, no shadows, no frame-skip)'); } setMultiplayerSync(sync) { this._mpSync = sync; // Сразу шлём текущее активное оружие, чтобы remote-клиенты // увидели его в руке модели сразу после нашего onAdd. if (sync) { try { const active = this.inventory?.getActive?.(); const modelId = (active && active.kind === 'weapon') ? (active.modelTypeId || '') : ''; sync.sendWeapon(modelId); } catch (e) { /* ignore */ } } } _pickFromMouse() { // 1) Стандартный pick — для моделей, примитивов, пола, ghost'ов и т.п. // Блоки рисуются через thin-instances; их proto-меш ИГНОРИРУЕМ // в этом проходе (он бы вернул thin instance с потерянным индексом // в новых версиях Babylon — старая боль с выделением и постановкой). const pi = this.scene.pick( this.scene.pointerX, this.scene.pointerY, (mesh) => { if (!mesh.isPickable) return false; if (mesh === this._ghostMesh) return false; if (mesh.name && mesh.name.startsWith('gridLine')) return false; if (mesh.metadata?._isBlockProto) return false; // ⬅ важно! return true; } ); // 2) Отдельный пик блоков через свой raycast по AABB-сетке. // Гораздо надёжнее thin-instance pick'а: даём гарантированный // proxy + нормаль грани попадания. const blockHit = this._pickBlockManually(); // Выбираем ближайший: либо стандартный pick, либо блок. if (pi && pi.hit && blockHit) { // Сравниваем по дистанции от камеры const cam = this.scene.activeCamera?.position; if (cam) { const d1Sq = (pi.pickedPoint.x - cam.x) ** 2 + (pi.pickedPoint.y - cam.y) ** 2 + (pi.pickedPoint.z - cam.z) ** 2; const d2Sq = (blockHit.point.x - cam.x) ** 2 + (blockHit.point.y - cam.y) ** 2 + (blockHit.point.z - cam.z) ** 2; if (d2Sq < d1Sq) { return blockHit; } } return { mesh: pi.pickedMesh, point: pi.pickedPoint, normal: pi.getNormal(true), pickInfo: pi, }; } if (blockHit) return blockHit; if (pi && pi.hit) { return { mesh: pi.pickedMesh, point: pi.pickedPoint, normal: pi.getNormal(true), pickInfo: pi, }; } return null; } /** * Свой raycast по блокам. Идёт от камеры в направлении курсора, проходит * по сетке и проверяет каждую клетку: есть ли блок в blockManager.blocks? * Возвращает { mesh: proxy, point, normal } или null. * * Используется DDA (digital differential analyzer) — самый быстрый алгоритм * для voxel-raycast. */ _pickBlockManually() { if (!this.blockManager || !this.scene.activeCamera) return null; // Получаем ray из курсора const camera = this.scene.activeCamera; const ray = this.scene.createPickingRay( this.scene.pointerX, this.scene.pointerY, null, camera ); const origin = ray.origin; const dir = ray.direction; // DDA для voxel-сетки. // Стартуем с клетки в которой находится origin let x = Math.round(origin.x); let y = Math.floor(origin.y); let z = Math.round(origin.z); // Шаги по каждой оси const stepX = dir.x > 0 ? 1 : -1; const stepY = dir.y > 0 ? 1 : -1; const stepZ = dir.z > 0 ? 1 : -1; // Длина шага вдоль луча для перехода на следующую клетку const tDeltaX = Math.abs(dir.x) > 1e-8 ? Math.abs(1 / dir.x) : Infinity; const tDeltaY = Math.abs(dir.y) > 1e-8 ? Math.abs(1 / dir.y) : Infinity; const tDeltaZ = Math.abs(dir.z) > 1e-8 ? Math.abs(1 / dir.z) : Infinity; // Расстояние до первой границы клетки // Блок (x,y,z) занимает X: x-0.5..x+0.5, Y: y..y+1, Z: z-0.5..z+0.5 const nextBoundaryX = x + 0.5 * stepX; const nextBoundaryY = stepY > 0 ? (y + 1) : y; const nextBoundaryZ = z + 0.5 * stepZ; let tMaxX = Math.abs(dir.x) > 1e-8 ? (nextBoundaryX - origin.x) / dir.x : Infinity; let tMaxY = Math.abs(dir.y) > 1e-8 ? (nextBoundaryY - origin.y) / dir.y : Infinity; let tMaxZ = Math.abs(dir.z) > 1e-8 ? (nextBoundaryZ - origin.z) / dir.z : Infinity; const MAX_STEPS = 200; // максимум 200 клеток по лучу const MAX_DIST = 100; // и не дальше 100м // Какая ось пересечена последней (для вычисления нормали) let lastAxis = -1; for (let i = 0; i < MAX_STEPS; i++) { // Проверяем клетку (x, y, z) if (y >= 0 && y < 200) { const key = `${x},${y},${z}`; const proxy = this.blockManager.blocks.get(key); if (proxy && proxy.metadata?.canCollide !== false) { // Нашли! Вычисляем точку контакта и нормаль. let t; let nx = 0, ny = 0, nz = 0; if (lastAxis === 0) { // Зашли через X-грань t = tMaxX - tDeltaX; nx = -stepX; } else if (lastAxis === 1) { t = tMaxY - tDeltaY; ny = -stepY; } else if (lastAxis === 2) { t = tMaxZ - tDeltaZ; nz = -stepZ; } else { // Стартуем уже внутри клетки — нормаль вверх по умолчанию t = 0; ny = 1; } if (t > MAX_DIST) return null; const point = { x: origin.x + dir.x * t, y: origin.y + dir.y * t, z: origin.z + dir.z * t, }; return { mesh: proxy, point: { x: point.x, y: point.y, z: point.z, clone() { return { x: this.x, y: this.y, z: this.z }; } }, normal: { x: nx, y: ny, z: nz }, pickInfo: null, }; } } // Шаг по ближайшей оси if (tMaxX < tMaxY && tMaxX < tMaxZ) { if (tMaxX > MAX_DIST) return null; x += stepX; tMaxX += tDeltaX; lastAxis = 0; } else if (tMaxY < tMaxZ) { if (tMaxY > MAX_DIST) return null; y += stepY; tMaxY += tDeltaY; lastAxis = 1; } else { if (tMaxZ > MAX_DIST) return null; z += stepZ; tMaxZ += tDeltaZ; lastAxis = 2; } } return null; } /** * Обновить позицию ghost-блока под курсором. * Вызывается каждый кадр когда tool='block'. */ _updateGhostPosition() { if (!this._ghostMesh) return; if (this._isPlaying) { this._ghostMesh.setEnabled(false); return; } if (this._activeTool !== 'block' && this._activeTool !== 'model') { this._ghostMesh.setEnabled(false); return; } const pick = this._pickFromMouse(); if (!pick) { this._ghostMesh.setEnabled(false); return; } const target = this._computePlacementCell(pick); if (!target) { this._ghostMesh.setEnabled(false); return; } // Не показываем ghost если в этой клетке уже блок (только для tool=block) if (this._activeTool === 'block' && this.blockManager?.hasBlock(target.x, target.y, target.z)) { this._ghostMesh.setEnabled(false); return; } this._ghostMesh.position = new Vector3(target.x, target.y + 0.5, target.z); // Для модели — отображаем угол поворота через rotation (визуальная подсказка) if (this._activeTool === 'model') { this._ghostMesh.rotation.y = this._ghostRotationY; } else { this._ghostMesh.rotation.y = 0; } this._ghostMesh.setEnabled(true); } /** * Высчитать целочисленную клетку (gridX, gridY, gridZ) куда ставить блок. * Координаты — это нижний-передний-левый угол клетки (блок занимает * (gridX..gridX+1, gridY..gridY+1, gridZ..gridZ+1)). * * Попали в блок: новая клетка = соседняя по нормали грани. * Попали в пол: новая клетка = (round(p.x - 0.5), 0, round(p.z - 0.5)). * * Почему -0.5: точка p.x на полу — это координата в мире (0..40). Сетка * целочисленная: блок «(0,0,0)» занимает (-0.5..0.5, 0..1, -0.5..0.5). * Чтобы клик точно в центр клетки попал в (0,0,0), нужно округление * без сдвига. Math.round(0.4) = 0, Math.round(0.6) = 1 — правильно. */ _computePlacementCell(pick) { const p = pick.point; const n = pick.normal || new Vector3(0, 1, 0); const mesh = pick.mesh; if (mesh?.metadata?.isBlock) { // Соседняя клетка по нормали грани, в которую попали const m = mesh.metadata; const nx = Math.round(n.x); const ny = Math.round(n.y); const nz = Math.round(n.z); const cell = { x: m.gridX + nx, y: m.gridY + ny, z: m.gridZ + nz, }; if (cell.y < 0) return null; return cell; } // Попали в ТЕРРЕЙН (воксельный region-mesh или гладкий roblox-terrain). // У этих мешей нет metadata.isBlock, но есть свои метки. Берём // РЕАЛЬНУЮ точку пересечения луча (p.y) — это высота поверхности // там, куда кликнули. Без этого модель вставала на y=0 (baseplate). const md = mesh?.metadata; const isTerrain = md && (md._isTerrainProto || md._isRegionMesh || md._isRobloxTerrain); if (isTerrain) { return { x: Math.round(p.x), y: p.y, // реальная высота поверхности под курсором z: Math.round(p.z), }; } // Попали в пол / прочее. Точка p — мировая. Блок «(ix,iy,iz)» имеет // центр на (ix, iy+0.5, iz), его горизонтальные грани (x,z) от (ix-0.5) // до (ix+0.5). Поэтому простое Math.round(p.x) даёт верный gridX. const x = Math.round(p.x); const z = Math.round(p.z); // Если попали в верхнюю грань пола → ставим на y=0. // Если попали под низ пола (камера ниже сцены) → не ставим. if (n.y < 0.5) return null; return { x, y: 0, z }; } /** * Обработать клик мыши (вызывается из mouseup если это был клик, не drag). * tool: 'block' / 'model' / 'erase' / 'select'. */ _handleEditorClick(shiftKey, ctrlKey = false) { if (this._isPlaying) return; if (!this.blockManager) return; const pick = this._pickFromMouse(); if (!pick) { if (this._activeTool === 'select' && !ctrlKey) { this.selection?.clear(); } return; } const tool = shiftKey ? 'erase' : this._activeTool; if (tool === 'select') { if (this.selection) { // Для надёжности: если pick.mesh почему-то остался прото-мешем // (без metadata.isBlock), пробуем разрезолвить через // findProxyByPickInfo ещё раз. let selectMesh = pick.mesh; if (selectMesh?.metadata?._isBlockProto && this.blockManager) { const proxy = this.blockManager.findProxyByPickInfo(pick.pickInfo); if (proxy) selectMesh = proxy; } if (ctrlKey) { this.selection.toggleMeshSelection(selectMesh); } else { this.selection.selectByMesh(selectMesh); } } } else if (tool === 'block') { const target = this._computePlacementCell(pick); if (!target) return; // Блоки живут в целочисленной сетке. Если кликнули по террейну, // _computePlacementCell вернёт нецелый y (реальная высота // поверхности) — округляем, чтобы блок встал ровно в клетку. const by = Math.round(target.y); const mesh = this.blockManager.addBlock(target.x, by, target.z, this._activeBlockType); this._lastPlacedKey = `${target.x},${by},${target.z}`; // Авто-выделение поставленного блока. Тени уже работают через proto-меш // (зарегистрирован в refreshAllShadows и обновляется автоматически). if (mesh) { this.selection?.selectBlockAt(target.x, by, target.z); if (this._onPostPlace) this._onPostPlace(); } } else if (tool === 'model') { if (!this._activeModelType) return; const cell = this._computePlacementCell(pick); if (!cell) return; // Пользовательская voxel-модель (id 'user:') — отдельный путь. if (typeof this._activeModelType === 'string' && this._activeModelType.startsWith(USER_MODEL_PREFIX)) { this.userModelManager.addInstance( this._activeModelType, cell.x, cell.y, cell.z, this._ghostRotationY, { currentUserId: this._currentUserId || null }, ).then(instId => { if (instId != null) { const data = this.userModelManager.instances.get(instId); if (data?.meshes) { for (const m of data.meshes) this.addShadowCaster(m); } // Регистрируем коллайдер this._syncUserModelColliders(); if (this._onPostPlace) this._onPostPlace(); try { this._onSceneChange?.(); } catch (e) {} // Инкремент uses_count — fire-and-forget const numericId = parseUserModelId(this._activeModelType); if (numericId != null && this._userModelsApi?.incrementModelUses) { this._userModelsApi.incrementModelUses(numericId) .catch(() => {}); } } }); return; } // addInstance модели — async, ждём id и выделяем Promise.resolve(this.modelManager.addInstance( this._activeModelType, cell.x, cell.y, cell.z, this._ghostRotationY )).then(instId => { if (instId != null) { const data = this.modelManager.instances.get(instId); if (data?.rootMesh && typeof data.rootMesh.getChildMeshes === 'function') { // rootMesh — TransformNode, пропускаем его и берём только меши for (const cm of data.rootMesh.getChildMeshes()) this.addShadowCaster(cm); } this.selection?.selectModelByInstanceId(instId); if (this._onPostPlace) this._onPostPlace(); } }); } else if (tool === 'primitive') { if (!this._activePrimitiveType) return; const cell = this._computePlacementCell(pick); if (!cell) return; // Примитив ставим так чтобы его НИЖНЯЯ грань была на клетке. // Для куба/цилиндра/конуса pivot — центр, поэтому добавляем halfHeight. const def = getPrimitiveType(this._activePrimitiveType); const halfH = (def.defaultScale.y) / 2; const newId = this.primitiveManager.addInstance(this._activePrimitiveType, { x: cell.x, y: cell.y + halfH, z: cell.z, }); // Авто-выделение поставленного примитива if (newId != null) { const data = this.primitiveManager.instances.get(newId); if (data?.mesh) this.addShadowCaster(data.mesh); this.selection?.selectPrimitiveById(newId); if (this._onPostPlace) this._onPostPlace(); } } else if (tool === 'erase') { if (pick.mesh?.metadata?.isBlock) { this.blockManager.removeBlockByMesh(pick.mesh); } else if (pick.mesh?.metadata?.isModel) { this.modelManager.removeInstanceByMesh(pick.mesh); } else if (pick.mesh?.metadata?.isPrimitive) { this.primitiveManager.removeInstanceByMesh(pick.mesh); } } } /** * Drag-постановка/удаление блоков. Вызывается на mousemove когда ЛКМ * удерживается и активен tool=block/erase. * * Чтобы блоки не «лезли на игрока» при ведении мышью по сцене, фиксируем * плоскость первого блока (X/Y/Z в зависимости от грани попадания): * - Кликнул на пол / верх блока → drag по горизонтали (фиксируем Y) * - Кликнул на боковую грань (по X) → drag по вертикальной плоскости (фиксируем X) * - и т.д. * * isFirst=true — это первый клик drag'а, запоминаем ось фиксации. */ _dragPlaceTick(shiftKey, isFirst = false) { if (this._isPlaying || !this.blockManager) return; const tool = shiftKey ? 'erase' : this._activeTool; if (tool !== 'block' && tool !== 'erase') return; const pick = this._pickFromMouse(); if (!pick) return; if (tool === 'block') { const target = this._computePlacementCell(pick); if (!target) return; // Первый клик — запоминаем ось фиксации по нормали попадания if (isFirst) { const n = pick.normal; if (Math.abs(n.y) > 0.5) { this._dragLockAxis = 'y'; this._dragLockValue = target.y; } else if (Math.abs(n.x) > 0.5) { this._dragLockAxis = 'x'; this._dragLockValue = target.x; } else if (Math.abs(n.z) > 0.5) { this._dragLockAxis = 'z'; this._dragLockValue = target.z; } else { this._dragLockAxis = null; } } else if (this._dragLockAxis) { // На последующих движениях — переопределяем target в плоскости if (target[this._dragLockAxis] !== this._dragLockValue) { // Курсор ушёл с зафиксированной плоскости. Пересчитываем // через raycast на полу/блоке, но с принудительной координатой. target[this._dragLockAxis] = this._dragLockValue; } } const key = `${target.x},${target.y},${target.z}`; if (key === this._lastPlacedKey) return; if (this.blockManager.hasBlock(target.x, target.y, target.z)) return; this.blockManager.addBlock(target.x, target.y, target.z, this._activeBlockType); this._lastPlacedKey = key; } else if (tool === 'erase') { if (pick.mesh?.metadata?.isBlock) { const m = pick.mesh.metadata; const key = `${m.gridX},${m.gridY},${m.gridZ}`; if (key === this._lastPlacedKey) return; this.blockManager.removeBlockByMesh(pick.mesh); this._lastPlacedKey = key; } } } /** * Обновить гизмо под текущее выделение. */ _updateGizmoForSelection(sel) { if (!this._gizmo) return; if (!sel) { this._gizmo.attachTo(null); return; } if (sel.type === 'block') { this._gizmo.attachTo(sel.mesh); } else if (sel.type === 'model' || sel.type === 'spawn' || sel.type === 'userModel') { this._gizmo.attachTo(sel.rootMesh); } else if (sel.type === 'primitive') { this._gizmo.attachTo(sel.mesh); } // Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale) // гарантированно пересоздалась поверх нового attached-mesh. // Без этого гизмо иногда оказывается привязанным к старому или null // объекту и стрелки становятся «неактивными». this._gizmo.refreshMode(); } /** * Гизмо манипулировал объектом — синхронизируем через SelectionManager. * Тип операции (move/rotate/scale) определяется по режиму гизмо. */ _onGizmoDragEnd() { if (!this.selection || !this._gizmo) return; const sel = this.selection.getSelection(); if (!sel) return; const mode = this._gizmo.getMode(); if (sel.type === 'block') { if (mode === 'move') { // Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ) const newX = Math.round(sel.mesh.position.x); const newY = Math.round(sel.mesh.position.y - 0.5); const newZ = Math.round(sel.mesh.position.z); if (newX === sel.gridX && newY === sel.gridY && newZ === sel.gridZ) { sel.mesh.position.set(sel.gridX, sel.gridY + 0.5, sel.gridZ); return; } if (this.blockManager.hasBlock(newX, newY, newZ)) { sel.mesh.position.set(sel.gridX, sel.gridY + 0.5, sel.gridZ); return; } this.selection.moveSelectedBlock(newX, newY, newZ); } // Блоки не поворачиваем и не масштабируем (по дизайну voxel-сцены). // Если пользователь дёрнул rotate/scale — игнорируем. } else if (sel.type === 'model') { const root = sel.rootMesh; if (mode === 'move') { this.selection.moveSelectedModel(root.position.x, root.position.y, root.position.z); } else if (mode === 'rotate') { this.selection.rotateSelectedModel(root.rotation.y); } else if (mode === 'scale') { // Берём средний масштаб (для равномерного скейла) const avg = (root.scaling.x + root.scaling.y + root.scaling.z) / 3; this.selection.scaleSelectedModel(avg); } } else if (sel.type === 'userModel') { const root = sel.rootMesh; if (mode === 'move') { this.selection.moveSelectedUserModel(root.position.x, root.position.y, root.position.z); } else if (mode === 'rotate') { this.selection.rotateSelectedUserModel(root.rotation.y); } else if (mode === 'scale') { const avg = (root.scaling.x + root.scaling.y + root.scaling.z) / 3; this.selection.scaleSelectedUserModel(avg); } } else if (sel.type === 'spawn') { const root = sel.rootMesh; if (mode === 'move') { this.selection.moveSelectedSpawn(root.position.x, root.position.y, root.position.z); } } else if (sel.type === 'primitive') { const root = sel.mesh; if (mode === 'move') { this.selection.moveSelectedPrimitive(root.position.x, root.position.y, root.position.z); } else if (mode === 'rotate') { // Сохраняем поворот в data → попадёт в serialize при save. this.primitiveManager?.updateInstance(sel.id, { rotationX: root.rotation.x, rotationY: root.rotation.y, rotationZ: root.rotation.z, }); } else if (mode === 'scale') { // Снимаем scaling до пересоздания (после _recreateMesh старый mesh dispose'ится). const newSx = sel.sx * root.scaling.x; const newSy = sel.sy * root.scaling.y; const newSz = sel.sz * root.scaling.z; root.scaling.set(1, 1, 1); this.selection.resizeSelectedPrimitive(newSx, newSy, newSz); // resizeSelectedPrimitive уже обновил sel.mesh на новый и // вызвал _highlightMesh. Перепривязываем гизмо к новому mesh. const updatedSel = this.selection.getSelection(); if (updatedSel?.mesh) { this._gizmo.attachTo(updatedSel.mesh); } } } } /** Публичный сеттер: переключить инструмент извне (из React-компонента). */ setActiveTool(toolName) { this._activeTool = toolName; if (this._ghostMesh) { this._ghostMesh.setEnabled(toolName === 'block'); } // Preview-кисть террейна. Если меш ещё не создан — создадим лениво // при первом setTerrainBrush (это произойдёт в TerrainPanel useEffect). if (toolName === 'terrain') { if (!this._terrainBrushPreview && this._terrainBrush) { this._updateTerrainBrushPreview(); } if (this._terrainBrushPreview) { this._terrainBrushPreview.setEnabled(true); } } else if (this._terrainBrushPreview) { this._terrainBrushPreview.setEnabled(false); } } /** * Обновить состояние кисти ландшафта из TerrainPanel. * Принимает частичный объект — то что не задано, не меняется. */ setTerrainBrush(patch) { if (!this._terrainBrush) return; const prevTool = this._terrainBrush.tool; Object.assign(this._terrainBrush, patch || {}); this._updateTerrainBrushPreview(); // Инструмент «Выбрать деко»: включаем пикинг thin-instance декораций, // при уходе с инструмента — выключаем и снимаем подсветку. const nowPick = this._terrainBrush.tool === 'pickDeco'; const wasPick = prevTool === 'pickDeco'; if (nowPick !== wasPick) { if (this._smoothDecoManager?.setPickingEnabled) { this._smoothDecoManager.setPickingEnabled(nowPick); } if (!nowPick) this._clearDecoSelection(); } } /** Снять подсветку выбранной декорации (маркер-сфера). */ _clearDecoSelection() { if (this._decoSelMarker) { try { this._decoSelMarker.dispose(); } catch (e) {} this._decoSelMarker = null; } this._decoSelection = null; } /** * Клик инструментом «Выбрать деко»: raycast по thin-instance декорациям, * подсветка выбранного дерева/куста маркером. Удаление — по Del * (обрабатывается в _deleteSelectedDeco). */ _pickDecoTick() { const dm = this._smoothDecoManager; if (!dm) return; const pick = this.scene.pick(this.scene.pointerX, this.scene.pointerY, (m) => m.isPickable && m.name && m.name.startsWith('__smoothDeco_')); if (!pick || !pick.hit || !pick.pickedMesh) { this._clearDecoSelection(); return; } const thinIdx = pick.thinInstanceIndex; const found = dm.findInstanceByPick(pick.pickedMesh, thinIdx); if (!found) { this._clearDecoSelection(); return; } this._decoSelection = found; // Маркер-подсветка: жёлтая полупрозрачная сфера над выбранным деко. if (this._decoSelMarker) { try { this._decoSelMarker.dispose(); } catch (e) {} } const marker = MeshBuilder.CreateSphere('__decoSelMarker', { diameter: 3, segments: 10 }, this.scene); const mat = new StandardMaterial('__decoSelMarkerMat', this.scene); mat.emissiveColor = new Color3(1, 0.85, 0.1); mat.alpha = 0.35; mat.disableLighting = true; marker.material = mat; marker.isPickable = false; marker.position.set(found.x, found.y + 2, found.z); this._decoSelMarker = marker; console.log(`[pickDeco] выбрана ${found.decoKey} @ (${found.x.toFixed(1)},${found.z.toFixed(1)})`); } /** Удалить выбранную инструментом «Выбрать деко» декорацию (вызов по Del). */ _deleteSelectedDeco() { if (!this._decoSelection || !this._smoothDecoManager) return false; const { decoKey, fullIndex } = this._decoSelection; const ok = this._smoothDecoManager.removeInstanceAt(decoKey, fullIndex); if (ok) { // Пересинхронизировать tree-collider'ы (вдруг удалили дерево) if (this.physics?.setSmoothDecoTrees) { this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders()); } this._clearDecoSelection(); try { this._onSceneChange?.(); } catch (e) {} } return ok; } /** * Обновить geometry preview-меша кисти (полупрозрачная сфера/куб * по форме кисти, размер 2*radius). Цвет берётся из текущего материала. */ _updateTerrainBrushPreview() { if (!this._terrainBrush) return; const { brushSize, shape, material } = this._terrainBrush; const r = Math.max(1, brushSize); // Берём цвет напрямую из палитры — стабильный hex, не зависит от того // загружена ли уже текстура. Превью кисти просто тонируется в основной // цвет выбранного материала. let matCol = null; try { const def = TERRAIN_MATERIAL_DEFS?.[material]; if (def?.color) matCol = Color3.FromHexString(def.color); } catch (e) {} // Удаляем старый preview если форма/размер/режим изменились if (this._terrainBrushPreview) { const md = this._terrainBrushPreview.metadata || {}; const curMode = this._terrainBrush?.terrainMode || 'voxel'; if (md.shape !== shape || md.radius !== r || md.terrainMode !== curMode) { try { this._terrainBrushPreview.dispose(); } catch (e) {} this._terrainBrushPreview = null; } } if (!this._terrainBrushPreview) { // Размер кисти в МИРОВЫХ единицах: // voxel-режим: r (voxel) × VOXEL_SIZE × 2 + 1 voxel (центр) // smooth-режим: r — это РАДИУС В МЕТРАХ, диаметр = r*2 const isSmooth = this._terrainBrush?.terrainMode === 'smooth'; const worldDiameter = isSmooth ? r * 2 : (r * 2 + 1) * TERRAIN_VOXEL_SIZE; let mesh; if (shape === 'cube') { mesh = MeshBuilder.CreateBox('__terrainBrushPreview', { size: worldDiameter }, this.scene); } else if (shape === 'cylinder') { mesh = MeshBuilder.CreateCylinder('__terrainBrushPreview', { height: worldDiameter, diameter: worldDiameter }, this.scene); } else { mesh = MeshBuilder.CreateSphere('__terrainBrushPreview', { diameter: worldDiameter, segments: 16 }, this.scene); } const mat = new StandardMaterial('__terrainBrushPreviewMat', this.scene); mat.emissiveColor = matCol || new Color3(0.2, 0.7, 0.3); mat.diffuseColor = new Color3(0, 0, 0); mat.alpha = 0.22; mat.disableLighting = false; mat.backFaceCulling = false; mesh.material = mat; mesh.isPickable = false; mesh.metadata = { _isTerrainBrushPreview: true, shape, radius: r, terrainMode: this._terrainBrush?.terrainMode || 'voxel', _baseAlpha: 0.22, _activeAlpha: 0.45, }; mesh.setEnabled(this._activeTool === 'terrain'); this._terrainBrushPreview = mesh; } else { // Обновляем цвет if (matCol) { try { this._terrainBrushPreview.material.emissiveColor = matCol; } catch (e) {} } } } /** * Вычислить точку клика для кисти террейна. Возвращает {x, y, z} в * voxel-координатах (целые) или null. * * Логика: * 1. Если попали по существующему voxel'у — берём клетку или соседнюю * по нормали (в зависимости от инструмента). * 2. Иначе делаем raycast на плоскость y=0 (пол сцены) — это даёт * площадку «где начнётся ландшафт». */ _pickTerrainCell(forNewVoxel) { const pi = this.scene.pick( this.scene.pointerX, this.scene.pointerY, (mesh) => { if (!mesh.isPickable) return false; if (mesh === this._ghostMesh) return false; if (mesh === this._terrainBrushPreview) return false; if (mesh.metadata?._isBlockProto) return false; if (mesh.metadata?._isTerrainProto) return false; if (mesh.name && mesh.name.startsWith('gridLine')) return false; return true; } ); // Сначала — попытка raycast по существующему террейну (DDA) const camera = this.scene.activeCamera; const ray = this.scene.createPickingRay( this.scene.pointerX, this.scene.pointerY, null, camera, false, ); const tHit = this.terrainManager?.pickVoxelByRay(ray.origin, ray.direction, 200); if (tHit) { if (forNewVoxel) { return { x: tHit.cell.x + tHit.normal.x, y: tHit.cell.y + tHit.normal.y, z: tHit.cell.z + tHit.normal.z, }; } return tHit.cell; } // Иначе — попадание по обычным мешам (пол/блок). Делим мировые // координаты на TERRAIN_VOXEL_SIZE чтобы получить voxel-индекс. if (pi?.hit && pi.pickedPoint) { const p = pi.pickedPoint; const n = pi.getNormal?.(true) || { x: 0, y: 1, z: 0 }; // Идём на 0.001 внутрь по противоположной нормали — чтобы координата // упала в правильный voxel. const inside = { x: p.x - n.x * 0.001, y: p.y - n.y * 0.001, z: p.z - n.z * 0.001, }; const S = TERRAIN_VOXEL_SIZE; const baseX = Math.floor(inside.x / S); const baseY = Math.floor(inside.y / S); const baseZ = Math.floor(inside.z / S); if (forNewVoxel) { return { x: baseX + Math.round(n.x), y: baseY + Math.round(n.y), z: baseZ + Math.round(n.z), }; } return { x: baseX, y: baseY, z: baseZ }; } // Fallback — raycast на плоскость y=0. Преобразуем мировые // координаты в voxel-индексы делением на TERRAIN_VOXEL_SIZE. if (Math.abs(ray.direction.y) > 1e-4) { const t = -ray.origin.y / ray.direction.y; if (t > 0 && t < 200) { const hx = ray.origin.x + ray.direction.x * t; const hz = ray.origin.z + ray.direction.z * t; const S = TERRAIN_VOXEL_SIZE; const cx = Math.floor(hx / S); const cz = Math.floor(hz / S); // Прилипание к верху столбца: если в этом столбце уже // есть voxel'ы террейна, используем Y верхнего + 1. // Это убирает «прыжок» кисти когда юзер начинает рисовать // на пол рядом с уже существующим холмом — она остаётся // на уровне его поверхности. let topY = -1; if (this.terrainManager) { const found = this.terrainManager._findTopY?.(cx, cz, 200, -50); if (found !== null && found !== undefined) topY = found; } return { x: cx, y: forNewVoxel && topY >= 0 ? topY + 1 : (topY >= 0 ? topY : 0), z: cz, }; } } return null; } /** * Один «тик» кисти террейна. Вызывается при mousedown и mousemove с зажатой * ЛКМ когда activeTool === 'terrain'. * * shiftKey — модификатор «обратное действие» (стереть/опустить). */ _terrainBrushTick(shiftKey, isFirst) { if (this._isPlaying) return; if (this._activeTool !== 'terrain') return; // === Smooth-режим: редактируем DensityGrid через SmoothBrushes === if (this._terrainBrush?.terrainMode === 'smooth') { this._smoothBrushTick(shiftKey, isFirst); return; } if (!this.terrainManager) return; const tool = this._terrainBrush?.tool || 'draw'; // === Rate-limit voxel-кисти === // mousemove приходит ~100Hz, каждый тик brushDraw/sculpt с radius=16 // = ~17000 thinInstanceAdd. Даже с GPU-batch это съедает кадр. // Ограничиваем тики до ~25 Hz (40ms) — кисть всё равно плавно // покрывает поверхность за счёт drag по экрану. if (!isFirst) { const now = performance.now(); const last = this._voxelBrushLastTick || 0; const radius = this._terrainBrush?.brushSize || 4; // Чем больше кисть, тем реже тики (защита от лагов). const minInterval = radius <= 4 ? 30 : radius <= 8 ? 50 : radius <= 16 ? 80 : 120; if (now - last < minInterval) return; this._voxelBrushLastTick = now; } else { this._voxelBrushLastTick = performance.now(); } // === Plant-кисти voxel-режима: размещение мини-воксельных моделей === // plantGrass / plantFlower / plantMushroom / plantTree. // Shift = стереть декорации в зоне. if (tool === 'plantGrass' || tool === 'plantFlower' || tool === 'plantMushroom' || tool === 'plantTree') { // При новом клике сбрасываем rate-limit pos, чтобы первый клик // в той же точке всегда срабатывал. if (isFirst) { this._voxelTreeLastPos = null; this._voxelPlantLastPos = null; } const cell = this._pickTerrainCell(true); if (!cell) return; const brush = { x: cell.x, y: cell.y, z: cell.z, radius: this._terrainBrush.brushSize || 4, shape: this._terrainBrush.shape || 'sphere', strength: this._terrainBrush.strength ?? 50, }; if (shiftKey) { this._eraseDecorationsInBrush(brush); } else { this._placeVoxelPlantsAtBrush(brush, tool); } return; } // Для перекраски/выровнять — берём клетку с поверхности (не «над») // Для рисования — клетку «над» (по нормали) const wantsAdjacent = (tool === 'draw' || tool === 'sculpt'); const cell = this._pickTerrainCell(wantsAdjacent); if (!cell) return; // На скульпте и выровнять — фиксируем Y первой точки, чтобы при drag // не перепрыгивать на разные слои каждым движением мыши. if (isFirst) { this._terrainDragLockY = cell.y; } const brush = { x: cell.x, y: (tool === 'flatten' || tool === 'sculpt' || tool === 'smooth') ? this._terrainDragLockY ?? cell.y : cell.y, z: cell.z, radius: this._terrainBrush.brushSize || 4, shape: this._terrainBrush.shape || 'sphere', }; const matId = this._terrainBrush.material || 'grass'; const strength = this._terrainBrush.strength ?? 50; if (shiftKey) { // Shift = обратная кисть. Для sculpt — опускает (Sculpt Down). // Для всех остальных — стирает (voxels + декорации в зоне). if (tool === 'sculpt') { this.terrainManager.brushSculpt(brush, -1, matId, strength); } else { this.terrainManager.brushErase(brush); this._eraseDecorationsInBrush(brush); } return; } // === Деко-материалы → мини-воксельные модели === // Если выбран материал-декорация (трава/цветы/грибы/листья) — кисть // ставит МОДЕЛЬ из DecoModels, а не плоский voxel. Это для tool=draw, // sculpt (рисование) — где пользователь "красит" декорациями. // smooth/paint/flatten — стандартный voxel-rendering. if (this._isDecoMaterial(matId) && (tool === 'draw' || tool === 'sculpt')) { this._placeDecoModelsAtBrush(brush, matId); return; } // Если выбран деко-материал но инструмент НЕ ставит модели (smooth/paint/flatten), // нельзя засыпать столбцы декорациями — fallback на 'grass'. const safeMatId = this._isDecoMaterial(matId) ? 'grass' : matId; switch (tool) { case 'draw': this.terrainManager.brushDraw(brush, matId); break; case 'sculpt': this.terrainManager.brushSculpt(brush, +1, matId, strength); break; case 'smooth': // Smooth работает БЕЗ выбранного материала: засыпка идёт // тем материалом, что уже есть у соседних solid voxels. this.terrainManager.brushSmooth(brush, null); break; case 'paint': this.terrainManager.brushPaint(brush, safeMatId); break; case 'flatten': this.terrainManager.brushFlatten(brush, safeMatId); break; case 'erase': // Стираем И voxels И декорации в зоне кисти. this.terrainManager.brushErase(brush); this._eraseDecorationsInBrush(brush); break; default: break; } } /** Переключение «активного» состояния preview-меша кисти террейна. * active=true делает кисть ярче (alpha 0.45 vs 0.22) — пока зажата ЛКМ. */ _setTerrainBrushPreviewActive(active) { const m = this._terrainBrushPreview; if (!m || !m.material) return; try { const meta = m.metadata || {}; m.material.alpha = active ? (meta._activeAlpha || 0.45) : (meta._baseAlpha || 0.22); } catch (e) {} } // ======================================================================== // Undo / Redo для террейна // // Хранится стек снапшотов всего террейна (массив serialize'данных). // Один drag-мазок кистью = один снапшот. История ограничена 30 шагами // (чтобы не съесть RAM при больших террейнах). // // Использование: // _terrainHistoryOpen() — перед началом мазка (mousedown по террейну) // _terrainHistoryClose() — после конца мазка (mouseup): если что-то // изменилось, фиксируем «открытый» снапшот // undoTerrain() / redoTerrain() — горячие клавиши Ctrl+Z / Ctrl+Y // ======================================================================== /** * Маппинг "деко-материалов" (выбираемых в палитре voxel-режима) на * `modelId` из DECO_MODELS. Если material есть в этой карте — кисть * ставит МОДЕЛЬ через DecoManager, а не плоский voxel. */ _decoMaterialToModels(matId) { switch (matId) { case 'tall_grass': // Случайная модель травы из pool — каждый клик ставит разные return GRASS_MODELS_POOL; case 'flower_red': return ['poppy']; case 'flower_blue': return ['cornflower']; case 'flower_yellow': return ['dandelion', 'daisy']; case 'mushroom_red': return ['fly_mushroom']; // Эти материалы можно тоже спрятать под деко если хочется, // но пока оставляем как voxels (они нужны для деревьев и т.п.) // case 'leaves': case 'leaves_orange': case 'rock_moss': case 'trunk': default: return null; } } /** True если material — декорация (ставится моделью). */ _isDecoMaterial(matId) { return this._decoMaterialToModels(matId) !== null; } /** * Поставить мини-воксельные модели в зоне кисти (sphere/cube). * Плотность — 30% точек grid в радиусе → штук 5-15 на клик. */ _placeDecoModelsAtBrush(brush, matId) { if (!this.decoManager) return; const models = this._decoMaterialToModels(matId); if (!models || models.length === 0) return; const TERRAIN_VOXEL = 0.25; const r = brush.radius; // brush.x/y/z в voxel-индексах террейна (cells 0.25м). // Мировые координаты центра brush в МЕТРАХ. const cx = (brush.x + 0.5) * TERRAIN_VOXEL; const cz = (brush.z + 0.5) * TERRAIN_VOXEL; // Top-surface Y: ищем верх solid voxels под brush.x,brush.z. // Если на этой колонне нет voxel — ставим прямо на baseplate y=0. let topVoxY = brush.y; // count = ~ 8 случайных позиций const COUNT = 10; const placedKeys = new Set(); for (let i = 0; i < COUNT; i++) { // Случайная точка в круге radius (в voxel-units) const angle = Math.random() * Math.PI * 2; const rr = Math.sqrt(Math.random()) * r; const vx = brush.x + Math.cos(angle) * rr; const vz = brush.z + Math.sin(angle) * rr; const worldX = (vx + 0.5) * TERRAIN_VOXEL; const worldZ = (vz + 0.5) * TERRAIN_VOXEL; // Y: top surface — используем brush.y + 1 voxel (над поверхностью) const worldY = (topVoxY + 1) * TERRAIN_VOXEL; // Защита от дублирования: не ставим 2 модели в одну сетку 0.5м const key = `${Math.floor(worldX*2)},${Math.floor(worldZ*2)}`; if (placedKeys.has(key)) continue; placedKeys.add(key); // Случайная модель из набора const modelId = models[Math.floor(Math.random() * models.length)]; const rotation = Math.random() * Math.PI * 2; const scale = 0.9 + Math.random() * 0.3; this.decoManager.placeModel(worldX, worldY, worldZ, modelId, rotation, scale); } try { this._onSceneChange?.(); } catch (e) {} } /** * Поставить декорации plant-кистью voxel-режима по типу инструмента. * tool: plantGrass | plantFlower | plantMushroom | plantTree. * Trees — из voxel-блоков (trunk + leaves), остальные — мини-модели. */ _placeVoxelPlantsAtBrush(brush, tool) { if (tool === 'plantTree') { this._placeVoxelTreesAtBrush(brush); return; } // Подбираем пул моделей по типу инструмента let models; let countMul; // множитель плотности на тик (как в smooth: grass=густо, flower=средне) switch (tool) { case 'plantGrass': models = GRASS_MODELS_POOL; countMul = 1.5; break; case 'plantFlower': models = ['daisy', 'cornflower', 'poppy', 'dandelion']; countMul = 1.0; break; case 'plantMushroom': models = ['fly_mushroom', 'brown_mushroom']; countMul = 0.5; break; default: return; } if (!this.decoManager || !models || models.length === 0) return; // Rate-limit между тиками: не ставим если кисть не сдвинулась. const r = brush.radius; const minDist = Math.max(1, r * 0.3); const minDist2 = minDist * minDist; if (this._voxelPlantLastPos) { const dx = brush.x - this._voxelPlantLastPos.x; const dz = brush.z - this._voxelPlantLastPos.z; if (dx * dx + dz * dz < minDist2) return; } this._voxelPlantLastPos = { x: brush.x, z: brush.z }; const TERRAIN_VOXEL = 0.25; const topVoxY = brush.y; // Кол-во точек пропорционально радиусу и типу декорации. const COUNT = Math.max(2, Math.min(16, Math.round(r * countMul))); const placedKeys = new Set(); for (let i = 0; i < COUNT; i++) { const angle = Math.random() * Math.PI * 2; const rr = Math.sqrt(Math.random()) * r; const vx = brush.x + Math.cos(angle) * rr; const vz = brush.z + Math.sin(angle) * rr; const worldX = (vx + 0.5) * TERRAIN_VOXEL; const worldZ = (vz + 0.5) * TERRAIN_VOXEL; const worldY = (topVoxY + 1) * TERRAIN_VOXEL; const key = `${Math.floor(worldX*2)},${Math.floor(worldZ*2)}`; if (placedKeys.has(key)) continue; placedKeys.add(key); const modelId = models[Math.floor(Math.random() * models.length)]; const rotation = Math.random() * Math.PI * 2; const scale = 0.9 + Math.random() * 0.3; this.decoManager.placeModel(worldX, worldY, worldZ, modelId, rotation, scale); } try { this._onSceneChange?.(); } catch (e) {} } /** * Поставить ОДНО красивое процедурное дерево из voxel-блоков под кистью. * * Логика как в smooth-режиме (`_smoothBrushTickPlant`): * - 1 дерево за тик (а не пучок) * - rate-limit: если кисть не сдвинулась далеко, пропускаем тик * - случайный выбор типа: oak / birch / autumn * * Использует `placeVoxelTree` из VoxelTreeBuilder.js (тот же алгоритм, * который генерирует деревья в процедурном мире — толстый ствол, * корни, ветви-зигзаги, главная крона + кроны на ветвях). */ _placeVoxelTreesAtBrush(brush) { if (!this.terrainManager) return; const tm = this.terrainManager; // === Rate-limit между тиками === // Один тик = одно дерево. Если кисть не сдвинулась более чем на // 0.4×radius (в voxel-units), пропускаем. Это убирает спам деревьев // друг на друге при удержании ЛКМ. const r = brush.radius; const minDist = Math.max(2, r * 0.4); const minDist2 = minDist * minDist; if (this._voxelTreeLastPos) { const dx = brush.x - this._voxelTreeLastPos.x; const dz = brush.z - this._voxelTreeLastPos.z; if (dx * dx + dz * dz < minDist2) return; } this._voxelTreeLastPos = { x: brush.x, z: brush.z }; // === Случайная точка в круге кисти (jitter) === const angle = Math.random() * Math.PI * 2; const rr = Math.sqrt(Math.random()) * r * 0.5; // не до края — деревья ближе к центру const tx = Math.round(brush.x + Math.cos(angle) * rr); const tz = Math.round(brush.z + Math.sin(angle) * rr); // === Top-surface для этой XZ === const topY = tm._findTopY?.(tx, tz, brush.y + r * 4, brush.y - r * 4); const baseY = (topY === null || topY === undefined) ? brush.y : topY; // === Размер дерева от strength (1..100) === // strength=10 → саженец (sizeScale=0.5) // strength=50 → стандарт (sizeScale=1.0) // strength=100 → большое (sizeScale=2.0) const strength = brush.strength ?? 50; const sizeScale = 0.5 + (strength / 100) * 1.5; // === Случайный тип дерева === const type = TREE_TYPES[Math.floor(Math.random() * TREE_TYPES.length)]; // === Уникальный seed на каждое дерево — даёт разную форму === const seed = (Math.random() * 0x7fffffff) | 0; // === Ставим voxels через batched setVoxel-fn === // tm._addInstance не обновляет GPU buffer в batch-режиме, делаем // один flushBatch в конце. Это превращает ~300 add'ов в один upload. tm._beginBatch?.(); let placed = 0; try { const setVoxelFn = (x, y, z, matId) => { const k = `${x},${y},${z}`; if (tm.voxels.has(k)) return; tm._addInstance?.(k, x, y, z, matId); tm.voxels.set(k, matId); placed++; }; placeVoxelTree(setVoxelFn, tx, baseY, tz, type, sizeScale, seed); } finally { tm._flushBatch?.(); } if (placed > 0) { try { tm._emit?.(); } catch (e) {} try { this._onSceneChange?.(); } catch (e) {} } } /** Удалить все декорации в зоне кисти (по миру). */ _eraseDecorationsInBrush(brush) { if (!this.decoManager || !this.decoManager.placements) return; const TERRAIN_VOXEL = 0.25; const cx = (brush.x + 0.5) * TERRAIN_VOXEL; const cz = (brush.z + 0.5) * TERRAIN_VOXEL; const r = brush.radius * TERRAIN_VOXEL * 1.2; // чуть больше для удобства const r2 = r * r; const keep = []; let removed = 0; for (const p of this.decoManager.placements) { const dx = p.x - cx; const dz = p.z - cz; if (dx * dx + dz * dz <= r2) { removed++; } else { keep.push(p); } } if (removed > 0) { // Перезагружаем decoManager с обновлённым списком this.decoManager.clear(); this.decoManager.loadFromArray(keep); try { this._onSceneChange?.(); } catch (e) {} } } _terrainHistoryEnsure() { if (!this._terrainHistory) { this._terrainHistory = { stack: [], cursor: -1, pending: null }; } return this._terrainHistory; } _terrainHistoryOpen() { const tm = this.terrainManager; if (!tm) return; const h = this._terrainHistoryEnsure(); // Снапшот до изменения h.pending = tm.serialize(); } _terrainHistoryClose() { const tm = this.terrainManager; if (!tm) return; const h = this._terrainHistoryEnsure(); if (!h.pending) return; const after = tm.serialize(); // Сравниваем размером — если ничего не изменилось, не пушим if (after.length === h.pending.length && this._terrainSerEqual(after, h.pending)) { h.pending = null; return; } // Обрубаем все «впереди-курсора» (redo-стек после нового действия) if (h.cursor < h.stack.length - 1) { h.stack.length = h.cursor + 1; } h.stack.push(h.pending); h.cursor = h.stack.length - 1; // Ограничение 30 шагов const MAX = 30; while (h.stack.length > MAX) { h.stack.shift(); h.cursor--; } h.pending = null; } _terrainSerEqual(a, b) { // Сравнение двух serialize-массивов. O(n) с использованием Set. if (a.length !== b.length) return false; const sa = new Set(); for (const v of a) sa.add(`${v.x},${v.y},${v.z},${v.m}`); for (const v of b) if (!sa.has(`${v.x},${v.y},${v.z},${v.m}`)) return false; return true; } undoTerrain() { const tm = this.terrainManager; if (!tm) return false; const h = this._terrainHistoryEnsure(); if (h.cursor < 0) return false; // Текущий стейт в редо-позицию (cursor+1) const current = tm.serialize(); const target = h.stack[h.cursor]; // Если на cursor лежит снапшот «до», нам нужно вернуться к нему. // Cursor указывает на последнее ВОЗВРАТНОЕ состояние. tm.loadFromArray(target); // Записываем текущий стейт в позицию cursor+1 для возможного redo h.stack[h.cursor + 1] = current; h.cursor--; return true; } redoTerrain() { const tm = this.terrainManager; if (!tm) return false; const h = this._terrainHistoryEnsure(); if (h.cursor + 1 >= h.stack.length - 0) return false; const target = h.stack[h.cursor + 2]; if (!target) return false; tm.loadFromArray(target); h.cursor++; return true; } // ======================================================================== // Регион террейна (инструменты «Выделить», «Заполнить», «Преобразовать») // // Регион — это объёмная коробка, заданная двумя углами в voxel-индексах. // Визуализируется wireframe-боксом. Создаётся drag-rectangle'ом на земле: // первый mousedown в режиме «Выделить» — стартовый угол, drag — второй. // Высота коробки фиксируется ±radius по Y вокруг плоскости клика. // // Регион используется: // • «Заполнить» — залить регион выбранным материалом // • «Преобразовать» — переместить все voxel'ы региона в новое место // (плоский drag по XZ, без поворота на этапе 2). // ======================================================================== /** Текущее выделение или null. Структура: {x0,y0,z0,x1,y1,z1} (включительно). */ getTerrainRegion() { return this._terrainRegion || null; } /** Очистить выделение и убрать визуализацию. */ clearTerrainRegion() { this._terrainRegion = null; if (this._terrainRegionMesh) { try { this._terrainRegionMesh.dispose(); } catch (e) {} this._terrainRegionMesh = null; } } /** Обновить wireframe-визуализацию региона по this._terrainRegion. */ _updateTerrainRegionVisual() { const r = this._terrainRegion; if (this._terrainRegionMesh) { try { this._terrainRegionMesh.dispose(); } catch (e) {} this._terrainRegionMesh = null; } if (!r) return; const S = TERRAIN_VOXEL_SIZE; const minX = Math.min(r.x0, r.x1); const maxX = Math.max(r.x0, r.x1); const minY = Math.min(r.y0, r.y1); const maxY = Math.max(r.y0, r.y1); const minZ = Math.min(r.z0, r.z1); const maxZ = Math.max(r.z0, r.z1); // Размер в мире — кол-во клеток × VOXEL_SIZE. +1 потому что включительно. const sizeX = (maxX - minX + 1) * S; const sizeY = (maxY - minY + 1) * S; const sizeZ = (maxZ - minZ + 1) * S; const cx = (minX + 0.5) * S + (sizeX - S) / 2; const cy = (minY + 0.5) * S + (sizeY - S) / 2; const cz = (minZ + 0.5) * S + (sizeZ - S) / 2; const mesh = MeshBuilder.CreateBox('__terrainRegion', { width: sizeX, height: sizeY, depth: sizeZ, }, this.scene); mesh.position.set(cx, cy, cz); mesh.isPickable = false; const mat = new StandardMaterial('__terrainRegionMat', this.scene); mat.wireframe = true; mat.emissiveColor = new Color3(0.20, 0.55, 1.00); mat.diffuseColor = new Color3(0, 0, 0); mat.alpha = 0.9; mesh.material = mat; mesh.metadata = { _isTerrainRegion: true }; this._terrainRegionMesh = mesh; } /** Запустить выделение региона: pickStart — voxel-клетка начала. */ _terrainBeginRegion(pickStart) { const radius = this._terrainBrush?.brushSize || 4; // Высота региона по умолчанию = ±radius от Y клика. При drag юзер // может уточнить — но через первый MVP оставим фиксированной. this._terrainRegion = { x0: pickStart.x, y0: Math.max(0, pickStart.y - radius), z0: pickStart.z, x1: pickStart.x, y1: pickStart.y + radius, z1: pickStart.z, }; this._terrainRegionDragging = true; this._updateTerrainRegionVisual(); } /** Обновить второй угол региона по новой voxel-клетке. */ _terrainUpdateRegion(pickEnd) { if (!this._terrainRegion) return; this._terrainRegion.x1 = pickEnd.x; this._terrainRegion.z1 = pickEnd.z; // Y оставляем как поставили при начале — drag по плоскости XZ this._updateTerrainRegionVisual(); } /** Завершить drag выделения. */ _terrainEndRegion() { this._terrainRegionDragging = false; } /** * Инициализировать пустой smooth-terrain для скульптинга с нуля. * Создаёт DensityGrid 100×24×100 cells (400×96×400м) с density=0 везде. * Первый клик sculpt-кистью сразу породит холм в нужном месте. */ _initEmptySmoothTerrain() { if (this._robloxTerrain) { try { this._robloxTerrain.disposeAll(); } catch (e) {} } this._robloxTerrain = new RobloxTerrain(this.scene); if (this.physics?.setRobloxTerrain) { this.physics.setRobloxTerrain(this._robloxTerrain); } const sx = 100, sy = 24, sz = 100; const grid = new RobloxDensityGrid({ origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 }, size: { x: sx, y: sy, z: sz }, }); // Регистрируем стандартные материалы в палитре (нужно для brushes). // Index 0 = пусто, далее по порядку для совместимости с web/Android. for (const matKey of ['grass', 'rock', 'sand', 'snow', 'dirt']) { // Hack: set одной ячейки потом обнуляем, чтобы добавить в palette. grid.set(0, 0, 0, 0, matKey); } // Сбрасываем (0,0,0) обратно в пусто — но matData[0] остался matId // последнего set'а. Обнуляем явно. grid.densityData[0] = 0; grid.matData[0] = 0; // skipEmpty: true — НЕ добавляем 98 пустых chunks в pending, // mesher будет работать только после первой кисти. this._robloxTerrain.loadFromGrid(grid, { skipEmpty: true }); // НЕ отключаем baseplate сразу — нужен чтобы raycast мог пикать // плоскость y=0 при первых кликах. Отключим когда появится хоть один // solid chunk (см. updateStreaming / applyBrushAndRebuild). console.log('[BabylonScene] _initEmptySmoothTerrain: 100×24×100 grid created (skipEmpty=true)'); } /** * Тик smooth-кисти. Делает raycast по mesh-ам smooth-terrain, * получает worldPosition и вызывает applyBrush в этой точке. * * Если smooth-terrain ещё не создан (свежий проект, нажимаем sculpt * на пустой сцене) — создаём пустой DensityGrid 100×24×100 cells * (400×96×400м) при первом клике sculpt/fill, как в Roblox Studio. */ _smoothBrushTick(shiftKey, isFirst) { const tool = this._terrainBrush?.tool || 'sculpt'; // === Инструмент «Выбрать деко» — поштучный клик-выбор декораций === // Не модифицирует ландшафт, работает только на клик (не drag). if (tool === 'pickDeco') { if (isFirst) this._pickDecoTick(); return; } const terrainEmpty = !this._robloxTerrain || !this._robloxTerrain.grid; // === Счётчики для диагностики === if (!this._smoothBrushDiag) { this._smoothBrushDiag = { tickCount: 0, hitTerrain: 0, hitGround: 0, hitNone: 0, applyResult: { built: 0, dirty0: 0 }, }; } const D = this._smoothBrushDiag; D.tickCount++; const tickN = D.tickCount; // === Plane-lock + rate-limit (как в Roblox Studio) === // При isFirst — фиксируем плоскость и позицию первого клика. // Дальше при drag: // 1) center.y берётся не из raycast (он растёт вверх вслед за рельефом), // а из зафиксированного _smoothBrushLockY → кисть работает в плоскости. // 2) Между tick'ами требуется минимальное расстояние (0.6×radius) — // иначе одна и та же точка → cells доходят до 255 → "какаха". if (isFirst) { this._smoothBrushLockY = null; this._smoothBrushLastPos = null; } // === Raycast — выбираем точку под курсором === let hit = null; let pickSource = ''; if (!terrainEmpty) { const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); hit = this.scene.pickWithRay( this.scene.createPickingRay(this.scene.pointerX, this.scene.pointerY, null, this.camera), pickPred, ); if (hit && hit.hit) { pickSource = 'terrain'; D.hitTerrain++; } } if (!hit || !hit.hit || !hit.pickedPoint) { const groundPick = this.scene.pick(this.scene.pointerX, this.scene.pointerY, (m) => m.name === 'editorGround'); if (groundPick && groundPick.hit && groundPick.pickedPoint) { hit = groundPick; pickSource = 'ground'; D.hitGround++; } else { D.hitNone++; if (isFirst || tickN % 20 === 0) { console.log(`[SmoothBrush] tick#${tickN} isFirst=${isFirst} → NO HIT (terrainEmpty=${terrainEmpty}, pointer=${this.scene.pointerX},${this.scene.pointerY})`); } return; } } const worldPt = hit.pickedPoint; // === Инициализация пустого grid при первом клике sculpt/fill === let initialized = false; if (terrainEmpty) { if (!isFirst) { if (tickN <= 5 || tickN % 30 === 0) { console.log(`[SmoothBrush] tick#${tickN} isFirst=false, terrainEmpty=true → skip (нужен первый клик)`); } return; } if (tool !== 'sculpt' && tool !== 'draw' && tool !== 'fill') { console.log(`[SmoothBrush] tick#${tickN} terrainEmpty + tool='${tool}' → нельзя инициализировать (используйте sculpt/draw/fill)`); return; } console.log(`[SmoothBrush] tick#${tickN} INIT empty grid (tool='${tool}', hit at ${worldPt.x.toFixed(1)},${worldPt.y.toFixed(1)},${worldPt.z.toFixed(1)})`); this._initEmptySmoothTerrain(); initialized = true; if (!this._robloxTerrain || !this._robloxTerrain.grid) { console.warn(`[SmoothBrush] tick#${tickN} init FAILED — grid still null`); return; } } const material = this._terrainBrush?.material || 'grass'; const radius = Math.max(3, (this._terrainBrush?.brushSize || 4) * 2.0); // strength: slider 0..100 → реальная strength 60..400 для sculpt. // Минимум 60 чтобы edge influence (~0.05) давал delta=3 → cells // на краю кисти достигали threshold 128 за 40 тиков а не 128. // Максимум 400 = мгновенно ставит cells в 255 (полная заливка). const strengthSlider = this._terrainBrush?.strength ?? 50; const strength = 60 + (strengthSlider / 100) * 340; let brushType; if (shiftKey) { brushType = 'sculptDown'; } else { switch (tool) { case 'draw': brushType = 'sculptUp'; break; case 'sculpt': brushType = 'sculptUp'; break; case 'smooth': brushType = 'smooth'; break; case 'paint': brushType = 'paint'; break; case 'flatten': if (isFirst) this._smoothFlattenTargetY = worldPt.y; brushType = 'flatten'; break; case 'fill': brushType = 'fill'; break; case 'erase': brushType = 'erase'; break; // === Plant-кисти: добавление декораций === case 'plantGrass': brushType = 'plantGrass'; break; case 'plantFlower': brushType = 'plantFlower'; break; case 'plantMushroom': brushType = 'plantMushroom'; break; case 'plantTree': brushType = 'plantTree'; break; default: brushType = 'sculptUp'; } } // === Plant-кисти обрабатываются ОТДЕЛЬНО от sculpt-логики === // Они НЕ модифицируют DensityGrid — добавляют thin-instance модели. if (brushType.startsWith('plant')) { return this._smoothBrushTickPlant(brushType, worldPt, radius, shiftKey, isFirst); } // === Plane-lock: для sculpt-кистей фиксируем Y первого клика === // Без этого при drag в одной XZ-точке raycast возвращает всё более // высокий Y (рельеф рос на прошлых тиках) → кисть смещается ВВЕРХ → // рельеф летит на камеру ("какаха"). // С lock'ом drag работает в горизонтальной плоскости фиксированной // высоты первого клика, как в Roblox Studio. const isSculptKind = brushType === 'sculptUp' || brushType === 'sculptDown'; let centerY = worldPt.y; if (isSculptKind) { if (this._smoothBrushLockY === null) { this._smoothBrushLockY = worldPt.y; } centerY = this._smoothBrushLockY; // Смещение от плоскости первого клика на radius×0.5 // (вверх для sculptUp, вниз для sculptDown). if (brushType === 'sculptUp') centerY += radius * 0.5; else centerY -= radius * 0.5; } // === Rate-limit: пропускаем tick если кисть не сдвинулась далеко === // Между тиками должно быть >= 0.6×radius по XZ. Это убивает feedback // loop в одной точке. if (isSculptKind && !isFirst && this._smoothBrushLastPos) { const dx = worldPt.x - this._smoothBrushLastPos.x; const dz = worldPt.z - this._smoothBrushLastPos.z; const minDist = radius * 0.6; if (dx * dx + dz * dz < minDist * minDist) { // Кисть в той же точке — пропускаем (но НЕ для isFirst). return; } } if (isSculptKind) { this._smoothBrushLastPos = { x: worldPt.x, z: worldPt.z }; } const params = { center: { x: worldPt.x, y: centerY, z: worldPt.z }, radius, strength, material, targetY: this._smoothFlattenTargetY, }; const tApply0 = performance.now(); const built = this._robloxTerrain.applyBrushAndRebuild(brushType, params); const tApply = performance.now() - tApply0; if (built > 0) D.applyResult.built += built; else D.applyResult.dirty0++; if (isFirst || tickN <= 5 || tickN % 20 === 0 || initialized || built === 0) { const gridStats = this._robloxTerrain.grid ? `solid=${this._robloxTerrain.grid.countSolid?.() ?? '?'}` : 'no-grid'; console.log( `[SmoothBrush] tick#${tickN} ${brushType} slider=${strengthSlider} → strength=${strength.toFixed(0)} ` + `pick='${pickSource}' @(${worldPt.x.toFixed(1)},${worldPt.y.toFixed(1)},${worldPt.z.toFixed(1)}) ` + `r=${radius} mat=${material} ${gridStats} ` + `→ built=${built} chunks in ${tApply.toFixed(0)}ms ` + (initialized ? ' [INITIALIZED!]' : '') + (built === 0 ? ' [NO CHANGE]' : ''), ); } try { this._onSceneChange?.(); } catch (e) {} } /** * Plant-кисть: расставляет/удаляет декорации (трава/цветы/грибы/деревья). * Не трогает DensityGrid — работает только с SmoothDecoManager. * Shift = ластик (удалить декорации в радиусе). */ _smoothBrushTickPlant(brushType, worldPt, radius, shiftKey, isFirst) { // Нужен SmoothDecoManager (создаём lazy при первом plant-клике) if (!this._smoothDecoManager) { this._smoothDecoManager = new SmoothDecoManager(this.scene); this._smoothDecoManager.loadAll(); } // Rate-limit как в sculpt: пропускаем близкие тики if (!isFirst && this._smoothBrushLastPos) { const dx = worldPt.x - this._smoothBrushLastPos.x; const dz = worldPt.z - this._smoothBrushLastPos.z; const minDist = radius * 0.4; // плотнее чем sculpt (декорации мелкие) if (dx * dx + dz * dz < minDist * minDist) return; } this._smoothBrushLastPos = { x: worldPt.x, z: worldPt.z }; // Shift — ластик if (shiftKey) { const removed = this._smoothDecoManager.removeBrushDecoInRadius( { x: worldPt.x, z: worldPt.z }, radius, ); if (removed > 0) { console.log(`[SmoothBrush] erased ${removed} decorations at (${worldPt.x.toFixed(1)},${worldPt.z.toFixed(1)})`); // Пересинхронизировать tree-colliders в physics if (this.physics?.setSmoothDecoTrees) { this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders()); } try { this._onSceneChange?.(); } catch (e) {} } return; } // Маппинг brushType → kind для SmoothDecoManager const kindMap = { plantGrass: 'grass', plantFlower: 'flower', plantMushroom: 'mushroom', plantTree: 'tree', }; const kind = kindMap[brushType]; if (!kind) return; // Количество инстансов за один тик зависит от типа. // Трава густая, деревья редко. const countMap = { grass: 6, flower: 4, mushroom: 2, tree: 1 }; const count = countMap[kind] || 3; // Surface-Y хелпер: raycast по smooth-terrain ИЛИ ground (y=0). const sampleSurfaceY = (x, z) => { if (this.physics?._sampleRobloxSurface) { const y = this.physics._sampleRobloxSurface(x, z); if (y !== null) return y; } // Fallback на ground y=0 return 0; }; const result = this._smoothDecoManager.addBrushDeco({ kind, center: { x: worldPt.x, y: worldPt.y, z: worldPt.z }, radius, count, sampleSurfaceY, }); const added = result.added || 0; if (added > 0) { console.log(`[SmoothBrush] planted ${added} ${kind} at (${worldPt.x.toFixed(1)},${worldPt.z.toFixed(1)})`); // Если посадили деревья — пересинхронизировать tree-colliders // в physics (полная переустановка через getAllTreeColliders). if (kind === 'tree' && this.physics?.setSmoothDecoTrees) { this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders()); } try { this._onSceneChange?.(); } catch (e) {} } } /** Залить выделенный регион материалом. Используется инструментом * «Заполнить» когда есть активный _terrainRegion. */ _terrainFillRegion(matId) { const r = this._terrainRegion; const tm = this.terrainManager; if (!r || !tm) return 0; const minX = Math.min(r.x0, r.x1); const maxX = Math.max(r.x0, r.x1); const minY = Math.min(r.y0, r.y1); const maxY = Math.max(r.y0, r.y1); const minZ = Math.min(r.z0, r.z1); const maxZ = Math.max(r.z0, r.z1); let n = 0; for (let x = minX; x <= maxX; x++) { for (let y = minY; y <= maxY; y++) { for (let z = minZ; z <= maxZ; z++) { const key = `${x},${y},${z}`; if (tm.voxels.has(key)) continue; tm.setVoxel(x, y, z, matId); n++; } } } return n; } /** Переместить весь регион на dx/dy/dz в voxel-клетках. */ _terrainMoveRegion(dx, dy, dz) { const r = this._terrainRegion; const tm = this.terrainManager; if (!r || !tm) return 0; if (dx === 0 && dy === 0 && dz === 0) return 0; const minX = Math.min(r.x0, r.x1); const maxX = Math.max(r.x0, r.x1); const minY = Math.min(r.y0, r.y1); const maxY = Math.max(r.y0, r.y1); const minZ = Math.min(r.z0, r.z1); const maxZ = Math.max(r.z0, r.z1); // Собираем содержимое региона const collected = []; for (let x = minX; x <= maxX; x++) { for (let y = minY; y <= maxY; y++) { for (let z = minZ; z <= maxZ; z++) { const m = tm.getVoxel(x, y, z); if (!m) continue; collected.push({ x, y, z, m }); } } } // Удаляем из старых позиций for (const v of collected) tm.removeVoxel(v.x, v.y, v.z); // Ставим в новые let n = 0; for (const v of collected) { tm.setVoxel(v.x + dx, v.y + dy, v.z + dz, v.m); n++; } // Сдвигаем сам регион r.x0 += dx; r.x1 += dx; r.y0 += dy; r.y1 += dy; r.z0 += dz; r.z1 += dz; this._updateTerrainRegionVisual(); return n; } /** Двигать preview-меш под курсор. Вызывается из mousemove. */ _updateTerrainBrushPosition() { if (this._activeTool !== 'terrain') return; if (!this._terrainBrushPreview) return; // === Smooth-режим: raycast по smooth-mesh, preview на surface === if (this._terrainBrush?.terrainMode === 'smooth' && this._robloxTerrain?.grid) { const ray = this.scene.createPickingRay( this.scene.pointerX, this.scene.pointerY, null, this.camera, ); const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); const hit = this.scene.pickWithRay(ray, pickPred); if (hit && hit.hit && hit.pickedPoint) { this._terrainBrushPreview.position.copyFrom(hit.pickedPoint); } return; } // Voxel-режим (как было) const cell = this._pickTerrainCell(false); if (!cell) return; const S = TERRAIN_VOXEL_SIZE; this._terrainBrushPreview.position.set( (cell.x + 0.5) * S, (cell.y + 0.5) * S, (cell.z + 0.5) * S, ); } /** Публичный сеттер: выбрать тип блока для постановки. */ setActiveBlockType(blockTypeId) { this._activeBlockType = blockTypeId; } /** Публичный сеттер: выбрать тип модели для постановки. */ setActiveModelType(modelTypeId) { this._activeModelType = modelTypeId; } /** Тип примитива для постановки (cube/sphere/...). */ setActivePrimitiveType(typeId) { this._activePrimitiveType = typeId; } getPrimitiveCount() { return this.primitiveManager ? this.primitiveManager.getInstanceCount() : 0; } /** Количество блоков (для status bar). */ getBlockCount() { return this.blockManager ? this.blockManager.count() : 0; } /** Количество моделей-инстансов. */ getModelCount() { return this.modelManager ? this.modelManager.getInstanceCount() : 0; } /** Подписаться на изменение выделения (UI / Inspector / Hierarchy). */ setOnSelectionChange(cb) { if (this.selection) { // Объединяем со внутренней подпиской на gizmo this.selection.setOnSelectionChange((sel) => { this._updateGizmoForSelection(sel); if (cb) cb(sel); }); } } /** Текущее выделение (или null). */ getSelection() { return this.selection?.getSelection() || null; } /** Выделить блок программно (например по клику в Hierarchy). */ selectBlockAt(x, y, z) { this.selection?.selectBlockAt(x, y, z); } /** Выделить модель программно. */ selectModelByInstanceId(id) { this.selection?.selectModelByInstanceId(id); } /** Снять выделение. */ clearSelection() { this.selection?.clear(); } /** Удалить выделенный объект. */ deleteSelected() { this.selection?.deleteSelected(); } /** * Дублировать выделенный объект (Ctrl+D). * Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y). * Модель: создаёт копию со смещением +1 по X. */ duplicateSelected() { const sel = this.selection?.getSelection(); if (!sel) return; if (sel.type === 'block') { // Ищем свободную клетку рядом const candidates = [ [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1], [0, 1, 0], ]; for (const [dx, dy, dz] of candidates) { const nx = sel.gridX + dx, ny = sel.gridY + dy, nz = sel.gridZ + dz; if (ny < 0) continue; if (!this.blockManager.hasBlock(nx, ny, nz)) { this.blockManager.addBlock(nx, ny, nz, sel.blockTypeId); this.selection.selectBlockAt(nx, ny, nz); return; } } } else if (sel.type === 'model') { // Сохраняем все нужные поля выделения до того как промис завершится // (selection может перезатереться к моменту resolve) const typeId = sel.modelTypeId; const sx = sel.x, sy = sel.y, sz = sel.z; const rotY = sel.rotationY || 0; this.modelManager.addInstance(typeId, sx + 1, sy, sz, rotY) .then(newId => { if (newId != null) this.selection?.selectModelByInstanceId(newId); }) .catch(err => { // eslint-disable-next-line no-console console.error('[BabylonScene] duplicate model error:', err); }); } else if (sel.type === 'userModel') { const typeId = sel.userModelTypeId; const sx = sel.x, sy = sel.y, sz = sel.z; const rotY = sel.rotationY || 0; this.userModelManager.addInstance(typeId, sx + 1, sy, sz, rotY, { currentUserId: this._currentUserId || null, }).then(newId => { if (newId != null) this.selection?.selectUserModelByInstanceId(newId); }).catch(err => { console.error('[BabylonScene] duplicate user model error:', err); }); } else if (sel.type === 'primitive') { const newId = this.primitiveManager.addInstance(sel.primitiveType, { x: sel.x + 1, y: sel.y, z: sel.z, sx: sel.sx, sy: sel.sy, sz: sel.sz, color: sel.color, material: sel.material, canCollide: sel.canCollide, visible: sel.visible, anchored: sel.anchored, // Копируем и спец-свойства: текстуру, параметры лампы/эмиттера. textureAsset: sel.textureAsset || null, brightness: sel.brightness, range: sel.range, effect: sel.effect, }); if (newId != null) this.selection.selectPrimitiveById(newId); } } /** * Скопировать выделенный объект в буфер обмена (Ctrl+C, Фаза 5.10). * Буфер — localStorage, поэтому переживает перезагрузку страницы * и смену проекта (Copy/Paste между проектами). */ copySelected() { const sel = this.selection?.getSelection(); if (!sel) return; let clip = null; if (sel.type === 'block') { clip = { kind: 'block', blockTypeId: sel.blockTypeId }; } else if (sel.type === 'model') { clip = { kind: 'model', modelTypeId: sel.modelTypeId, rotationY: sel.rotationY || 0, scale: sel.scale || 1, }; } else if (sel.type === 'userModel') { clip = { kind: 'userModel', userModelTypeId: sel.userModelTypeId, rotationY: sel.rotationY || 0, }; } else if (sel.type === 'primitive') { clip = { kind: 'primitive', primitiveType: sel.primitiveType, sx: sel.sx, sy: sel.sy, sz: sel.sz, color: sel.color, material: sel.material, canCollide: sel.canCollide, visible: sel.visible, anchored: sel.anchored, textureAsset: sel.textureAsset || null, brightness: sel.brightness, range: sel.range, effect: sel.effect, }; } if (clip) { try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); } catch (e) { /* ignore — приватный режим / переполнение */ } } } /** * Вставить объект из буфера обмена (Ctrl+V, Фаза 5.10). * Объект появляется у точки, куда смотрит редактор-камера. */ pasteFromClipboard() { let clip; try { const raw = localStorage.getItem('kubikon_clipboard'); if (!raw) return; clip = JSON.parse(raw); } catch (e) { return; } if (!clip || !clip.kind) return; // Точка вставки — перед редактор-камерой (~6м по направлению взгляда). const cam = this.camera; let px = 0, py = 1, pz = 0; if (cam) { const fwd = cam.getForwardRay ? cam.getForwardRay().direction : null; if (fwd) { px = cam.position.x + fwd.x * 6; pz = cam.position.z + fwd.z * 6; } } if (clip.kind === 'block') { const gx = Math.round(px), gz = Math.round(pz); let gy = 0; while (gy < 64 && this.blockManager?.hasBlock(gx, gy, gz)) gy++; this.blockManager?.addBlock(gx, gy, gz, clip.blockTypeId); this.selection?.selectBlockAt(gx, gy, gz); } else if (clip.kind === 'model') { this.modelManager?.addInstance(clip.modelTypeId, px, py, pz, clip.rotationY || 0) .then(id => { if (id != null) this.selection?.selectModelByInstanceId(id); }) .catch(() => {}); } else if (clip.kind === 'userModel') { this.userModelManager?.addInstance( clip.userModelTypeId, px, py, pz, clip.rotationY || 0, { currentUserId: this._currentUserId || null }, ).then(id => { if (id != null) this.selection?.selectUserModelByInstanceId(id); }) .catch(() => {}); } else if (clip.kind === 'primitive') { const id = this.primitiveManager?.addInstance(clip.primitiveType, { x: px, y: Math.max(py, (clip.sy || 1) / 2), z: pz, sx: clip.sx, sy: clip.sy, sz: clip.sz, color: clip.color, material: clip.material, canCollide: clip.canCollide, visible: clip.visible, anchored: clip.anchored, textureAsset: clip.textureAsset || null, brightness: clip.brightness, range: clip.range, effect: clip.effect, }); if (id != null) this.selection?.selectPrimitiveById(id); } if (this._onSceneChange) this._onSceneChange(); } /** * Поставить выделенный объект на пол (y = 0). * Для блоков — gridY=0. Для моделей — нижняя граница на полу. Для примитивов — sy/2. */ alignSelectedToFloor() { const sel = this.selection?.getSelection(); if (!sel) return; if (sel.type === 'block') { this.selection.moveSelectedBlock(sel.gridX, 0, sel.gridZ); } else if (sel.type === 'model') { // Модель: rootMesh.position.y = низ модели. y=0 = низ на полу. this.selection.moveSelectedModel(sel.x, 0, sel.z); } else if (sel.type === 'userModel') { this.selection.moveSelectedUserModel(sel.x, 0, sel.z); } else if (sel.type === 'primitive') { // Центр примитива должен быть на высоте sy/2 чтобы низ касался пола. const halfH = (sel.sy || 1) / 2; this.selection.moveSelectedPrimitive(sel.x, halfH, sel.z); } } /** * View-preset — поставить редактор-камеру в одну из стандартных позиций. * preset: 'top' | 'front' | 'side' | 'iso' */ setViewPreset(preset) { if (!this.camera) return; const presets = { top: { pos: [0, 40, 0.01], rot: [Math.PI / 2, 0, 0] }, // прямо сверху front: { pos: [0, 8, -25], rot: [0, 0, 0] }, // спереди side: { pos: [25, 8, 0], rot: [0, -Math.PI / 2, 0] }, // сбоку iso: { pos: [15, 15, -20], rot: [Math.PI / 5, -Math.PI / 5, 0] }, // изометрия }; const p = presets[preset]; if (!p) return; this.camera.position = new Vector3(p.pos[0], p.pos[1], p.pos[2]); this.camera.rotation = new Vector3(p.rot[0], p.rot[1], p.rot[2]); } /** * Поставить точку спавна там где сейчас смотрит редактор-камера * (полезно для размещения «тут начинать игру»). * spawnPoint = (camera.x, max(0, floor(camera.y) - 1), camera.z). */ setSpawnAtCamera() { if (!this.camera) return; const p = this.camera.position; this._spawnPoint = { x: Math.round(p.x), y: Math.max(0, Math.floor(p.y) - 1), z: Math.round(p.z), }; this._updateSpawnMarker(); this.history?.markChange(); if (this._onSceneChange) this._onSceneChange(); } /** Изменить позицию выделенного (используется Inspector). */ moveSelectedTo(x, y, z) { if (!this.selection) return; const sel = this.selection.getSelection(); if (!sel) return; if (sel.type === 'block') { this.selection.moveSelectedBlock(Math.round(x), Math.round(y), Math.round(z)); } else if (sel.type === 'model') { this.selection.moveSelectedModel(x, y, z); } else if (sel.type === 'userModel') { this.selection.moveSelectedUserModel(x, y, z); } else if (sel.type === 'spawn') { this.selection.moveSelectedSpawn(x, y, z); } else if (sel.type === 'primitive') { this.selection.moveSelectedPrimitive(x, y, z); } } /** Изменить размер выделенного примитива (Inspector). */ resizeSelectedPrimitiveTo(sx, sy, sz) { this.selection?.resizeSelectedPrimitive(sx, sy, sz); } /** Изменить свойства выделенного примитива (color/material/canCollide/visible). */ setSelectedPrimitivePropsTo(patch) { this.selection?.setSelectedPrimitiveProps(patch); } /** Повернуть выделенную модель (Y, в радианах). */ rotateSelectedModelTo(angleRad) { if (!this.selection) return; const sel = this.selection.getSelection(); if (sel?.type === 'userModel') { this.selection.rotateSelectedUserModel(angleRad); } else { this.selection.rotateSelectedModel(angleRad); } } /** Изменить масштаб выделенной модели. */ scaleSelectedModelTo(scale) { if (!this.selection) return; const sel = this.selection.getSelection(); if (sel?.type === 'userModel') { this.selection.scaleSelectedUserModel(scale); } else { this.selection.scaleSelectedModel(scale); } } /** Установить режим гизмо: 'select' | 'move' | 'rotate' | 'scale'. */ setGizmoMode(mode) { if (this._gizmo) this._gizmo.setMode(mode); } /** Получить текущий режим гизмо. */ getGizmoMode() { return this._gizmo ? this._gizmo.getMode() : 'select'; } /** Установить snap-step гизмо для перемещения (1.0 / 0.5 / 0.25 / 0=off). * Также применяется к Inspector-вводу координат моделей. */ setGizmoSnap(step) { if (this._gizmo) this._gizmo.setSnap(step); if (this.selection) this.selection.setSnapStep(step); } getGizmoSnap() { return this._gizmo ? this._gizmo.getSnap() : 0; } /** Сфокусировать редактор-камеру на выделенном (двигает камеру к объекту). */ focusOnSelection() { const sel = this.selection?.getSelection(); if (!sel) return; let target; if (sel.type === 'block') { target = new Vector3(sel.gridX, sel.gridY + 0.5, sel.gridZ); } else if (sel.type === 'model' || sel.type === 'spawn' || sel.type === 'primitive') { target = new Vector3(sel.x, sel.y + 0.5, sel.z); } if (target) this._focusOnTarget(target); } /** Установить точку спавна игрока в режиме Play. */ setSpawnPoint(x, y, z) { this._spawnPoint = { x, y, z }; this._updateSpawnMarker(); } /** Установить тип модели персонажа (для Play). */ setPlayerModelType(typeId) { if (!typeId) return; this._playerModelType = typeId; } getPlayerModelType() { return this._playerModelType; } /** Идёт ли сейчас режим игры. */ isPlaying() { return this._isPlaying; } /** * Переключить в режим игры. Создаём PlayerController, прячем ghost-блок, * запоминаем позицию редактор-камеры чтобы вернуть при exit. */ enterPlayMode() { if (this._isPlaying) return; this._isPlaying = true; // Сброс состояния касаний — каждый прогон начинается «не касаясь», // иначе rising-edge touch не сработает, если при стопе игрок стоял на цели. if (this._touchState) this._touchState.clear(); // По умолчанию стандартный HUD видим в Play. // Скрипт может скрыть через game.hud.setVisible(false). this._setStdHudVisible(true); this._setHotbarVisible(true); this._setHpVisible(true); // Включаем picking voxel-террейна — иначе камера _clampCameraToWorld // не «видит» воксели в Ray-каст и пролетает сквозь стены. // В редакторе остаётся выключенным (быстрее). try { this.terrainManager?.enablePickingForCamera?.(true); } catch (e) {} // Снимок редактор-камеры this._editorCameraSnapshot = { position: this.camera.position.clone(), rotation: this.camera.rotation.clone(), }; if (this._ghostMesh) this._ghostMesh.setEnabled(false); this._setSpawnMarkerVisible(false); // Триггеры — невидимые в Play, видимые в редакторе this.primitiveManager?.setTriggersVisible(false); // Запоминаем исходные позиции unanchored-объектов чтобы вернуть // их при выходе из Play (физика двигает mesh.position). this._snapshotDynamicObjects(); // Полный снимок примитивов и моделей — чтобы при Stop откатить // ВСЕ изменения скриптов (удаления, цвет, видимость, повороты). this._snapshotFullScene(); // Запускаем физику unanchored this.dynamics?.start(); // Запускаем фоновую музыку и амбиент this.audioManager?.start(); // Создаём PlayerController и стартуем this.player = new PlayerController(this.scene, this.canvas, this.physics, this); this.player.setModelType(this._playerModelType); // Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck try { this.modalManager?.attachPlayer?.(this.player); this.modalManager?.attachAudio?.(this.audioManager); } catch (e) {} this.player._jumpPowerMul = this._jumpPowerMul ?? 1; // Применяем дефолтную камеру если задана в сцене if (this._defaultCameraMode && this._defaultCameraMode !== this.player._cameraMode) { this.player._cameraMode = this._defaultCameraMode; } // На тач-устройствах отключаем pointer-lock и mouse-камеру if (this._touchMode) this.player.setTouchMode(true); this.player.setOnExitRequest(() => { // Задача 07: магазин скинов открыт → Esc закрывает его (приоритет выше модала). if (this._skinShop?.open) { this._closeSkinShop(); return; } // Задача 04: если открыт модал — первый Esc закрывает его, // второй Esc уже выходит из Play. Так юзер не теряет состояние игры // случайно при попытке скрыть модал. if (this.modalManager?.isOpen?.()) { this.modalManager.close(); return; } this.exitPlayMode(); if (this._onPlayChange) this._onPlayChange(false); }); if (this._onPlayerHpChange) this.player.setOnHpChange(this._onPlayerHpChange); if (this._onPlayerDeath) this.player.setOnDeath(this._onPlayerDeath); this.player.start(this._spawnPoint); // Запускаем пользовательские скрипты (этап 2.1). // Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену, // поэтому скрипты стартуем в следующем кадре. this.gameRuntime = new GameRuntime(this); try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {} // Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime. // ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным // this.audioManager (AudioManager — ambient/music для всех проектов). if (!this.gameAudioManager) { this.gameAudioManager = new GameAudioManager(); } // GD-уровень (Этап 5.1): автоматически обрабатывает GD-порталы, шипы, финиш, монеты. // Юзер просто ставит объекты из палитры (категории "GD-порталы" и "GD-объекты") в редакторе. if (!this.gdLevelManager) { this.gdLevelManager = new GdLevelManager(this); this.gdLevelManager.setOnPortalEnter((newMode, prevMode) => { try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdPortalEnter', data: { newMode, prevMode } }); } catch (e) {} try { this.gameAudioManager?.playSfx?.('flip'); } catch (e) {} }); this.gdLevelManager.setOnDeath((info) => { try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdDeath', data: info }); } catch (e) {} try { this.gameAudioManager?.playSfx?.('death'); } catch (e) {} // Респавн игрока через teleport на spawnPoint try { const sp = this._spawnPoint || { x: 0, y: 2, z: 0 }; this.player.teleport(sp.x, sp.y, sp.z); // Сбросить vy чтобы не нести инерцию из шипа if (this.player) this.player._vy = 0; } catch (e) {} }); this.gdLevelManager.setOnFinish((info) => { const stats = this.gdLevelManager.getCoinsStats(); try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdFinish', data: { ...info, coinsCollected: stats.collected, coinsTotal: stats.total }, }); } catch (e) {} try { this.gameAudioManager?.playSfx?.('level_complete'); } catch (e) {} }); this.gdLevelManager.setOnCoinCollected((info) => { try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdCoinCollected', data: info }); } catch (e) {} try { this.gameAudioManager?.playSfx?.('coin'); } catch (e) {} }); } this.gdLevelManager.start(); // Этапы G1/G2: skybox+параллакс+декоративная трава для GD-уровней. // Откладываем на setTimeout — primitiveManager.instances наполняется // не сразу при enterPlayMode (load.primitives асинхронный). // GD-проект определяется флагом settings.isGd (см. serialize/loadFromState). // Fallback для старых проектов БЕЗ флага — реальные id GD-уровней: // - 295: GD 2.0 sandbox // - 296..306: L1-L11 (эпоха 1 + L11 легаси) // - 350..358: L12-L20 (эпоха 2) // Раньше было только 296..315 — L12-L20 (id 350..358) НЕ попадали, // и веб-плеер не активировал GD-инфру (шипы конусы вместо .glb, // нет skybox/forest на заднем фоне). На APK работало правильно. const _pid = Number(this._currentProjectId); const isGd = (typeof this._isGdProject === 'boolean') ? this._isGdProject : ((_pid >= 296 && _pid <= 315) || (_pid >= 350 && _pid <= 358) || _pid === 295); console.log(`[GD-gfx] currentProjectId=${this._currentProjectId}, isGd=${isGd}, flag=${this._isGdProject}`); if (isGd) { // Ширина уровня — по самому правому cube-блоку let levelWidth = 1000; if (this.blockManager && this.blockManager.blocks) { for (const key of this.blockManager.blocks.keys()) { const x = parseInt(String(key).split(',')[0], 10); if (Number.isFinite(x) && x > levelWidth) levelWidth = x; } } setTimeout(() => { try { if (!this.gdSkybox) { this.gdSkybox = new GdSkybox(); const cam = this.player?.camera || this.scene.activeCamera; this.gdSkybox.attach(this.scene, cam); console.log('[GD-gfx] skybox attached'); } if (!this.gdGroundSkin) { this.gdGroundSkin = new GdGroundSkin(); this.gdGroundSkin.attach(this.scene, levelWidth, this._shadowGenerator, this); console.log('[GD-gfx] groundSkin attached, width=', levelWidth); } // Эпоха по project_id. L11 = 306 (легаси). L12-L20 = 350-358. const pid = Number(this._currentProjectId) || 296; const GD_PID_TO_EPOCH = { 296:1, 297:1, 298:1, 299:1, 300:1, 301:1, 302:1, 303:1, 304:1, 305:1, 306:2, 350:2, 351:2, 352:2, 353:2, 354:2, 355:2, 356:2, 357:2, 358:2, }; const epoch = GD_PID_TO_EPOCH[pid] || 1; if (!this.gdSpikes) { this.gdSpikes = new GdSpikes(); this.gdSpikes.attach(this.scene, this, epoch); } if (!this.gdStartArch) { this.gdStartArch = new GdStartArch(); this.gdStartArch.attach(this.scene, epoch); } if (!this.gdPortalArch) { this.gdPortalArch = new GdPortalArch(); this.gdPortalArch.attach(this.scene, this, this._currentUserId); } if (!this.gdDiamond) { this.gdDiamond = new GdDiamond(); this.gdDiamond.attach(this.scene, this); } if (!this.gdFinish) { this.gdFinish = new GdFinish(); this.gdFinish.attach(this.scene, this, epoch); } if (!this.gdForest) { this.gdForest = new GdForest(); this.gdForest.attach(this.scene, levelWidth, epoch); } if (!this.gdPlayerCube) { this.gdPlayerCube = new GdPlayerCube(); this.gdPlayerCube.attach(this.scene, this); } if (!this.gdPlayerModeSkin) { // Задержка 600мс — даём скрипту уровня применить базовый cube-skin, // чтобы _origTexture при первой смене режима содержала правильную текстуру. setTimeout(() => { this.gdPlayerModeSkin = new GdPlayerModeSkin(); this.gdPlayerModeSkin.attach(this.scene, this, this._currentUserId); }, 600); } if (!this.gdPlayerTrail) { this.gdPlayerTrail = new GdPlayerTrail(); this.gdPlayerTrail.attach(this.scene, this, this._currentProjectId, this._currentUserId); } if (!this.gdPostFx) { this.gdPostFx = new GdPostFx(); const cam = this.player?.camera || this.scene.activeCamera; this.gdPostFx.attach(this.scene, cam, this); } // Тени отключены — делаем через GdGroundSkin (fake shadows) this._enableGdShadows(); } catch (e) { console.warn('[BabylonScene] GD-graphics attach failed', e); } }, 50); } if (this._onScriptLog) this.gameRuntime.setOnLog(this._onScriptLog); if (this._onScriptHud) this.gameRuntime.setOnHud(this._onScriptHud); if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair); // eslint-disable-next-line no-console console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts); // Старт через requestAnimationFrame — даём Babylon собрать сцену requestAnimationFrame(() => { if (this._isPlaying) this.gameRuntime?.start(this._scripts || []); }); // === Оружие === if (!this.weapons) this.weapons = new WeaponSystem(this); if (this._onAmmoChange) this.weapons.setOnAmmoChange(this._onAmmoChange); // Подключаем зомби-логику к попаданиям пули this.weapons.setOnHit((hit) => { if (hit?.mesh && this.zombieManager) { this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25); } if (this._onWeaponHit) { try { this._onWeaponHit(hit); } catch (e) {} } }); this.weapons.start(); // === Зомби-система === if (!this.zombieManager) this.zombieManager = new ZombieManager(this); if (!this.spawnerManager) this.spawnerManager = new ZombieSpawnerManager(this, this.zombieManager); this.zombieManager.start(); this.spawnerManager.start(); // === NPC-система (Фаза 4.1) — управляемые скриптом персонажи === if (!this.npcManager) this.npcManager = new NpcManager(this); this.npcManager.start(); // === Связи объектов (Фаза 5, Constraints) === if (!this.constraintManager) this.constraintManager = new ConstraintManager(this); this.constraintManager.start(); // === Лучи и следы (Фаза 5.2 — Beam/Trail) === if (!this.beamManager) this.beamManager = new BeamManager(this); this.beamManager.start(); // Задача 08: активируем pointer-примитивы из палитры в реальные стрелки. this._activatePointers(); // === 3D-звук (Фаза 5.5 — позиционный звук) === if (!this.soundManager) this.soundManager = new SoundManager(this); this.soundManager.start(); // Регистрируем gameplay-объекты: зомби и спавнеры. // Применяем defaults + текущие gameplayParams из инспектора. if (this.modelManager) { for (const data of this.modelManager.instances.values()) { const gp = data.gameplay; if (!gp) continue; const params = { ...(gp.defaultParams || {}), ...(data.gameplayParams || {}) }; if (gp.isZombie) { this.zombieManager.registerExisting(data.instanceId, params); } else if (gp.isZombieSpawner) { this.spawnerManager.register(data.instanceId, params); } } } // Снаряжаем оружие из активного слота инвентаря const active = this.inventory?.getActive?.(); if (active) this.weapons.equip(active); // Замораживаем world-matrix у всех статичных GLB-моделей // (не зомби и не спавнеры). Деревья, дома, камни не двигаются — // Babylon не должен пересчитывать их матрицы каждый кадр. try { this.modelManager?.freezeStaticModels?.(); } catch (e) {} try { this.primitiveManager?.freezeStaticPrimitives?.(); } catch (e) {} // ОПТИМИЗАЦИЯ ОТКЛЮЧЕНА: octree селекшн. // Octree создаётся один раз и не «знает» о мешах добавленных позже — // даже с alwaysSelectAsActiveMesh новые меши (трейсеры выстрелов, // debris-кубы при смерти, динамические объекты) фактически выпадают // из активного списка → невидимы. Стандартный frustum-culling Babylon // дешёвый сам по себе для нашей сцены, octree больше вреда чем пользы. } /** Заглушка для совместимости — раньше пересоздавала octree. */ setActiveMeshesDirty() { // no-op } /** Установить колбэк логов от скриптов (для Console-панели UI). */ setOnScriptLog(cb) { this._onScriptLog = cb; if (this.gameRuntime) this.gameRuntime.setOnLog(cb); } /** Колбэк команд HUD от скриптов (для GameHud React-компонента). */ setOnScriptHud(cb) { this._onScriptHud = cb; if (this.gameRuntime) this.gameRuntime.setOnHud(cb); } /** Колбэк смены прицела из скрипта (game.player.crosshair = 'cross'). */ setOnScriptCrosshair(cb) { this._onScriptCrosshair = cb; if (this.gameRuntime) this.gameRuntime.setOnCrosshairChange(cb); } // ============================================================ // Таймер прохождения (для лидерборда) // ============================================================ /** cb({state: 'start'|'stop'|'submit', timeMs}) */ setOnTimer(cb) { this._onTimer = cb; } _timerStart() { this._timerStartedAt = performance.now(); this._timerRunning = true; if (this._onTimer) try { this._onTimer({ state: 'start', timeMs: 0 }); } catch (e) {} } _timerStop() { if (!this._timerRunning) return; const ms = Math.round(performance.now() - (this._timerStartedAt || 0)); this._timerRunning = false; if (this._onTimer) try { this._onTimer({ state: 'stop', timeMs: ms }); } catch (e) {} } _timerSubmit() { if (!this._timerRunning && !this._timerStartedAt) return; const ms = Math.round(performance.now() - (this._timerStartedAt || 0)); this._timerRunning = false; if (this._onTimer) try { this._onTimer({ state: 'submit', timeMs: ms }); } catch (e) {} } /** Получить текущее время таймера в мс (или 0 если не запущен). */ getTimerMs() { if (!this._timerRunning || !this._timerStartedAt) return 0; return Math.round(performance.now() - this._timerStartedAt); } isTimerRunning() { return !!this._timerRunning; } /** PERF-METRICS: получить и сбросить накопленные метрики за окно. */ flushPerfMetrics() { const m = this._perfMetrics; if (!m) return null; const out = { render_ms_avg: m.render_count ? (m.render_ms_sum / m.render_count) : 0, physics_ms_avg: m.physics_count ? (m.physics_ms_sum / m.physics_count) : 0, script_ms_avg: m.script_count ? (m.script_ms_sum / m.script_count) : 0, idle_ms_avg: m.idle_count ? (m.idle_ms_sum / m.idle_count) : 0, render_count: m.render_count, physics_count: m.physics_count, script_count: m.script_count, }; m.render_ms_sum = 0; m.render_count = 0; m.physics_ms_sum = 0; m.physics_count = 0; m.script_ms_sum = 0; m.script_count = 0; m.idle_ms_sum = 0; m.idle_count = 0; return out; } /** * Поставить render-loop на паузу. * Используется когда Babylon canvas не виден (активен таб скрипта), * чтобы освободить CPU/GPU и Monaco не лагал. * НЕ останавливает Play-режим — только рендер. */ pauseRendering() { this._renderingPaused = true; } resumeRendering() { this._renderingPaused = false; } isRenderingPaused() { return !!this._renderingPaused; } /** * Создать эффект частиц в точке. Вызывается из GameRuntime._spawnParticles * (через game.scene.spawnParticles в скриптах). * * payload: { type, position: {x,y,z}, duration, count, color } */ _spawnParticleEffect(payload) { if (!payload || !this.scene) return; const pos = payload.position || { x: 0, y: 0, z: 0 }; const type = payload.type || 'sparks'; const duration = Math.max(0.1, Math.min(20, Number(payload.duration) || 1.5)); const countMul = Math.max(0.1, Math.min(10, Number(payload.count) || 1)); // Кэшируем текстуру частицы — один раз на сцену if (!this._particleTex) { const tex = new DynamicTexture('particleTex', { width: 64, height: 64 }, this.scene, false); const ctx = tex.getContext(); const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); grad.addColorStop(0, 'rgba(255,255,255,1)'); grad.addColorStop(0.4, 'rgba(255,255,255,0.6)'); grad.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = grad; ctx.fillRect(0, 0, 64, 64); tex.update(); tex.hasAlpha = true; this._particleTex = tex; } // MOBILE-OPT (этап 1): на мобильном уменьшаем кол-во частиц в 2 раза const baseCount = this._isMobileMode ? 40 : 80; const ps = new ParticleSystem('p_' + Date.now(), Math.floor(baseCount * countMul), this.scene); ps.particleTexture = this._particleTex; ps.emitter = new Vector3(pos.x, pos.y, pos.z); ps.minEmitBox = new Vector3(-0.1, -0.1, -0.1); ps.maxEmitBox = new Vector3(0.1, 0.1, 0.1); ps.blendMode = ParticleSystem.BLENDMODE_ADD; const customColor = payload.color && /^#[0-9a-fA-F]{6}$/.test(payload.color) ? payload.color : null; this._configureParticleSystem(ps, type, customColor, countMul); ps.start(); // Авто-стоп: для explosion почти сразу (это burst), для остальных = duration const stopAt = type === 'explosion' ? 0.05 : duration; const disposeAt = stopAt + (ps.maxLifeTime || 1) + 0.3; setTimeout(() => { try { ps.stop(); } catch (e) {} }, stopAt * 1000); // dispose(false) — particleTexture расшарена (_particleTex), не удалять. setTimeout(() => { try { ps.dispose(false); } catch (e) {} }, disposeAt * 1000); } /** * Настроить параметры ParticleSystem под тип эффекта. * Общий конфигуратор для разового эффекта (_spawnParticleEffect) и * постоянного эмиттера-объекта (createEmitterParticles). */ _configureParticleSystem(ps, type, customColor, countMul = 1) { const hexToColor4 = (hex, a = 1) => { const r = parseInt(hex.substr(1, 2), 16) / 255; const g = parseInt(hex.substr(3, 2), 16) / 255; const b = parseInt(hex.substr(5, 2), 16) / 255; return new Color4(r, g, b, a); }; switch (type) { case 'fire': ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.6, 0.1, 1); ps.color2 = customColor ? hexToColor4(customColor, 0.8) : new Color4(1, 0.2, 0, 1); ps.colorDead = new Color4(0.2, 0, 0, 0); ps.minSize = 0.2; ps.maxSize = 0.5; ps.minLifeTime = 0.4; ps.maxLifeTime = 1.0; ps.emitRate = 80; ps.gravity = new Vector3(0, 1.2, 0); ps.direction1 = new Vector3(-0.4, 1.5, -0.4); ps.direction2 = new Vector3(0.4, 2.0, 0.4); ps.minEmitPower = 0.5; ps.maxEmitPower = 1.2; break; case 'smoke': ps.color1 = new Color4(0.4, 0.4, 0.4, 0.6); ps.color2 = new Color4(0.2, 0.2, 0.2, 0.4); ps.colorDead = new Color4(0, 0, 0, 0); ps.minSize = 0.4; ps.maxSize = 1.2; ps.minLifeTime = 1.5; ps.maxLifeTime = 3; ps.emitRate = 40; ps.gravity = new Vector3(0, 0.5, 0); ps.direction1 = new Vector3(-0.3, 1, -0.3); ps.direction2 = new Vector3(0.3, 1.5, 0.3); ps.minEmitPower = 0.3; ps.maxEmitPower = 0.7; ps.blendMode = ParticleSystem.BLENDMODE_STANDARD; break; case 'sparks': ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 1, 0.4, 1); ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.6, 0, 1); ps.colorDead = new Color4(0.5, 0.2, 0, 0); ps.minSize = 0.05; ps.maxSize = 0.15; ps.minLifeTime = 0.3; ps.maxLifeTime = 0.8; ps.emitRate = 200; ps.gravity = new Vector3(0, -8, 0); ps.direction1 = new Vector3(-3, 4, -3); ps.direction2 = new Vector3(3, 7, 3); ps.minEmitPower = 1; ps.maxEmitPower = 3; break; case 'magic': ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(0.6, 0.3, 1, 1); ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(0.3, 0.6, 1, 1); ps.colorDead = new Color4(0.2, 0, 0.5, 0); ps.minSize = 0.15; ps.maxSize = 0.35; ps.minLifeTime = 1; ps.maxLifeTime = 2.2; ps.emitRate = 60; ps.gravity = new Vector3(0, 0.3, 0); ps.direction1 = new Vector3(-1, 1, -1); ps.direction2 = new Vector3(1, 2, 1); ps.minEmitPower = 0.5; ps.maxEmitPower = 1.5; break; case 'explosion': ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.7, 0.2, 1); ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.3, 0, 1); ps.colorDead = new Color4(0.2, 0, 0, 0); ps.minSize = 0.3; ps.maxSize = 0.8; ps.minLifeTime = 0.4; ps.maxLifeTime = 1.0; ps.emitRate = 0; ps.manualEmitCount = Math.floor(120 * countMul); ps.gravity = new Vector3(0, -3, 0); ps.direction1 = new Vector3(-5, -1, -5); ps.direction2 = new Vector3(5, 5, 5); ps.minEmitPower = 2; ps.maxEmitPower = 6; break; case 'confetti': ps.color1 = new Color4(1, 0.3, 0.3, 1); ps.color2 = new Color4(0.3, 0.6, 1, 1); ps.colorDead = new Color4(0, 0, 0, 0); ps.minSize = 0.1; ps.maxSize = 0.25; ps.minLifeTime = 1.5; ps.maxLifeTime = 3; ps.emitRate = 100; ps.gravity = new Vector3(0, -3, 0); ps.direction1 = new Vector3(-3, 5, -3); ps.direction2 = new Vector3(3, 8, 3); ps.minEmitPower = 1; ps.maxEmitPower = 3; break; default: ps.color1 = new Color4(1, 1, 1, 1); ps.color2 = new Color4(0.7, 0.7, 0.7, 0.5); ps.colorDead = new Color4(0, 0, 0, 0); ps.minSize = 0.1; ps.maxSize = 0.3; ps.minLifeTime = 0.5; ps.maxLifeTime = 1.5; ps.emitRate = 60; ps.direction1 = new Vector3(-1, 1, -1); ps.direction2 = new Vector3(1, 2, 1); } } /** * Создать ПОСТОЯННУЮ систему частиц для эмиттера-объекта (костёр и т.п.). * Не имеет авто-стопа — горит пока объект существует. Возвращает ps. */ createEmitterParticles(type, position, color) { if (!this.scene) return null; if (!this._particleTex) { const tex = new DynamicTexture('particleTex', { width: 64, height: 64 }, this.scene, false); const ctx = tex.getContext(); const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); grad.addColorStop(0, 'rgba(255,255,255,1)'); grad.addColorStop(0.4, 'rgba(255,255,255,0.6)'); grad.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = grad; ctx.fillRect(0, 0, 64, 64); tex.update(); tex.hasAlpha = true; this._particleTex = tex; } const baseCount = this._isMobileMode ? 60 : 120; const ps = new ParticleSystem('emitter_' + Date.now(), baseCount, this.scene); ps.particleTexture = this._particleTex; ps.emitter = new Vector3(position.x, position.y, position.z); ps.minEmitBox = new Vector3(-0.15, -0.1, -0.15); ps.maxEmitBox = new Vector3(0.15, 0.1, 0.15); ps.blendMode = ParticleSystem.BLENDMODE_ADD; const customColor = color && /^#[0-9a-fA-F]{6}$/.test(color) ? color : null; // explosion как постоянный эффект не имеет смысла → fire const effType = type === 'explosion' ? 'fire' : type; this._configureParticleSystem(ps, effType, customColor, 1); ps.start(); return ps; } /** * Запустить ОДИН скрипт без Play-режима (отладочный запуск из редактора). * Если runtime уже есть — переиспользуем, иначе создаём. */ startSoloScript(scriptId) { const all = this._scripts || []; const sc = all.find(s => s.id === scriptId); if (!sc) return false; if (!this.gameRuntime) { this.gameRuntime = new GameRuntime(this); try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {} if (!this.gameAudioManager) { this.gameAudioManager = new GameAudioManager(); } if (this._onScriptLog) this.gameRuntime.setOnLog(this._onScriptLog); if (this._onScriptHud) this.gameRuntime.setOnHud(this._onScriptHud); if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair); } this.gameRuntime.startSolo(sc); return true; } /** Остановить отладочный (solo) запуск. */ stopSoloScript() { if (this.gameRuntime && this.gameRuntime.isSolo?.()) { this.gameRuntime.stop(); // Если не в Play-режиме — освобождаем runtime if (!this._isPlaying) { this.gameRuntime = null; } } } isSoloRunning() { return !!this.gameRuntime?.isSolo?.(); } getSoloScriptId() { return this.gameRuntime?.getSoloScriptId?.() || null; } /** Получить все скрипты проекта. */ getScripts() { return [...this._scripts]; } /** Заменить все скрипты (используется при load/edit). */ setScripts(scripts) { this._scripts = Array.isArray(scripts) ? scripts.slice() : []; } /** Установить код одного скрипта по id. Если id нет — создать новый. */ upsertScript(id, code, target = undefined) { const i = this._scripts.findIndex(s => s.id === id); if (i >= 0) { this._scripts[i] = { ...this._scripts[i], code, ...(target !== undefined ? { target } : {}), }; } else { this._scripts.push({ id: id || `script_${Date.now()}`, code, target: target !== undefined ? target : null, }); } // Скрипты — часть сцены: фиксируем в истории, иначе undo откатит // _scripts к снапшоту, снятому до создания скрипта, и скрипт пропадёт. this.history?.markChange(); if (this._onSceneChange) this._onSceneChange(); } /** Удалить скрипт по id. */ removeScript(id) { this._scripts = this._scripts.filter(s => s.id !== id); this.history?.markChange(); if (this._onSceneChange) this._onSceneChange(); } /** * Зарегистрировать колбэк для уведомлений об изменении режима Play * (вызывается когда player сам инициирует exit, например по Esc). * KubikonEditor подписывается чтобы синхронизировать React-state. */ setOnPlayChange(cb) { this._onPlayChange = cb; } /** * Колбэк изменения сцены (любая модификация блоков/моделей). * Используется KubikonEditor для dirty-tracking → auto-save. * Сами обработчики на blockManager/modelManager привязаны в init() — * они дёргают и history.markChange() и this._onSceneChange. */ setOnSceneChange(cb) { this._onSceneChange = cb; } /** Колбэк изменения GUI-элементов (для перерисовки React-overlay). */ setOnGuiChange(cb) { this._onGuiChange = cb; } /** Подключить API для пользовательских моделей (Kubikon3DService). * Нужно дважды: setApi для самого UserModelManager (getUserModel) * и сохранить _userModelsApi для incrementModelUses в _handlePlaceModel. */ setUserModelsApi(api) { this._userModelsApi = api; if (this.userModelManager && api) { this.userModelManager.setApi(api); } } /** Передать id текущего пользователя — для запросов к приватным моделям. */ setCurrentUserId(userId) { this._currentUserId = userId; } /** Передать id текущего проекта — для game.save.* эндпоинтов (savegame API). * Без этого скрипты не смогут сохранять прогресс. */ setCurrentProjectId(projectId) { this._currentProjectId = projectId; } /** Колбэк изменения видимости стандартного HUD (HP-бар, hotbar, ...). * Редактор/плеер подписываются и реактивно скрывают/показывают элементы. * Скрипт зовёт game.hud.setVisible(false) → этот колбэк сработает. */ setOnStdHudVisibilityChange(cb) { this._onStdHudVisibilityChange = cb; } _setStdHudVisible(visible) { this._stdHudVisible = !!visible; try { this._onStdHudVisibilityChange?.(this._stdHudVisible); } catch (e) {} } /** Задача 03: отдельный контроль хотбара (5 слотов инвентаря снизу). * Дёргается из game.hud.setHotbarVisible(bool). */ setOnHotbarVisibilityChange(cb) { this._onHotbarVisibilityChange = cb; } _setHotbarVisible(visible) { this._hotbarVisible = !!visible; try { this._onHotbarVisibilityChange?.(this._hotbarVisible); } catch (e) {} } /** Задача 03: отдельный контроль HP-индикатора (полоска слева сверху). * Дёргается из game.hud.setHpVisible(bool). */ setOnHpVisibilityChange(cb) { this._onHpVisibilityChange = cb; } _setHpVisible(visible) { this._hpVisible = !!visible; try { this._onHpVisibilityChange?.(this._hpVisible); } catch (e) {} } /** Колбэк смены cursor-режима (ui/game) скриптом через game.input.setCursorMode. * Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */ setOnCursorModeChange(cb) { this._onCursorModeChange = cb; } /** Пересобрать spatial-индекс физики для user-моделей. * Вызывается из SelectionManager при изменении canCollide / anchored / * position / rotation / scale. */ _syncUserModelColliders() { try { this.physics?.setSpatialDirty?.(); } catch (e) {} } /** Инвалидировать пользовательскую модель после её редактирования. * Сбрасывает кэш + пересоздаёт все инстансы этой модели в сцене с * новой геометрией. Вызывается из KubikonEditor.jsx после закрытия * редактора модели (когда editingUserModelId != null). */ async refreshUserModel(userModelId) { if (!this.userModelManager) return 0; const rebuilt = await this.userModelManager.invalidateModel(userModelId, { rebuild: true, currentUserId: this._currentUserId || null, }); // Тени для свежесозданных мешей if (rebuilt > 0) { for (const inst of this.userModelManager.instances.values()) { if (inst.userModelId === userModelId) { for (const m of inst.meshes) { try { this.addShadowCaster(m); } catch (e) {} } } } this._syncUserModelColliders(); } return rebuilt; } /** Колбэк изменения инвентаря (для hot-bar React). */ setOnInventoryChange(cb) { this._onInventoryChange = cb; } /** Колбэк изменения патронов оружия (для GUI). */ setOnAmmoChange(cb) { this._onAmmoChange = cb; if (this.weapons) this.weapons.setOnAmmoChange(cb); } /** Колбэк попадания пули (для логики урона зомби и др.). */ setOnWeaponHit(cb) { this._onWeaponHit = cb; if (this.weapons) this.weapons.setOnHit(cb); } /** Колбэк изменения HP игрока. */ setOnPlayerHpChange(cb) { this._onPlayerHpChange = cb; if (this.player) this.player.setOnHpChange(cb); } /** Колбэк смерти игрока. */ setOnPlayerDeath(cb) { this._onPlayerDeath = cb; if (this.player) this.player.setOnDeath(cb); } /** Колбэк Escape в редакторе (для возврата в инструмент «Выделить»). */ setOnEditorEscape(cb) { this._onEditorEscape = cb; } getInventoryState() { return this.inventory ? this.inventory.serialize() : { slots: [], activeIndex: 0 }; } setActiveInventorySlot(index) { // Phase 6.4: фиксируем что было активно ДО смены -- для onUnequipped. const wasActive = this.inventory?.getActive?.(); this.inventory?.setActive(index); // Если в Play — пересменяем оружие if (this._isPlaying && this.weapons) { const active = this.inventory?.getActive?.(); if (active && active.kind === 'weapon') { this.weapons.equip(active); } else { this.weapons.unequip(); } // Сообщаем мультиплееру о смене оружия — чтобы remote-клиенты // увидели в руке нашей модели правильный GLB. if (this._mpSync) { const modelId = (active && active.kind === 'weapon') ? (active.modelTypeId || '') : ''; try { this._mpSync.sendWeapon(modelId); } catch (e) {} } // Phase 6.4: события onEquipped / onUnequipped для кастомных tool. try { if (wasActive && wasActive !== active) { const tool = { kind: wasActive.kind, modelTypeId: wasActive.modelTypeId, name: wasActive.name, }; if (wasActive.params && wasActive.params._customToolId) { tool.customToolId = wasActive.params._customToolId; } this.gameRuntime?.routeGlobalEvent?.('toolUnequipped', { tool }); } if (active && active !== wasActive) { const tool = { kind: active.kind, modelTypeId: active.modelTypeId, name: active.name, }; if (active.params && active.params._customToolId) { tool.customToolId = active.params._customToolId; } this.gameRuntime?.routeGlobalEvent?.('toolEquipped', { tool }); } } catch (e) { /* ignore */ } } } addInventoryItem(item) { return this.inventory?.add(item) ?? -1; } /** API для UI: создать/изменить/удалить GUI-элемент. Делегирует в GuiManager. */ createGuiElement(type, opts) { return this.guiManager?.create(type, opts); } updateGuiElement(id, patch) { this.guiManager?.update(id, patch); } removeGuiElement(id) { // Если был выделен — снять выделение if (this.selection?._selection?.type === 'gui' && this.selection._selection.id === id) { this.selection.clearSelection?.(); } this.guiManager?.remove(id); } renameGuiElement(id, name) { this.guiManager?.rename(id, name); } moveGuiElementZ(id, direction) { this.guiManager?.moveZ(id, direction); } getGuiElements() { return this.guiManager ? this.guiManager.getAll() : []; } // ===== Задача 07: встроенный магазин скинов (React-оверлей) ===== // Состояние держим тут; React-компонент SkinShopOverlay поллит getSkinShopState(). _ensureSkinShopState() { if (!this._skinShop) { this._skinShop = { open: false, rev: 0, // ревизия — React видит изменение data: { all: [], unlocked: [], current: null, coins: 0, manifestFull: [] }, buyResult: null, // последний результат покупки {slug, ok, reason} }; } return this._skinShop; } /** Снимок состояния магазина для React (поллинг через rAF). */ getSkinShopState() { return this._skinShop || null; } /** Открыть/закрыть магазин (из скрипта или клавиши B). */ _openSkinShop() { const s = this._ensureSkinShopState(); // Отключён в проекте? (скрипт всё равно может открыть через API — // shopVisible:false запрещает только клавишу B, см. toggleSkinShop). s.open = true; s.rev++; } _closeSkinShop() { const s = this._ensureSkinShopState(); s.open = false; s.rev++; } toggleSkinShop() { const s = this._ensureSkinShopState(); if (s.open) { this._closeSkinShop(); return; } // Клавиша B открывает магазин только если он включён в проекте. if (this._skinsConfig && this._skinsConfig.shopVisible === false) return; this._openSkinShop(); } /** Данные скинов от GameRuntime (манифест + unlocked + coins). */ _setSkinShopData(data) { const s = this._ensureSkinShopState(); s.data = { ...s.data, ...data }; s.rev++; } _onSkinBuyResult(res) { const s = this._ensureSkinShopState(); s.buyResult = { ...res, t: (this.scene?.getEngine?.()?.getFps?.() || 0) }; s.rev++; } /** Намерение купить/надеть скин — шлём в runtime (он списывает рублики). */ requestBuySkin(slug, price) { const rt = this.gameRuntime; if (!rt) return; try { rt._handleCommand?.(null, 'player.buySkin', { slug, price: price || 0 }); } catch (e) {} } /** Данные из загруженных проектом кастомных .glb-скинов (data-URL по slug). */ getAssetDataUrl(slug) { try { // Кастомные скины хранят dataUrl прямо в _skinsConfig.customGlbs. const list = this._skinsConfig?.customGlbs || []; const rec = list.find(g => g && g.slug === slug); if (rec && rec.dataUrl) return rec.dataUrl; } catch (e) {} return null; } _onPlayerSkinChanged(slug) { const s = this._ensureSkinShopState(); if (s.data) { s.data.current = slug; s.rev++; } } // ===== Библиотека пользовательских картинок (этап 3.6) ===== /** Список картинок проекта [{id, name, dataUrl}]. */ getAssets() { return this.assetManager ? this.assetManager.list() : []; } /** Загрузить картинку из File. Возвращает Promise<{ok, id?, error?}>. */ addAssetFromFile(file) { if (!this.assetManager) return Promise.resolve({ ok: false, error: 'нет менеджера' }); return this.assetManager.addFromFile(file).then((res) => { if (res.ok && this._onSceneChange) this._onSceneChange(); return res; }); } renameAsset(id, name) { if (!this.assetManager) return; this.assetManager.rename(id, name); if (this._onSceneChange) this._onSceneChange(); } /** * Удалить картинку. Снимает её с примитивов/GUI, которые на неё ссылались — * иначе остались бы «висячие» ссылки на несуществующий ассет. */ removeAsset(id) { if (!this.assetManager) return; this.assetManager.remove(id); // Снять текстуру с примитивов, использовавших этот ассет. if (this.primitiveManager) { for (const data of this.primitiveManager.instances.values()) { if (data.textureAsset === id) { this.primitiveManager.updateInstance(data.id, { textureAsset: null }); } } } // Снять картинку с GUI-элементов image. if (this.guiManager) { for (const el of this.guiManager.getAll()) { if (el.imageAsset === id) { this.guiManager.update(el.id, { imageAsset: null }); } } } if (this._onSceneChange) this._onSceneChange(); } // ===== Библиотека пользовательских звуков (Фаза 5.5) ===== /** Список звуков проекта [{id, name, dataUrl}]. */ getSounds() { return this.soundLibrary ? this.soundLibrary.list() : []; } /** Загрузить звук из File. Возвращает Promise<{ok, id?, error?}>. */ addSoundFromFile(file) { if (!this.soundLibrary) return Promise.resolve({ ok: false, error: 'нет библиотеки' }); return this.soundLibrary.addFromFile(file).then((res) => { if (res.ok && this._onSceneChange) this._onSceneChange(); return res; }); } renameSound(id, name) { if (!this.soundLibrary) return; this.soundLibrary.rename(id, name); if (this._onSceneChange) this._onSceneChange(); } removeSound(id) { if (!this.soundLibrary) return; this.soundLibrary.remove(id); if (this._onSceneChange) this._onSceneChange(); } // ===== Библиотека импортированных .glb-моделей (Фаза 5.8) ===== /** Список импортированных моделей [{id, name, dataUrl}]. */ getGlbModels() { return this.glbLibrary ? this.glbLibrary.list() : []; } /** Загрузить .glb из File. Возвращает Promise<{ok, id?, error?}>. */ addGlbFromFile(file) { if (!this.glbLibrary) return Promise.resolve({ ok: false, error: 'нет библиотеки' }); return this.glbLibrary.addFromFile(file).then((res) => { if (res.ok && this._onSceneChange) this._onSceneChange(); return res; }); } renameGlb(id, name) { if (!this.glbLibrary) return; this.glbLibrary.rename(id, name); if (this._onSceneChange) this._onSceneChange(); } removeGlb(id) { if (!this.glbLibrary) return; this.glbLibrary.remove(id); if (this._onSceneChange) this._onSceneChange(); } /** * Колбэк после постановки нового объекта (блока/модели/примитива). * Используется KubikonEditor чтобы переключить activeTool на 'select' * и дать пользователю сразу таскать поставленный объект. */ setOnPostPlace(cb) { this._onPostPlace = cb; } /** * Сохранить позиции всех unanchored объектов перед стартом физики. * При exitPlayMode они возвращаются на эти позиции. */ _snapshotDynamicObjects() { this._dynamicSnapshot = []; if (this.blockManager) { // Запоминаем позиции unanchored блоков (mesh-position). // Сами блоки ОСТАЮТСЯ в blockManager.blocks Map, иначе вся остальная // логика (сериализация, удаление, выделение) сломается. // PhysicsAABB при Play фильтрует hasBlock через metadata.anchored // и не считает unanchored клетку статичным препятствием. for (const mesh of this.blockManager.blocks.values()) { if (mesh.metadata?.anchored === false) { this._dynamicSnapshot.push({ kind: 'block', mesh, x: mesh.position.x, y: mesh.position.y, z: mesh.position.z, rotX: mesh.rotation?.x || 0, rotY: mesh.rotation?.y || 0, rotZ: mesh.rotation?.z || 0, }); } } } if (this.primitiveManager) { for (const data of this.primitiveManager.instances.values()) { if (data.anchored === false) { this._dynamicSnapshot.push({ kind: 'primitive', data, x: data.x, y: data.y, z: data.z, rotX: data.mesh?.rotation?.x || 0, rotY: data.mesh?.rotation?.y || 0, rotZ: data.mesh?.rotation?.z || 0, }); } } } if (this.modelManager) { for (const data of this.modelManager.instances.values()) { if (data.anchored === false) { this._dynamicSnapshot.push({ kind: 'model', data, x: data.x, y: data.y, z: data.z, rotX: data.rootMesh?.rotation?.x || 0, rotY: data.rootMesh?.rotation?.y || 0, rotZ: data.rootMesh?.rotation?.z || 0, }); } } } } _restoreDynamicObjects() { if (!this._dynamicSnapshot) return; for (const snap of this._dynamicSnapshot) { if (snap.kind === 'block' && snap.mesh) { snap.mesh.position.x = snap.x; snap.mesh.position.y = snap.y; snap.mesh.position.z = snap.z; if (snap.mesh.rotation) { snap.mesh.rotation.x = snap.rotX || 0; snap.mesh.rotation.y = snap.rotY || 0; snap.mesh.rotation.z = snap.rotZ || 0; } snap.mesh.setEnabled(true); } else if (snap.kind === 'primitive' && snap.data) { snap.data.x = snap.x; snap.data.y = snap.y; snap.data.z = snap.z; if (snap.data.mesh) { snap.data.mesh.position.set(snap.x, snap.y, snap.z); if (snap.data.mesh.rotation) { snap.data.mesh.rotation.x = snap.rotX || 0; snap.data.mesh.rotation.y = snap.rotY || 0; snap.data.mesh.rotation.z = snap.rotZ || 0; } snap.data.mesh.setEnabled(snap.data.visible !== false); } } else if (snap.kind === 'model' && snap.data) { snap.data.x = snap.x; snap.data.y = snap.y; snap.data.z = snap.z; if (snap.data.rootMesh) { snap.data.rootMesh.position.set(snap.x, snap.y, snap.z); if (snap.data.rootMesh.rotation) { snap.data.rootMesh.rotation.x = snap.rotX || 0; snap.data.rootMesh.rotation.y = snap.rotY || 0; snap.data.rootMesh.rotation.z = snap.rotZ || 0; } snap.data.rootMesh.setEnabled(true); } } } this._dynamicSnapshot = null; } /** * Полный снимок сцены перед Play — примитивы и модели целиком. * При exitPlayMode сцена восстанавливается ровно к этому состоянию: * вернутся удалённые скриптом объекты, откатятся цвет/видимость/ * коллизия/поворот, исчезнут заспавненные скриптом объекты. * * Зачем: скрипты игр меняют сцену деструктивно (game.self.delete, * setColor, tween rotationY и т.д.). Без полного отката после * Stop→Play сцена остаётся «использованной» — собранные монетки * не появляются, открытая дверь остаётся открытой. Это как Stop * в Roblox Studio: сцена возвращается к авторскому виду. * * Блоки СЮДА НЕ входят — их скрипты практически не меняют, а полная * пересборка тысяч блоков дорогая. Падающие unanchored-блоки и так * откатываются через _restoreDynamicObjects (позиции). */ _snapshotFullScene() { this._fullSceneSnapshot = null; try { const snap = {}; if (this.primitiveManager) { snap.primitives = this.primitiveManager.serialize(); } if (this.modelManager) { snap.models = this.modelManager.serialize(); } this._fullSceneSnapshot = snap; } catch (e) { console.warn('[BabylonScene] _snapshotFullScene не удался:', e); this._fullSceneSnapshot = null; } } /** * Восстановить сцену из полного снимка после Play. * Пересоздаёт примитивы и модели точь-в-точь (id сохраняются — * addInstance принимает opts.id, поэтому скрипты на объектах после * рестарта снова найдут свои target.id). */ _restoreFullScene() { if (!this._fullSceneSnapshot) return; const snap = this._fullSceneSnapshot; this._fullSceneSnapshot = null; try { // Сбрасываем выделение — loadFromArray диспозит старые mesh, // selection не должен держать мёртвую ссылку. try { this.selection?.clear?.(); } catch (e) {} if (this.primitiveManager && Array.isArray(snap.primitives)) { this.primitiveManager.loadFromArray(snap.primitives); } if (this.modelManager && Array.isArray(snap.models)) { // loadFromArray у моделей async (модели грузятся с диска) — // не ждём, восстановление догрузится в фоне. Promise.resolve(this.modelManager.loadFromArray(snap.models)) .catch((e) => console.warn('[BabylonScene] откат моделей:', e)); } } catch (e) { console.warn('[BabylonScene] _restoreFullScene не удался:', e); } } /** Поменять anchored у выделенного объекта. */ setSelectedAnchored(anchored) { this.selection?.setSelectedAnchored(anchored); } /** === Окружение / Время суток / Аудио / Вода === */ setEnvironmentPreset(preset) { this.environment?.setPreset(preset); } setTimeOfDay(hour) { this.environment?.setTimeOfDay(hour); } setCycleDuration(dayMin, nightMin) { this.environment?.setCycleDuration(dayMin, nightMin); } setFog(enabled, color, density) { this.environment?.setFog(enabled, color, density); } getEnvironmentState() { return this.environment?.serialize() || null; } setAmbientAudio(opts) { this.audioManager?.setAmbient(opts); } setMusicAudio(opts) { this.audioManager?.setMusic(opts); } getAudioState() { return this.audioManager?.serialize() || null; } /** Доступные пресеты амбиента/музыки для UI. */ getAudioPresets() { return { ambient: AMBIENT_PRESETS || [], music: MUSIC_PRESETS || [] }; } /** Доступные модели игрока (категория «Персонажи»). */ getPlayerOptions() { // Импорт MODEL_TYPES сложен из engine, поэтому берём через _playerOptionsCache return this._playerOptionsCache || []; } setPlayerOptions(list) { this._playerOptionsCache = list; } /** Обновить пресет амбиента/музыки и обновить selection если открыт. */ setSoundProps(patch) { if (!patch) return; if (patch.ambientId !== undefined) { this.audioManager?.setAmbient({ preset: patch.ambientId }); } if (patch.musicId !== undefined) { this.audioManager?.setMusic({ preset: patch.musicId }); } if (this.selection?._selection?.type === 'sound') { this.selection.selectSound(); } } /** Обновить тип персонажа / силу прыжка / прицел. */ setPlayerProps(patch) { if (!patch) return; if (patch.playerModelType) { this.setPlayerModelType(patch.playerModelType); } if (typeof patch.jumpPower === 'number' && patch.jumpPower > 0) { this.setPlayerJumpPower(patch.jumpPower); } if (typeof patch.crosshair === 'string') { this.setCrosshair(patch.crosshair); } if (this.selection?._selection?.type === 'player') { this.selection.selectPlayer(); } } /** Поменять mass у выделенного объекта. */ setSelectedMass(mass) { this.selection?.setSelectedMass(mass); } /** Поменять свойства модели (canCollide / visible). */ setSelectedModelProps(patch) { if (!this.selection) return; const sel = this.selection.getSelection(); if (sel?.type === 'userModel') { this.selection.setSelectedUserModelProps(patch); return; } this.selection.setSelectedModelProps(patch); } /** Поменять свойства блока (canCollide / visible). */ setSelectedBlockProps(patch) { this.selection?.setSelectedBlockProps(patch); } /** === Папки/группы === */ createFolder(name = 'Новая папка', parentId = null) { return this.folderManager?.createFolder(name, parentId) ?? null; } renameFolder(id, name) { this.folderManager?.renameFolder(id, name); } /** Переименовать скрипт по id. Имя сохраняется в поле name. */ renameScript(id, name) { const i = this._scripts.findIndex(s => s.id === id); if (i < 0) return false; this._scripts[i] = { ...this._scripts[i], name: String(name || '').trim() || null }; this.history?.markChange(); if (this._onSceneChange) this._onSceneChange(); return true; } /** Переименовать инстанс модели. */ renameModel(instanceId, name) { const data = this.modelManager?.instances?.get(instanceId); if (!data) return false; data.name = String(name || '').trim() || null; if (this._onSceneChange) this._onSceneChange(); this.modelManager?._notifyChange?.(); return true; } /** Переименовать примитив. */ renamePrimitive(id, name) { const data = this.primitiveManager?.instances?.get(id); if (!data) return false; data.name = String(name || '').trim() || null; if (this._onSceneChange) this._onSceneChange(); this.primitiveManager?._notifyChange?.(); return true; } removeFolder(id, deleteContent = false) { this.folderManager?.removeFolder(id, deleteContent); } setFolderVisible(id, visible) { this.folderManager?.setVisible(id, visible); } assignToFolder(kind, ref, folderId) { this.folderManager?.assignToFolder(kind, ref, folderId); } /** Положить выделенное в указанную папку (или null = в корень). */ assignSelectionToFolder(folderId) { const sel = this.selection?.getSelection(); if (!sel) return; if (sel.type === 'block') { this.folderManager?.assignToFolder('block', { x: sel.gridX, y: sel.gridY, z: sel.gridZ }, folderId); } else if (sel.type === 'model') { this.folderManager?.assignToFolder('model', sel.instanceId, folderId); } else if (sel.type === 'primitive') { this.folderManager?.assignToFolder('primitive', sel.id, folderId); } } /** Undo. */ undo() { return this.history?.undo(); } /** Redo. */ redo() { return this.history?.redo(); } /** Можно ли откатиться. */ canUndo() { return !!this.history?.canUndo(); } /** Можно ли вернуть. */ canRedo() { return !!this.history?.canRedo(); } /** * Захватить превью-скриншот сцены как data URL (PNG, base64). * Используется для иконки проекта в «Мои игры». * size — размер квадратного превью в пикселях (по умолчанию 256). */ captureThumbnail(size = 256) { if (!this.canvas) return null; try { // Простейший способ — взять текущий canvas-buffer и масштабировать его // в новый offscreen canvas размера size×size. const out = document.createElement('canvas'); out.width = size; out.height = size; const ctx = out.getContext('2d'); // Чёрная заливка на случай прозрачности ctx.fillStyle = '#1a1410'; ctx.fillRect(0, 0, size, size); // Принудительный рендер чтобы backbuffer был свежим if (this.scene) this.scene.render(); // Сохраняем сохраняя пропорции — рисуем по короткой стороне const sw = this.canvas.width, sh = this.canvas.height; const minSide = Math.min(sw, sh); const sx = (sw - minSide) / 2; const sy = (sh - minSide) / 2; ctx.drawImage(this.canvas, sx, sy, minSide, minSide, 0, 0, size, size); return out.toDataURL('image/jpeg', 0.7); // JPEG-70% — ~10-30 КБ } catch (e) { // eslint-disable-next-line no-console console.error('[BabylonScene] thumbnail error:', e); return null; } } // === СОХРАНЕНИЕ И ЗАГРУЗКА =========================================== /** * Сериализовать сцену в JSON-объект для сохранения в БД. * Включает: блоки, модели, точку спавна, позицию редактор-камеры. */ /** * Подготовить мини-карту для уже загруженного проекта (когда нет * GeneratorParams). Считаем bbox реальных voxel'ов и сохраняем в * window.__lastGenSize, чтобы MinimapOverlay масштабировался правильно. * MinimapOverlay должна сама уметь рендерить real-data fallback. */ _setupMinimapForLoadedProject() { if (!this.terrainManager || !this.terrainManager.voxels) return; const voxels = this.terrainManager.voxels; if (voxels.size === 0) return; // Считаем bbox по X/Z. Берём max(|x|, |z|) как halfSize. let maxAbs = 0; for (const key of voxels.keys()) { const lastComma = key.lastIndexOf(','); const midComma = key.lastIndexOf(',', lastComma - 1); const x = parseInt(key.slice(0, midComma), 10); const z = parseInt(key.slice(lastComma + 1), 10); const ax = Math.abs(x); const az = Math.abs(z); if (ax > maxAbs) maxAbs = ax; if (az > maxAbs) maxAbs = az; } // maxAbs — в voxel-units. size для мини-карты = halfSize × 1.1 (запас). const size = Math.ceil(maxAbs * 1.1); window.__lastGenSize = size; // Если ничего не было сгенерировано — НЕ ставим __lastGenParams. // MinimapOverlay использует fallback на real-data top-down рендер. console.log(`[BabylonScene] minimap configured for loaded project: half-size=${size} voxel-units (${(size * 2 * 0.25).toFixed(0)}м)`); } /** * Подготовить мини-карту для гладкого ландшафта (RobloxTerrain). * * Воксельная миникарта (_setupMinimapForLoadedProject) читает * terrainManager.voxels — для smooth terrain их нет. Здесь публикуем * ссылку на density-grid в window.__robloxMinimapGrid, а MinimapOverlay * сам строит top-down heightmap из неё. * * Ставим _terrainStreamingEnabled=true чтобы MinimapOverlay стал visible * (он показывается по этому флагу), даже если у гладкого ландшафта * нет настоящего streaming-режима. */ _setupMinimapForRobloxTerrain() { const grid = this._robloxTerrain?.grid; if (!grid) return; // CELL_SIZE=4м. Полная ширина карты в метрах. const worldM = grid.size.x * 4; // MinimapOverlay масштаб: getWorldViewM() = size*2*0.25. // Чтобы worldViewM == worldM → size = worldM*2. window.__lastGenSize = worldM * 2; window.__lastGenParams = null; // не procedural-режим window.__robloxMinimapGrid = grid; // density-grid для real-data рендера this._terrainStreamingEnabled = true; // делает MinimapOverlay видимым // ВАЖНО: _worldHalf по умолчанию 40 — на больших гладких картах это // зажимало зомби/мобов в крошечный квадрат ±40 (они телепортировались // к центру и проваливались). Подгоняем под реальный размер карты. const half = Math.ceil(worldM / 2); if (this._worldHalf < half) { this._worldHalf = half; console.log(`[BabylonScene] _worldHalf -> ${half} (под размер гладкого ландшафта)`); } console.log(`[BabylonScene] minimap configured for RobloxTerrain: ${worldM}м (grid ${grid.size.x}×${grid.size.y}×${grid.size.z})`); } /** * Снять ТОЧНУЮ карту высот гладкого ландшафта (RobloxTerrain). * * Зачем: density-grid квантует высоту по 4м, а Surface Nets рендерит * сглаженную поверхность между ячейками — реальная видимая высота * отличается от грубой оценки по grid. Чтобы корректно ставить * объекты/блоки на землю, нужна высота РЕАЛЬНОГО меша. * * Метод raycast'ит сверху-вниз по мешам RobloxTerrain в сетке точек * (шаг step метров) и возвращает объект с картой высот. Используется * билд-скриптами игр для точного размещения. * * @param {number} step шаг сетки в метрах (по умолчанию 2) * @returns {object|null} { format, origin, worldSize, step, cols, rows, heights[] } * heights — плоский массив (rows × cols), значение = Y поверхности или null. */ exportRobloxHeightmap(step = 2) { const rt = this._robloxTerrain; if (!rt || !rt.grid) { console.warn('[exportRobloxHeightmap] нет гладкого ландшафта'); return null; } const grid = rt.grid; const CS = 4; // CELL_SIZE // Мировые границы карты по grid. const minWX = grid.origin.x * CS; const minWZ = grid.origin.z * CS; const worldX = grid.size.x * CS; const worldZ = grid.size.z * CS; const cols = Math.ceil(worldX / step) + 1; const rows = Math.ceil(worldZ / step) + 1; // Предикат: только меши гладкого ландшафта. const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); const heights = new Array(cols * rows).fill(null); let hit = 0; const t0 = performance.now(); for (let r = 0; r < rows; r++) { const wz = minWZ + r * step; for (let c = 0; c < cols; c++) { const wx = minWX + c * step; const ray = new Ray(new Vector3(wx, 5000, wz), new Vector3(0, -1, 0), 10000); const pick = this.scene.pickWithRay(ray, pickPred); if (pick && pick.hit && pick.pickedPoint) { heights[r * cols + c] = +pick.pickedPoint.y.toFixed(3); hit++; } } } const dt = (performance.now() - t0).toFixed(0); console.log(`[exportRobloxHeightmap] ${cols}×${rows} точек, ${hit} попаданий, ${dt}мс`); return { format: 'roblox-heightmap-v1', origin: { x: minWX, z: minWZ }, worldSize: { x: worldX, z: worldZ }, gridOrigin: { ...grid.origin }, gridSize: { ...grid.size }, step, cols, rows, heights, }; } serialize() { // Принадлежность объектов папкам — серилизуется в их собственных // данных (folderId), а сами папки в отдельном массиве. const blocksWithFolders = this.blockManager ? this.blockManager.serialize() : []; // BlockManager.serialize не знает про folderId — добавляем его поверх. if (this.blockManager) { for (const item of blocksWithFolders) { const mesh = this.blockManager.blocks.get(`${item.x},${item.y},${item.z}`); item.folderId = mesh?.metadata?.folderId ?? null; } } const modelsWithFolders = this.modelManager ? this.modelManager.serialize() : []; if (this.modelManager) { // Дописываем instanceId + folderId поверх стандартной сериализации // (которая уже включает type/x/y/z/rotationY/anchored/canCollide/visible/mass) const live = Array.from(this.modelManager.instances.values()); for (let i = 0; i < modelsWithFolders.length && i < live.length; i++) { modelsWithFolders[i].instanceId = live[i].instanceId; modelsWithFolders[i].folderId = live[i].folderId ?? null; } } const primitivesWithFolders = this.primitiveManager ? this.primitiveManager.getAll() : []; if (this.primitiveManager) { for (let i = 0; i < primitivesWithFolders.length; i++) { const id = primitivesWithFolders[i].id; const data = this.primitiveManager.instances.get(id); primitivesWithFolders[i].folderId = data?.folderId ?? null; } } // Terrain: RLE-формат для больших карт (×25 меньше чем legacy array). // На карте 250м: ~1.5МБ вместо ~38МБ. let terrainData; if (this.terrainManager) { const voxelCount = this.terrainManager.voxels?.size ?? 0; if (voxelCount > 5000 && typeof this.terrainManager.serializeRLE === 'function') { terrainData = this.terrainManager.serializeRLE(); } else { terrainData = this.terrainManager.serialize(); } } else { terrainData = []; } // Roblox smooth terrain — отдельная подсистема, сериализуется параллельно let robloxTerrainData = null; if (this._robloxTerrain && this._robloxTerrain.grid) { try { robloxTerrainData = this._robloxTerrain.serialize(); // Декорации сохраняем двумя способами: // - decoParams (seed + density) — для процедурной генерации // - decoInstances (полные матрицы) — для ручных правок plant-кистью // При load — приоритет у decoInstances если они есть. if (this._smoothDecoParams) { robloxTerrainData.decoParams = this._smoothDecoParams; } if (this._smoothDecoManager) { const stats = this._smoothDecoManager.getStats?.(); if (stats && stats.total > 0) { try { robloxTerrainData.decoInstances = this._smoothDecoManager.serialize(); } catch (e) { console.warn('smoothDeco serialize failed:', e); } } } } catch (e) { console.warn('robloxTerrain serialize failed:', e); } } return { version: 1, scene: { blocks: blocksWithFolders, models: modelsWithFolders, primitives: primitivesWithFolders, // Этап 5: пользовательские воксельные модели (созданные через // ModelEditorScreen). Каждая запись: { type:'user:42', x,y,z, ry }. userModels: this.userModelManager ? this.userModelManager.serialize() : [], terrain: terrainData, robloxTerrain: robloxTerrainData, decorations: this.decoManager ? this.decoManager.serialize() : [], folders: this.folderManager ? this.folderManager.serialize() : [], gui: this.guiManager ? this.guiManager.serialize() : [], inventory: this.inventory ? this.inventory.serialize() : null, spawnPoint: { ...this._spawnPoint }, playerModelType: this._playerModelType, skins: this._skinsConfig ? { default: this._skinsConfig.default || null, unlocked: this._skinsConfig.unlocked || [], shopVisible: this._skinsConfig.shopVisible !== false, coins: this._skinsConfig.coins || 0, customGlbs: this._skinsConfig.customGlbs || [], } : undefined, worldSize: this._worldHalf * 2, floorEnabled: this._floorEnabled !== false, jumpPowerMul: this._jumpPowerMul ?? 1, cameraMode: this._defaultCameraMode || 'third', crosshair: this._crosshair || 'dot', shadowQuality: this._shadowQuality || 'soft', environment: this.environment ? this.environment.serialize() : null, audio: this.audioManager ? this.audioManager.serialize() : null, // Библиотека пользовательских картинок (текстуры/GUI-image). assets: this.assetManager ? this.assetManager.serialize() : [], // Библиотека пользовательских звуков (Фаза 5.5). sounds: this.soundLibrary ? this.soundLibrary.serialize() : [], // Импортированные .glb-модели (Фаза 5.8). glbModels: this.glbLibrary ? this.glbLibrary.serialize() : [], // ЭТАП 2.1: пропускаем demo-скрипт (он добавляется автоматически // при загрузке если у проекта нет своих скриптов). scripts: this._scripts .filter(s => s.id !== 'demo') .map(s => ({ id: s.id, code: s.code, target: s.target || null, name: s.name || null, })), }, editorCamera: this.camera ? { position: { x: this.camera.position.x, y: this.camera.position.y, z: this.camera.position.z }, rotation: { x: this.camera.rotation.x, y: this.camera.rotation.y, z: this.camera.rotation.z }, } : null, settings: { // GD-проект (Geometry Dash) — включает GD-визуал в Play. // Раньше определялось по диапазону id (296-395), но диапазон // зарезервирован с запасом — обычные проекты туда попадали. // Теперь — явный флаг в данных проекта. isGd: this._isGdProject === true, }, }; } /** * Восстановить сцену из ранее сохранённого state. * Очищает существующие блоки/модели, создаёт заново. * Возвращает promise (async из-за загрузки моделей). */ async loadFromState(state) { if (!state || !state.scene) return; // Флаг GD-проекта — из settings. Если в данных проекта флага нет // (старые проекты до введения флага) — _isGdProject останется // undefined, и enterPlayMode сделает fallback на диапазон id. if (state.settings && typeof state.settings.isGd === 'boolean') { this._isGdProject = state.settings.isGd; } // Библиотека пользовательских картинок — грузим РАНЬШЕ примитивов, // чтобы при создании примитива с textureAsset текстура уже была доступна. if (this.assetManager) { this.assetManager.load(Array.isArray(state.scene.assets) ? state.scene.assets : []); } // Библиотека пользовательских звуков (Фаза 5.5). if (this.soundLibrary) { this.soundLibrary.load(Array.isArray(state.scene.sounds) ? state.scene.sounds : []); } // Импортированные .glb-модели (Фаза 5.8) — грузим РАНЬШЕ моделей, // чтобы при addInstance('glb:N') библиотека была готова. if (this.glbLibrary) { this.glbLibrary.load(Array.isArray(state.scene.glbModels) ? state.scene.glbModels : []); } // Размер пола — пересоздаём пол если у проекта он другой if (typeof state.scene.worldSize === 'number' && state.scene.worldSize > 0) { this.setWorldSize(state.scene.worldSize); } if (typeof state.scene.floorEnabled === 'boolean') { this.setFloorEnabled(state.scene.floorEnabled); } if (typeof state.scene.jumpPowerMul === 'number' && state.scene.jumpPowerMul > 0) { this.setPlayerJumpPower(state.scene.jumpPowerMul); } if (typeof state.scene.crosshair === 'string') { this.setCrosshair(state.scene.crosshair); } // Камера по умолчанию ('first' / 'third' / 'front'). Применяется при enterPlayMode. if (typeof state.scene.cameraMode === 'string') { this._defaultCameraMode = state.scene.cameraMode; } // Качество теней if (state.scene.shadowQuality) { this.setShadowQuality(state.scene.shadowQuality); } // Блоки — синхронно if (this.blockManager && Array.isArray(state.scene.blocks)) { this.blockManager.loadFromArray(state.scene.blocks); } // Террейн (voxel-ландшафт). Поддерживаем 2 формата: // 1. Legacy: terrain = [{x,y,z,m}, ...] — старые проекты // 2. RLE-v1: terrain = {format:'rle-v1', palette, chunks:{base64}} // — новый формат для больших карт (×25 меньше) const ts = state.scene.terrain; // Прогресс-индикатор: глобальный объект, KubikonEditor.jsx читает в polling const setProgress = (percent, label) => { if (typeof window !== 'undefined') { window.__kubikonLoadProgress = { percent, label, ts: performance.now() }; } }; setProgress(2, 'Подготовка сцены…'); if (this.terrainManager && ts) { const tLoad0 = performance.now(); if (Array.isArray(ts)) { // Legacy формат const tCount = ts.length; console.log(`[BabylonScene] LOAD terrain (legacy): ${tCount} voxels`); await this.terrainManager.loadFromArray(ts, (loaded, total) => { if (total > 5000 && loaded % 10000 < 2001) { console.log(`[BabylonScene] terrain: ${loaded}/${total}`); // 5-40% — заливка вокселей setProgress(5 + Math.floor((loaded / total) * 35), `Размещение блоков: ${loaded.toLocaleString()} / ${total.toLocaleString()}`); } }); } else if (ts.format === 'rle-v1' && typeof this.terrainManager.loadFromRLE === 'function') { // RLE формат const chunkCount = Object.keys(ts.chunks || {}).length; console.log(`[BabylonScene] LOAD terrain (RLE): ${chunkCount} chunks, palette=${(ts.palette || []).length - 1}`); await this.terrainManager.loadFromRLE(ts, (loaded, total) => { if (loaded % 16 === 0) { console.log(`[BabylonScene] terrain RLE: chunk ${loaded}/${total}`); // 5-40% — распаковка RLE setProgress(5 + Math.floor((loaded / total) * 35), `Распаковка карты: ${loaded} / ${total} чанков`); } }); } else { console.warn('[BabylonScene] unknown terrain format:', ts); } setProgress(75, 'Сборка геометрии регионов…'); const tLoad1 = performance.now(); const finalVoxelCount = this.terrainManager.voxels?.size ?? 0; const regionCount = this.terrainManager.getRegionCount?.() ?? 0; console.log(`[BabylonScene] LOAD done in ${(tLoad1 - tLoad0).toFixed(0)}ms: ${finalVoxelCount} voxels → ${regionCount} regions`); // Shadow-load в VoxelWorld — ТОЛЬКО для маленьких карт. // На больших (>30K voxels) это лишняя нагрузка (память + время). // Если когда-то перейдём на новый рендер — сами решим shadow-load заново. if (Array.isArray(ts) && finalVoxelCount > 0 && finalVoxelCount < 30000) { try { this.voxelWorld.loadLegacyTerrain(ts); const s = this.voxelWorld.stats(); console.log(`[VoxelWorld] shadow-loaded: ${s.totalVoxels} voxels in ${s.totalChunks} chunks`); } catch (e) { console.warn('[VoxelWorld] shadow-load failed:', e); } } else if (finalVoxelCount >= 30000) { console.log(`[VoxelWorld] shadow-load SKIPPED (${finalVoxelCount} voxels > 30000 — экономим память)`); } // === АВТО-STREAMING для загруженных больших проектов === if (regionCount > 0) { this._terrainStreamingEnabled = true; // Адаптивный radius (2026-05-27 увеличен): на средних картах // 1-3M voxels старый radius 28-32м был слишком мал для // замкнутых объектов (вулкан) — дальние регионы стенки не // материализуются, игрок видит «полупрозрачные» стены. let radius = 80; if (finalVoxelCount > 5_000_000) radius = 40; else if (finalVoxelCount > 3_000_000) radius = 55; else if (finalVoxelCount > 1_000_000) radius = 70; this._terrainStreamingRadius = radius; this._terrainStreamingLastUpdate = 0; // Автотуман для скрытия границы streaming. Без него видно // резкий обрыв террейна на радиусе. // // Density подбираем по editor-radius: чем больше radius, // тем дальше начало тумана. Для radius=72м: 0.005 — туман // едва заметен в ближнем плане, но прячет обрыв на 70м. try { if (this.scene) { const camY = this.camera?.position.y || 0; const heightBonus = Math.max(0, Math.min(20, camY * 0.3)); const effectiveRadius = Math.min(60, this._terrainStreamingRadius * 1.3 + heightBonus); // Эмпирически: fogDensity ≈ 0.5/radius работает. // На radius=72м → 0.007 (туман на 70-90м) // На radius=40м → 0.0125 (туман на 40-60м, как раньше) const density = Math.max(0.004, Math.min(0.014, 0.5 / effectiveRadius)); this.scene.fogMode = 2; // FOGMODE_EXP this.scene.fogColor = new Color3(0.55, 0.7, 0.85); // светло-голубой this.scene.fogDensity = density; } } catch (e) {} // Сразу первый pass с editor-radius формулой (см. render loop). if (this.camera) { const camY = this.camera.position.y || 0; const heightBonus = Math.max(0, Math.min(20, camY * 0.3)); const editorRadius = Math.min(60, this._terrainStreamingRadius * 1.3 + heightBonus); const r = this.terrainManager.updateStreaming( this.camera.position.x, this.camera.position.z, editorRadius, ); console.log(`[BabylonScene] auto-streaming ON: ${r.enabled}/${r.total} regions visible (editor radius=${editorRadius.toFixed(0)}m, play radius=${this._terrainStreamingRadius}m)`); } this._setupMinimapForLoadedProject(); } else { console.log(`[BabylonScene] streaming NOT enabled (regionCount=0). Карта маленькая или region-split не сработал.`); } } // === Загрузка Roblox smooth terrain (параллельная подсистема) === // Если в проекте есть robloxTerrain — создаём менеджер и загружаем grid. const rts = state.scene.robloxTerrain; if (rts && rts.format === 'robloxterrain-v1') { try { setProgress(90, 'Загрузка гладкого ландшафта…'); if (!this._robloxTerrain) { this._robloxTerrain = new RobloxTerrain(this.scene); if (this.physics?.setRobloxTerrain) { this.physics.setRobloxTerrain(this._robloxTerrain); } } this._robloxTerrain.loadFromState(rts); // Сразу материализуем chunks вокруг камеры const camX = this.camera?.position.x || 0; const camZ = this.camera?.position.z || 0; const r = this._robloxTerrain.updateStreaming(camX, camZ, 99999, { maxBuild: 9999 }); const stats = this._robloxTerrain.getStats(); console.log(`[BabylonScene] LOAD robloxTerrain: ${r.built} chunks, ${stats.triangles} triangles`); // Включаем мини-карту для гладкого ландшафта — MinimapOverlay // показывается по флагу _terrainStreamingEnabled, а heightmap // строит из density-grid (window.__robloxMinimapGrid). this._setupMinimapForRobloxTerrain(); // Если есть RobloxTerrain — отключаем baseplate-floor чтобы // не создавал ложных коллизий под smooth-ландшафтом. // НО только если рельеф большой (≥500 cells) — иначе baseplate // нужен для визуального ориентирования и для plant-decos // которые ставятся на y=0. if (stats.solidCells >= 500) { try { this.setFloorEnabled(false); } catch (e) {} } // === Загрузка smooth-decorations === // Приоритет 1: decoInstances (точные матрицы, ручные правки plant-кистью) // Приоритет 2: decoParams (seed-based процедурная генерация) if (rts.decoInstances && rts.decoInstances.items?.length > 0) { try { if (!this._smoothDecoManager) { this._smoothDecoManager = new SmoothDecoManager(this.scene); } const r = await this._smoothDecoManager.loadFromState(rts.decoInstances); const total = r?.total ?? 0; const tc = r?.treeColliders ?? []; console.log(`[BabylonScene] LOAD smooth decorations (instances): ${total} (${tc.length} tree colliders)`); if (this.physics?.setSmoothDecoTrees) { this.physics.setSmoothDecoTrees(tc); } } catch (err) { console.error('[BabylonScene] smooth decoInstances load failed:', err); } } else if (rts.decoParams) { try { const dp = rts.decoParams; this._smoothDecoParams = dp; if (!this._smoothDecoManager) { this._smoothDecoManager = new SmoothDecoManager(this.scene); } await this._smoothDecoManager.loadAll(); const decoGen = new WorldGenerator(dp.genParams); const sampleSurfaceY = (x, z) => { if (!this.physics?._sampleRobloxSurface) return null; return this.physics._sampleRobloxSurface(x, z); }; const sampleBiomeId = (x, z) => { const b = decoGen.sampleBiome(x * 4, z * 4); return b?.id; }; const r2 = this._smoothDecoManager.placeDecorations({ sampleSurfaceY, sampleBiomeId, bbox: dp.bbox, densityFlowers: dp.flowersDensity, densityGrass: dp.grassDensity, densityTrees: dp.treesDensity ?? 0, seed: dp.seed, }); console.log(`[BabylonScene] LOAD smooth decorations (params): ${r2.total} instances`); if (this.physics?.setSmoothDecoTrees && r2.treeColliders) { this.physics.setSmoothDecoTrees(r2.treeColliders); } } catch (err) { console.error('[BabylonScene] smooth decorations load failed:', err); } } } catch (err) { console.error('[BabylonScene] robloxTerrain load failed:', err); } } setProgress(95, 'Размещение декораций…'); // Этап 6: загрузка decorations (цветы/грибы/трава мини-вокселями). if (this.decoManager && Array.isArray(state.scene.decorations)) { this.decoManager.loadFromArray(state.scene.decorations); // Этап D LOD: первый pass streaming для деко. // maxBuild=2 — строим только 2 ближайших chunk сразу (карта появляется // мгновенно). Остальные доступные подгружаются по 2-4 за тик // (200мс), весь видимый набор за 1-2 секунды. UI не блокируется. if (this.camera && this.decoManager.updateStreaming) { const decoRadius = Math.max(18, (this._terrainStreamingRadius || 40) * 0.35); const r = this.decoManager.updateStreaming( this.camera.position.x, this.camera.position.z, decoRadius, { maxBuild: 2 }, ); console.log(`[BabylonScene] deco streaming ON: ${r.visible}/${r.total} chunks visible (radius=${decoRadius.toFixed(0)}m)`); } } // Модели — асинхронно (GLB подгружаются) if (this.modelManager && Array.isArray(state.scene.models)) { await this.modelManager.loadFromArray(state.scene.models); } // Этап 5: пользовательские воксельные модели — асинхронно // (model_data грузится через API). Каждый item: {type:'user:42', x,y,z,ry,scale,canCollide,...}. if (this.userModelManager && Array.isArray(state.scene.userModels) && state.scene.userModels.length > 0) { const loaded = await this.userModelManager.loadFromArray( state.scene.userModels, { currentUserId: this._currentUserId }, ); console.log(`[BabylonScene] user models loaded: ${loaded}/${state.scene.userModels.length}`); // Регистрируем коллайдеры в физике this._syncUserModelColliders(); } // ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже — // PlayerController.start() её ждёт, но если предзагрузить сейчас, // на enterPlayMode она будет в кэше Babylon и стартует мгновенно. // ВАЖНО: R15-скины ('skin_*') — отдельная система (characters// // body.glb + манифест), ModelManager их не знает. Их грузит сам // PlayerController через _loadSkinManifest — здесь пропускаем, // иначе ModelManager пишет в консоль 'Unknown model type'. try { const playerModelType = this._playerModelType || 'character-a'; if (!String(playerModelType).startsWith('skin_')) { await this.modelManager?._loadPrototype?.(playerModelType); } } catch (e) { /* ignore */ } // Примитивы — синхронно if (this.primitiveManager && Array.isArray(state.scene.primitives)) { this.primitiveManager.loadFromArray(state.scene.primitives); } // Папки + восстановление folderId на всех объектах if (this.folderManager) { this.folderManager.loadFromArray(state.scene.folders || []); } // GUI-элементы if (this.guiManager) { this.guiManager.loadFromArray(state.scene.gui || []); } // Инвентарь if (this.inventory) { this.inventory.loadFromArray(state.scene.inventory || null); } // Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле) if (this.blockManager && Array.isArray(state.scene.blocks)) { for (const b of state.scene.blocks) { if (b.folderId == null) continue; const mesh = this.blockManager.blocks.get(`${b.x},${b.y},${b.z}`); if (mesh && mesh.metadata) mesh.metadata.folderId = b.folderId; } } if (this.modelManager && Array.isArray(state.scene.models)) { // ModelManager.loadFromArray генерирует новые instanceId, // поэтому folderId восстанавливаем по индексу (порядку). const arr = state.scene.models; const liveIds = Array.from(this.modelManager.instances.keys()); for (let i = 0; i < arr.length && i < liveIds.length; i++) { if (arr[i].folderId == null) continue; const data = this.modelManager.instances.get(liveIds[i]); if (data) data.folderId = arr[i].folderId; } } if (this.primitiveManager && Array.isArray(state.scene.primitives)) { // primitiveManager после loadFromArray генерирует новые id, поэтому // восстановим folderId по индексу (порядку) — он совпадает. const arr = state.scene.primitives; const liveIds = Array.from(this.primitiveManager.instances.keys()); for (let i = 0; i < arr.length && i < liveIds.length; i++) { if (arr[i].folderId == null) continue; const data = this.primitiveManager.instances.get(liveIds[i]); if (data) data.folderId = arr[i].folderId; } } // После расстановки folderId — применим эффективную видимость папок if (this.folderManager) { for (const f of this.folderManager.getAll()) { this.folderManager._applyVisibility(f.id, this.folderManager._effectiveVisible(f.id)); } } // Зарегистрировать все объекты как shadow casters this.refreshAllShadows(); // Точка спавна if (state.scene.spawnPoint) { this._spawnPoint = { ...state.scene.spawnPoint }; this._updateSpawnMarker(); } // === Авто-fix спавна для smooth terrain === // Если есть RobloxTerrain и spawnPoint оказался НИЖЕ поверхности — // поднимаем его, чтобы игрок не провалился в момент нажатия "Запустить". // Иначе raycast pickWithRay возвращает hit В ОБОИХ направлениях (mesh // обволакивает AABB), физика застревает в UNSTUCK-цикле и игрок падает // в бездну. try { if (this._robloxTerrain && (this._robloxTerrain.getStats?.().solidCells ?? 0) > 0 && this.physics?._sampleRobloxSurface) { const surfaceY = this.physics._sampleRobloxSurface(this._spawnPoint.x, this._spawnPoint.z); if (surfaceY !== null && this._spawnPoint.y < surfaceY + 1) { const newY = surfaceY + 2; console.log(`[BabylonScene] spawn auto-lifted: y ${this._spawnPoint.y.toFixed(2)} → ${newY.toFixed(2)} (surface=${surfaceY.toFixed(2)})`); this._spawnPoint.y = newY; this._updateSpawnMarker(); } else if (surfaceY === null) { console.warn('[BabylonScene] spawn auto-lift: no surface found under spawn'); } } } catch (e) { console.warn('[BabylonScene] spawn auto-lift failed:', e); } // Тип модели персонажа. // Миграция: старые проекты сохраняли Kenney-модель ('character-a..g'). // Теперь стандарт — R15-скин bacon-hair. Если в проекте старая // Kenney-модель — форсим bacon-hair. Явно выбранные 'skin_*' не трогаем. if (state.scene.playerModelType) { const pmt = state.scene.playerModelType; if (pmt.startsWith('character-')) { this._playerModelType = 'skin_bacon-hair'; } else { this._playerModelType = pmt; } } // Задача 07: конфиг скинов проекта { default, unlocked, shopVisible, coins, customGlbs }. if (state.scene.skins && typeof state.scene.skins === 'object') { this._skinsConfig = { default: state.scene.skins.default || null, unlocked: Array.isArray(state.scene.skins.unlocked) ? state.scene.skins.unlocked.slice() : [], shopVisible: state.scene.skins.shopVisible !== false, coins: Number.isFinite(state.scene.skins.coins) ? state.scene.skins.coins : 0, customGlbs: Array.isArray(state.scene.skins.customGlbs) ? state.scene.skins.customGlbs.slice() : [], }; // Стартовый скин из skins.default имеет приоритет над playerModelType. if (this._skinsConfig.default) { const d = this._skinsConfig.default; this._playerModelType = d.startsWith('character-') || d.startsWith('skin_') || d.startsWith('customskin:') ? d : ('skin_' + d); } } else { this._skinsConfig = null; } // Пользовательские скрипты if (Array.isArray(state.scene.scripts)) { this._scripts = state.scene.scripts .filter(s => s && typeof s.code === 'string') .map(s => ({ id: s.id || `script_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, code: s.code, target: s.target || null, name: s.name || null, })); } // Окружение (время суток, скайбокс, туман) if (state.scene.environment && this.environment) { this.environment.load(state.scene.environment); } // Аудио (фоновая музыка/амбиент) if (state.scene.audio && this.audioManager) { this.audioManager.load(state.scene.audio); } // Редактор-камера if (state.editorCamera && this.camera) { const c = state.editorCamera; if (c.position) this.camera.position = new Vector3(c.position.x, c.position.y, c.position.z); if (c.rotation) this.camera.rotation = new Vector3(c.rotation.x, c.rotation.y, c.rotation.z); } // Финальный прогресс — UI скроет overlay if (typeof window !== 'undefined') { window.__kubikonLoadProgress = { percent: 100, label: 'Готово!', ts: performance.now() }; } } /** * Задача 08: активировать pointer-примитивы из палитры в реальные стрелки. * Маркер-сфера прячется, через BeamManager создаётся анимированная стрелка * от источника (игрок/объект) к цели. from/to берутся из инспектора. */ _activatePointers() { const pm = this.primitiveManager; const bm = this.beamManager; if (!pm || !bm) return; let cnt = 0; for (const inst of pm.instances.values()) { if (inst.type !== 'pointer') continue; cnt++; try { if (inst.mesh) inst.mesh.setEnabled(false); } catch (e) {} const at = { x: inst.x, y: inst.y, z: inst.z }; const from = this._pointerRefOrPoint(inst.pointerFrom, at); const to = this._pointerRefOrPoint(inst.pointerTo, { x: at.x, y: at.y, z: at.z + 4 }); try { const pid = bm.addPointer({ from, to, preset: inst.pointerPreset || 'guide', color: inst.color, textureSpeed: inst.textureSpeed, curved: inst.curved, curveHeight: inst.curveHeight, }); } catch (e) { } } } /** from/to стрелки: 'player' | id примитива/модели → ref | точка-fallback. */ _pointerRefOrPoint(val, fallbackPoint) { if (val === 'player') return 'player'; if (val != null && val !== '') { const n = Number(val); if (Number.isFinite(n)) { if (this.primitiveManager?.instances?.has(n)) return 'primitive:' + n; if (this.modelManager?.instances?.has(n)) return 'model:' + n; } if (typeof val === 'string' && (val.startsWith('primitive:') || val.startsWith('model:'))) return val; } return fallbackPoint; } /** Выйти из режима игры — восстановить редактор-камеру. */ exitPlayMode() { if (!this._isPlaying) return; this._isPlaying = false; // Задача 04: закрываем любой активный модал чтобы не «висел» при стопе try { this.modalManager?._instantClose?.(); } catch (e) {} // Сбрасываем таймер прохождения this._timerRunning = false; this._timerStartedAt = null; // Отключаем picking voxel-террейна обратно (нужно только в play). try { this.terrainManager?.enablePickingForCamera?.(false); } catch (e) {} // Phase 6.4: чистим скрипт-созданные кастомные tool из инвентаря, // чтобы они не накапливались между Play. Обычное оружие остаётся. if (this.inventory) { for (let i = 0; i < this.inventory.slots.length; i++) { const s = this.inventory.slots[i]; if (s && s.params && s.params._customToolId) { this.inventory.removeSlot(i); } } } // Размораживаем world-matrix статичных моделей — в редакторе // пользователь может двигать их через гизмо. try { this.modelManager?.unfreezeStaticModels?.(); } catch (e) {} try { this.primitiveManager?.unfreezeStaticPrimitives?.(); } catch (e) {} // Возвращаем все примитивы в видимое состояние (LOD-cull сбрасывается) if (this.primitiveManager) { for (const data of this.primitiveManager.instances.values()) { const m = data.mesh; if (m && m._kubikonPrimCulled === true) { m.setEnabled(data.visible !== false); m._kubikonPrimCulled = false; } } } // Останавливаем пользовательские скрипты ПЕРЕД уничтожением player'а, // чтобы скрипты не успели потрогать player в момент disposal. if (this.gameRuntime) { this.gameRuntime.stop(); this.gameRuntime = null; } // Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением) if (this.gdLevelManager) { this.gdLevelManager.stop(); } // Этап G1: убрать skybox/параллакс (откатывает fog/clearColor) if (this.gdSkybox) { try { this.gdSkybox.dispose(); } catch (e) {} this.gdSkybox = null; } // Этап G2: убрать декоративную траву + neon-edge if (this.gdGroundSkin) { try { this.gdGroundSkin.dispose(); } catch (e) {} this.gdGroundSkin = null; } // Этап G3: убрать кастомные шипы, вернуть оригинальные конусы if (this.gdSpikes) { try { this.gdSpikes.dispose(); } catch (e) {} this.gdSpikes = null; } // Этап G4: убрать стартовую арку if (this.gdStartArch) { try { this.gdStartArch.dispose(); } catch (e) {} this.gdStartArch = null; } // Этап G5: убрать финиш-ворота if (this.gdFinish) { try { this.gdFinish.dispose(); } catch (e) {} this.gdFinish = null; } // Этап G6: убрать деревья/кусты if (this.gdForest) { try { this.gdForest.dispose(); } catch (e) {} this.gdForest = null; } // Этап G7: снять эффекты с куба игрока if (this.gdPlayerCube) { try { this.gdPlayerCube.dispose(); } catch (e) {} this.gdPlayerCube = null; } // Этап G8: trail-частицы if (this.gdPlayerTrail) { try { this.gdPlayerTrail.dispose(); } catch (e) {} this.gdPlayerTrail = null; } // Этап G9: пост-обработка (bloom/vignette/освещение) if (this.gdPostFx) { try { this.gdPostFx.dispose(); } catch (e) {} this.gdPostFx = null; } // Выключаем оружие if (this.weapons) { this.weapons.stop(); } // Выключаем зомби и спавнеры if (this.spawnerManager) this.spawnerManager.stop(); if (this.zombieManager) this.zombieManager.stop(); // Выключаем NPC (удаляет их модели и UI). if (this.npcManager) this.npcManager.stop(); // Выключаем связи объектов. if (this.constraintManager) this.constraintManager.stop(); // Выключаем лучи и следы. if (this.beamManager) this.beamManager.stop(); // Выключаем 3D-звук (останавливает активные звуки). if (this.soundManager) this.soundManager.stop(); if (this.player) { this.player.stop(); this.player = null; } // Возвращаем визуальный маркер спавна this._setSpawnMarkerVisible(true); this.primitiveManager?.setTriggersVisible(true); // Останавливаем физику и возвращаем объекты на исходные позиции this.dynamics?.stop(); this._restoreDynamicObjects(); // Полный откат сцены: пересоздаём примитивы и модели из снимка — // возвращаются удалённые скриптом объекты, откатываются цвет/ // видимость/коллизия/повороты, исчезают заспавненные объекты. this._restoreFullScene(); // Останавливаем фоновый звук this.audioManager?.stop(); // Восстанавливаем редактор-камеру const snap = this._editorCameraSnapshot; // Создаём новую UniversalCamera-редактор (наша старая была уничтожена когда // PlayerController сделал scene.activeCamera = playerCamera). // На самом деле она НЕ уничтожилась — мы просто переключали activeCamera. // Возвращаем её обратно. this.scene.activeCamera = this.camera; if (snap) { this.camera.position = snap.position; this.camera.rotation = snap.rotation; } this._editorCameraSnapshot = null; } dispose() { if (this._resizeHandler) { window.removeEventListener('resize', this._resizeHandler); this._resizeHandler = null; } if (this._ro) { this._ro.disconnect(); this._ro = null; } for (const { target, type, fn, opts } of this._listeners) { target.removeEventListener(type, fn, opts); } this._listeners = []; if (this.player) { this.player.stop(); this.player = null; } if (this.history) { this.history.dispose(); this.history = null; } if (this._gizmo) { try { this._gizmo.dispose(); } catch (e) { /* ignore */ } this._gizmo = null; } if (this._gizmoLayer) { try { this._gizmoLayer.dispose(); } catch (e) { /* ignore */ } this._gizmoLayer = null; } if (this.selection) { this.selection.dispose(); this.selection = null; } if (this.blockManager) { this.blockManager.dispose(); this.blockManager = null; } if (this.modelManager) { this.modelManager.dispose(); this.modelManager = null; } if (this.primitiveManager) { this.primitiveManager.dispose(); this.primitiveManager = null; } if (this.folderManager) { this.folderManager.dispose(); this.folderManager = null; } if (this.guiManager) { this.guiManager.clear(); this.guiManager = null; } if (this.inventory) { this.inventory.clear(); this.inventory = null; } if (this.dynamics) { this.dynamics.dispose(); this.dynamics = null; } if (this.audioManager) { this.audioManager.dispose(); this.audioManager = null; } if (this.assetManager) { this.assetManager.dispose(); this.assetManager = null; } if (this.soundLibrary) { this.soundLibrary.dispose(); this.soundLibrary = null; } if (this.glbLibrary) { this.glbLibrary.dispose(); this.glbLibrary = null; } this.environment = null; this.physics = null; if (this.scene) { this.scene.dispose(); this.scene = null; } if (this.engine) { this.engine.dispose(); this.engine = null; } } }