/**
 * 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,
    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 { PlacementManager } from './PlacementManager';
import { ShopInventoryUi } from './ShopInventoryUi';
import { LoadingScreenOverlay } from './LoadingScreenOverlay';
import { VehicleManager } from './VehicleManager';
import { VehicleHud } from './VehicleHud';
import { ZombieSpawnerManager } from './ZombieSpawnerManager';
import { DynamicsManager } from './DynamicsManager';
import { Environment } from './Environment';
import { SkyboxManager } from './SkyboxManager';
import { LeaderstatsManager } from './LeaderstatsManager';
import { AchievementsManager } from './AchievementsManager';
import { FloaterManager } from './FloaterManager';   // задача 40 — damage floaters
import { InventoryUI } from './InventoryUI';         // задача 44 — drag-drop инвентарь
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 { GraphicsManager } from './GraphicsManager';
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-элемент <canvas> для рендера
     */
    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)
        // Placement mode (задача 11) — фича-парность со студией.
        this.placementManager = null;
        this.shopInventoryUi = null;
        this.vehicleManager = null;       // задача 14
        this.vehicleHud = null;
        this._VehicleHudClass = VehicleHud;
        this._PlacementManagerClass = PlacementManager;
        this._ShopInventoryUiClass = ShopInventoryUi;
        // Экран загрузки (задача 12).
        this.loadingScreen = null;
        this._LoadingScreenOverlayClass = LoadingScreenOverlay;
        this._loadingConfig = null;
        this._mainMenuConfig = null;      // задача 13
        this._projectThumbnail = null;
        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.
        // 2026-06-13: дефолт сменён на skin_y-bot (Mixamo Y-Bot,
        // нейтральный по полу). Старые скины (skin_bacon-hair и др.) удалены.
        this._playerModelType = 'skin_y-bot';
        // Размер пола: пол идёт от -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 preset). (0.3,0.3,0.3) даёт мягкое всестороннее освещение,
        // не пересвечивает существующие сцены.
        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;
            };
            // === LEAK DETECTOR (dev-only) ===
            // Если sceneMeshes растёт без явной причины — каждый snapshot
            // запоминаем имена мешей, на след. snapshot печатаем НОВЫЕ.
            // Точно покажет утечку: какие меши накапливаются.
            // Использовать: window.__leakSnap(); потом подождать 5 сек,
            // снова window.__leakSnap() — выведет diff.
            let _leakLastNames = null;
            window.__leakSnap = () => {
                const names = this.scene.meshes.map(m => m.name || '<unnamed>');
                if (_leakLastNames === null) {
                    _leakLastNames = new Map();
                    for (const n of names) _leakLastNames.set(n, (_leakLastNames.get(n) || 0) + 1);
                    console.log('[LeakSnap] baseline:', names.length, 'мешей. Подожди 5+ сек и зови __leakSnap() снова.');
                    return;
                }
                const cur = new Map();
                for (const n of names) cur.set(n, (cur.get(n) || 0) + 1);
                const diff = {};
                let totalDiff = 0;
                for (const [n, c] of cur) {
                    const prev = _leakLastNames.get(n) || 0;
                    if (c > prev) { diff[n] = `+${c - prev} (теперь ${c})`; totalDiff += c - prev; }
                }
                for (const [n, c] of _leakLastNames) {
                    if (!cur.has(n)) { diff[n] = `-${c} (удалён)`; totalDiff -= c; }
                }
                console.log('[LeakSnap] diff:', totalDiff > 0 ? `+${totalDiff}` : totalDiff, diff);
                _leakLastNames = cur;
            };
            // Автомониторинг 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);
        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);
        // Сразу синхронизируем границу пола с текущим размером мира,
        // иначе при дефолтных 40 игрок проваливается на больших картах
        // ещё до первого setWorldSize().
        this.physics.floorHalf = this._worldHalf;
        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);
        this.vehicleManager = new VehicleManager(this);   // задача 14
        // Этап 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.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight);   // задача 16 — кастомное небо (единый источник света)
        this.floaters = new FloaterManager(this);            // задача 40 — damage floaters
        this.invUI = new InventoryUI(this);                  // задача 44 — drag-drop инвентарь
        this.leaderstats = new LeaderstatsManager(this);     // задача 20 — лидерборды
        this.achievements = new AchievementsManager(this);   // задача 20 — достижения
        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);

        // 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);
                }
                // Небо: дрейф облаков + fadeTo
                if (this.skybox) {
                    this.skybox.tick(dt);
                }
                // Лидерборды (задача 20) — рендер HUD-таблицы при изменениях.
                if (this._isPlaying && this.leaderstats) {
                    this.leaderstats.tick();
                }
                // Damage floaters (задача 40) — анимация всплывающих цифр.
                if (this.floaters) {
                    this.floaters.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) {}
                }
                // Задача 12: loadingScreen.tick — fade/auto-duration независимо от paused.
                if (this._isPlaying && this.loadingScreen?.tick) {
                    try { this.loadingScreen.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 — Screen-Space Ambient Occlusion (контактные тени в углах,
        // под объектами и в стыках). По умолчанию выключен — это дорогой
        // пост-эффект (-15..30% FPS). Включается через setSsaoEnabled(true) /
        // setLightingProps({ ssaoEnabled: true }) из инспектора Lighting.
        this._ssaoPipeline = null;
        this._ssaoEnabled = false;
    }

    /**
     * Система графики/эффектов («шейдеры»). Лениво создаём GraphicsManager.
     * Идентична студийной (фича-парность). Применяется при загрузке игры,
     * если автор настроил graphics в проекте (и не 'off').
     */
    _ensureGraphics() {
        if (this._graphics) {
            const cam = this.scene?.activeCamera || this.camera;
            if (cam) this._graphics.setCamera(cam);
            return this._graphics;
        }
        const cam = this.scene?.activeCamera || this.camera;
        if (!this.scene || !cam) return null;
        this._graphics = new GraphicsManager(this.scene, cam, this, {
            mobile: !!this._isMobileMode,
        });
        return this._graphics;
    }

    setGraphics(settings) {
        const g = this._ensureGraphics();
        if (!g) return null;
        const cfg = g.apply(settings || {});
        this._graphicsConfig = cfg;
        return cfg;
    }

    getGraphicsState() {
        return this._graphics ? this._graphics.serialize() : (this._graphicsConfig || null);
    }

    disableGraphics() {
        if (this._graphics) this._graphics.disableAll();
        this._graphicsConfig = null;
    }

    /**
     * Включить/выключить SSAO пост-эффект (контактные тени).
     * Используем SSAORenderingPipeline v1 — v2 ломал thin-instance рендер
     * (блоки/террейн пропадали) из-за GeometryBufferRenderer. v1 использует
     * только depthRenderer и совместим со всеми мешами.
     */
    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 {
            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;
            this._ssaoPipeline = pipeline;
            this._ssaoEnabled = true;
        } catch (e) {
            console.warn('[BabylonScene] SSAO не запустился:', e?.message || e);
            this._disposeSsaoPipeline();
            this._ssaoEnabled = false;
        }
    }

    /** Полностью убрать SSAO пайплайн (detach + remove + dispose). */
    _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 {
            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; }

    /** Создаёт ShadowGenerator (если ещё нет) и применяет текущее качество.
     *
     * Поддерживаемые уровни (Этап 2 теней, 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;
        }
        // Если уже создан — но качество поменялось на тот тип где нужен другой
        // движок (CSM vs обычный) — пересоздадим.
        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. Правильная техника мягких теней.
        //
        // bias 0.0005, normalBias 0.005. Раньше normalBias=0.012 давал
        // peter-panning — тень "уезжала" далеко в сторону от блока (баг
        // 2026-05-27). 0.005 — баланс между acne и peter-panning для
        // воксельных кубов 1м.
        const PCF_BIAS = 0.0008;
        const PCF_NORMAL_BIAS = 0.02;   // убирает «полосы»-acne на полу от соседних теней

        if (!this._shadowGenerator) {
            if (wantCsm) {
                // CSM с PCF. Поднял разрешение каскадов (2048/4096 — было 1024/2048).
                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;                  // 0.8 даёт больше детали ближе, меньше дальше → границы менее заметны
                csm.cascadeBlendPercentage = 0.35; // 0.10 → 0.35: плавный переход между каскадами (убирает резкие полосы на полу)
                csm.shadowMaxZ = (q === 'high') ? 90 : 60;
                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 = false;
                csm.frustumEdgeFalloff = 12;       // убирает «полосу-хвост» тени игрока
                // depthClamp убирает обрезку shadow на границе depth — без этого
                // иногда видны тонкие линии где shadow texel «выпадает» за depth-bound.
                csm.depthClamp = true;
                this._shadowGenerator = csm;
            } else {
                // Обычный ShadowGenerator. Soft теперь 2048 (было 1024).
                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 выставлены при создании
        } else if (q === 'soft') {
            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;
    }

    /**
     * Изменить качество теней. 'off' уничтожает генератор; 'hard'/'soft'/
     * 'medium'/'high' создают/обновляют. CSM используется для medium/high.
     */
    setShadowQuality(q) {
        const allowed = ['off', 'hard', 'soft', 'medium', 'high'];
        if (!allowed.includes(q)) return;
        this._shadowQuality = q;
        this._ensureShadowGenerator();
        // Если выключили — снимем receiveShadows с пола (необязательно, но чище)
        const ground = this.scene.getMeshByName('editorGround');
        if (ground) ground.receiveShadows = q !== 'off';
        // После смены качества — заново зарегистрировать всех casters
        // (при пересоздании генератора список обнулился).
        if (q !== 'off') {
            try { this.refreshAllShadows(); } catch (e) { /* ignore */ }
        }
    }

    getShadowQuality() { return this._shadowQuality || 'soft'; }

    /**
     * Установить свойства глобального освещения. Вызывается из 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;
        // ОПТИМИЗАЦИЯ ТЕНЕЙ (задача 14): мелкие/тонкие меши и огромный плоский
        // пол НЕ кастят тень — каждый caster дорого стоит в shadow-map
        // (на сцене из сотен примитивов давало 5-15 FPS вместо 45-60).
        try {
            const bb = mesh.getBoundingInfo().boundingBox;
            const ext = bb.extendSizeWorld || bb.extendSize;
            if (ext) {
                const w = ext.x * 2, h = ext.y * 2, d = ext.z * 2;
                const maxDim = Math.max(w, h, d);
                const minDim = Math.min(w, h, d);
                if (maxDim < 1.6 || minDim < 0.35) return;
                if (maxDim > 30 && Math.min(w, d) > 30 && h < 3) return;
            }
        } catch (e) { /* ignore */ }
        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;
        // ВАЖНО: physics.floorHalf по умолчанию 40. Если визуальная плита
        // больше — игрок проваливается за пределами центрального 80×80
        // квадрата. Синхронизируем физику с визуалом.
        if (this.physics) this.physics.floorHalf = 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 курсор в центре; в third (свободный курсор)
                // передаём реальные координаты клика для pick по табличкам.
                if (this.placementManager && this.placementManager.isActive()) {
                    if (e.button === 0) { e.preventDefault(); this.placementManager.confirm(); return; }
                    if (e.button === 2) { e.preventDefault(); this.placementManager.cancel(); return; }
                }
                if (e.button === 0) {
                    const r = canvas.getBoundingClientRect();
                    this._handlePlayClick(e.clientX - r.left, e.clientY - r.top);
                }
                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();
            if (this._isPlaying && this.placementManager && this.placementManager.isActive()) {
                this.placementManager.rotate();
                return;
            }
            const forward = this._getCameraForward();
            const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED;
            this.camera.position.addInPlace(forward.scale(delta));
        };

        const onContextMenu = (e) => {
            e.preventDefault();
        };

        // === КЛАВИАТУРА ===
        // Используем e.code (KeyW, KeyA, ...) — независимо от раскладки.
        // ВАЖНО: e.key на русской раскладке возвращает кириллицу ('ц', 'ы', ...),
        // поэтому надёжно использовать только e.code.

        /**
         * Игнорировать события клавиатуры если фокус в input/textarea/contenteditable.
         * Иначе пробел/буквы из ввода в модалке двигают камеру и блокируют ввод.
         * Также игнорируем когда открыта модалка (z-index overlay).
         */
        const isTypingTarget = (target) => {
            if (!target) return false;
            const tag = (target.tagName || '').toLowerCase();
            if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
            if (target.isContentEditable) return true;
            return false;
        };

        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 });
            }
            // Задача 44: I — открыть/закрыть инвентарь, Esc — закрыть, 1-9 — хотбар.
            if (this._isPlaying && e.code === 'KeyI' && this.invUI &&
                (this.invUI.defs.size > 0 || this.invUI.grid.some(Boolean) || this.invUI.hotbar.some(Boolean))) {
                e.preventDefault(); this.invUI.toggle(); return;
            }
            if (this._isPlaying && e.code === 'Escape' && this.invUI?.isOpen()) {
                e.preventDefault(); this.invUI.close(); return;
            }
            if (this._isPlaying && this.invUI && /^Digit[1-9]$/.test(e.code) &&
                (this.invUI.hotbar.some(Boolean) || this.invUI.defs.size > 0)) {
                this.invUI.setActiveHotbar(parseInt(e.code.slice(5), 10) - 1);
            }
            if (this._isPlaying && this.placementManager && this.placementManager.isActive()) {
                if (e.code === 'KeyR') { e.preventDefault(); this.placementManager.rotate(); return; }
                if (e.code === 'Escape') { e.preventDefault(); this.placementManager.cancel(); return; }
            }
            if (e.code === 'KeyF') {
                this._focusOnTarget(new Vector3(0, 0, 0));
            }
            // 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.npcId != null) return { kind: 'npc', id: md.npcId };
        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:<id>': её можно
                    // передать в game.scene.delete и сравнивать.
                    rt.routeGlobalEvent('playerTouch', {
                        target: 'primitive:' + id,
                    });
                } else if (!overlap && wasTouching) {
                    this._touchState.set(key, false);
                }
            }
        }

        // 3) Касания объектов, на которые подписан ГЛОБАЛЬНЫЙ скрипт через
        //    findOne(x).onTouch(...) (rt._watchedTouchRefs). Объекты без скрипта
        //    и не триггеры — например цели туториала. Событие адресное (по ref).
        const watched = rt._watchedTouchRefs;
        if (watched && watched.size > 0) {
            for (const ref of watched) {
                const target = this._refToTarget(ref);
                if (!target) continue;
                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') {
            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(clickX, clickY) {
        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;

        // === Задача 01: клик по КНОПКЕ 3D-таблички (billboard) ===
        // При pointer-lock (first/shift-lock) курсор в центре экрана → пикаем
        // из центра. В third курсор СВОБОДНЫЙ → пикаем по реальным координатам
        // клика (clickX/clickY переданы из onMouseDown). Без этого клик по
        // табличке мышью в third промахивался — кнопки не нажимались.
        if (this.billboardUiManager && this.primitiveManager) {
            const locked = (document.pointerLockElement === this.canvas);
            const w = this.engine?.getRenderWidth?.() || this.canvas.width;
            const h = this.engine?.getRenderHeight?.() || this.canvas.height;
            const px = locked ? w / 2 : (Number.isFinite(clickX) ? clickX : w / 2);
            const py = locked ? h / 2 : (Number.isFinite(clickY) ? clickY : h / 2);
            const bpick = this.scene.pick(px, py, (m) =>
                m && m.metadata && m.metadata.primitiveId != null
                && this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard');
            if (bpick && bpick.hit && bpick.pickedMesh) {
                const bdata = this.primitiveManager.instances.get(bpick.pickedMesh.metadata.primitiveId);
                const uv = bpick.getTextureCoordinates ? bpick.getTextureCoordinates() : null;
                if (bdata && uv) {
                    const buttonId = this.billboardUiManager.pickButtonAt(bdata, uv.x, uv.y);
                    console.log('[billboard] клик id=' + bpick.pickedMesh.metadata.primitiveId
                        + ' uv=(' + uv.x.toFixed(2) + ',' + uv.y.toFixed(2) + ') buttonId=' + buttonId
                        + ' locked=' + locked);
                    if (buttonId) {
                        this.billboardUiManager.fireClick(bdata, buttonId);
                        return;   // клик по табличке обработан
                    }
                } else {
                    console.log('[billboard] попал в табличку id='
                        + bpick.pickedMesh.metadata.primitiveId + ' но нет UV');
                }
            }
        }

        // В pointer-lock (1-е лицо) курсор скрыт в центре — пикаем центром.
        // В 3-м лице (свободный курсор) — пикаем по реальным координатам клика.
        const locked = (document.pointerLockElement === this.canvas);
        let pick;
        if (!locked && Number.isFinite(clickX) && Number.isFinite(clickY)) {
            const pi = this.scene.pick(clickX, clickY, (mesh) => {
                if (!mesh.isPickable) return false;
                if (mesh.name && mesh.name.startsWith('gridLine')) return false;
                return true;
            });
            if (pi?.hit) {
                let m = pi.pickedMesh;
                if (m?.metadata?._isBlockProto && this.blockManager) {
                    const proxy = this.blockManager.findProxyByPickInfo?.(pi);
                    if (proxy) m = proxy;
                }
                pick = { mesh: m, point: pi.pickedPoint, pickInfo: pi };
            } else {
                pick = null;
            }
        } else {
            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).
        try {
            const active = this.inventory?.getActive?.();
            if (active) {
                this.gameRuntime.routeGlobalEvent('toolUse', {
                    tool: {
                        kind: active.kind,
                        modelTypeId: active.modelTypeId,
                        name: active.name,
                    },
                    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:<numericId>') — отдельный путь.
            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, не зависит от того
        // загружена ли уже текстура. Превью кисти просто тонируется в основной
        // цвет выбранного материала.
        // eslint-disable-next-line global-require
        const { Color3 } = require('@babylonjs/core');
        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) {
            // eslint-disable-next-line global-require
            const { MeshBuilder, StandardMaterial, Color3 } = require('@babylonjs/core');
            // Размер кисти в МИРОВЫХ единицах:
            //   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;

        // eslint-disable-next-line global-require
        const { MeshBuilder, StandardMaterial, Color3 } = require('@babylonjs/core');
        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). */
    // ── Небо (задача 16) — обёртки для game-API ──────────────────────────
    setSkybox(opts) { this.skybox?.setSkybox(opts); }
    setClouds(opts) { this.skybox?.setClouds(opts); }
    setSkyFog(opts) { this.skybox?.setFog(opts); }

    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;
    }

    /** Задача 12+05: конфиг экрана загрузки из настроек проекта. */
    setLoadingConfig(cfg, thumbnail) {
        if (cfg && typeof cfg === 'object') {
            this._loadingConfig = {
                logo: cfg.logo || null,
                accentColor: cfg.accentColor || '#ffc020',
                defaultSpinner: cfg.defaultSpinner !== false,
                defaultSkipButton: !!cfg.defaultSkipButton,
                // Задача 05:
                enabled: cfg.enabled !== false,
                background: cfg.background || cfg.backgroundUrl || null,
                cover: cfg.cover || cfg.coverUrl || null,
                style: cfg.style || 'ken-burns',
                placeName: cfg.placeName || '',
                studioName: cfg.studioName || '',
                verified: !!cfg.verified,
                duration: Number.isFinite(cfg.duration) && cfg.duration > 0 ? Number(cfg.duration) : 2.5,
                progressBar: cfg.progressBar !== false,
            };
        } else {
            this._loadingConfig = null;
        }
        if (thumbnail !== undefined) this._projectThumbnail = thumbnail || null;
    }

    /** Задача 05: стартовый экран загрузки при входе в Play (Ken-Burns + название места). */
    showStartupLoadingScreen() {
        const cfg = this._loadingConfig;
        if (!cfg || cfg.enabled === false) return;
        if (!this.gameRuntime) return;
        try {
            const ls = this.gameRuntime._ensureLoadingScreen?.();
            if (!ls) return;
            ls.show({
                style: cfg.style,
                background: cfg.background || cfg.cover || this._projectThumbnail,
                cover: cfg.cover || this._projectThumbnail,
                placeName: cfg.placeName || this._projectName || '',
                studioName: cfg.studioName || '',
                verified: cfg.verified,
                duration: cfg.duration,
                progressBar: cfg.progressBar,
                spinner: true,
                bgColor: '#070a14',
                pauseSimulation: false,
                blockInput: true,
            });
        } catch (e) { /* ignore */ }
    }

    /**
     * Переключить в режим игры. Создаём PlayerController, прячем ghost-блок,
     * запоминаем позицию редактор-камеры чтобы вернуть при exit.
     */
    enterPlayMode() {
        if (this._isPlaying) return;
        this._isPlaying = true;
        // Сброс состояния касаний — каждый прогон начинается «не касаясь».
        if (this._touchState) this._touchState.clear();
        this._playerMenuOpen = false;   // меню-оверлей закрыт на старте Play
        // По умолчанию стандартный HUD видим в Play.
        // Скрипт может скрыть через game.hud.setVisible(false).
        this._setStdHudVisible(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;
            }
            // ESC в плеере = TOGGLE меню-оверлея поверх ЖИВОЙ игры (как в Roblox).
            // Единый источник истины — _playerMenuOpen в движке. Раньше состояние
            // меню держал React, а ESC слушали ДВА обработчика (движок + React) →
            // гонка: меню открывалось поверх меню, а _uiCursorMode застревал в true
            // → orbit-камера по ПКМ переставала работать после закрытия меню.
            // Теперь движок сам решает open/close и шлёт это в _onEscMenu(open).
            if (typeof this._onEscMenu === 'function') {
                if (this._playerMenuOpen) {
                    // Меню открыто → ESC закрывает: вернуть мышь в игру.
                    this._playerMenuOpen = false;
                    this.player?.setUiCursorMode?.(false);
                    this._onEscMenu(false);
                } else {
                    // Меню закрыто → ESC открывает: освободить курсор.
                    this._playerMenuOpen = true;
                    this.player?.setUiCursorMode?.(true);
                    this._onEscMenu(true);
                }
                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);
        // Точка спавна удалена → игрок появляется в (0, безопасная высота, 0).
        let startPoint = this._spawnPoint;
        if (this._spawnEnabled === false) {
            let sy = 3;
            try {
                const surf = this.physics?._sampleRobloxSurface?.(0, 0);
                if (surf !== null && surf !== undefined) sy = surf + 2;
            } catch (e) { /* ignore */ }
            startPoint = { x: 0, y: sy, z: 0 };
        }
        this.player.start(startPoint);

        // Запускаем пользовательские скрипты (этап 2.1).
        // Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену,
        // поэтому скрипты стартуем в следующем кадре.
        this.gameRuntime = new GameRuntime(this);
        try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
        // НЕ показывать стартовый экран загрузки автоматически.
        // По дефолту игра открывается мгновенно (как в Roblox). Экран загрузки
        // только если автор явно вызовет showLoadingScreen() из скрипта.
        // Аудио-менеджер (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);
        // Задача 20: смонтировать HUD лидербордов/достижений если определения уже
        // загружены из проекта (define из project_data при load).
        try { if (this.leaderstats?.active) this.leaderstats._mount(); } catch (e) {}
        try { if (this.achievements?.active) this.achievements._mountButton(); } catch (e) {}
        try {
            if (this.invUI && (this.invUI.defs.size > 0 || this.invUI.hotbar.some(Boolean) || this.invUI.grid.some(Boolean))) {
                this.invUI.mountHotbar();
            }
        } catch (e) {}
        // Старт через requestAnimationFrame — даём Babylon собрать сцену
        requestAnimationFrame(() => {
            if (this._isPlaying) this.gameRuntime?.start(this._scripts || []);
            // Задача 20: подгрузить сохранённый прогресс игрока из БД ПОСЛЕ define().
            setTimeout(() => {
                if (!this._isPlaying) return;
                try { this.achievements?.loadFromDB?.(); } catch (e) {}
                try { this.leaderstats?.loadFromDB?.(); } catch (e) {}
            }, 250);
        });

        // === Оружие ===
        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);
            }
            // Урон скриптовым NPC (киты-враги) → авто-floater над мобом (задача 40).
            if (hit?.mesh && this.npcManager) {
                try { this.npcManager.damageByMesh(hit.mesh, hit.damage || 25); } catch (e) {}
            }
            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;
    }

    /**
     * Колбэк «ESC в Play» для плеера: открыть меню-оверлей поверх живой игры
     * БЕЗ выхода из Play. Если подписан — ESC не делает exitPlayMode (см.
     * setOnExitRequest в enterPlayMode). В студии не подписывается → там ESC
     * по-прежнему выходит из Play.
     */
    setOnEscMenu(cb) {
        this._onEscMenu = cb;
    }

    /**
     * Синхронизация состояния меню-оверлея из UI (React). Когда меню закрывают
     * НЕ через ESC (кнопка «Продолжить»/крестик/клик по фону), UI обязан сообщить
     * движку — иначе _playerMenuOpen рассинхронизируется и следующий ESC решит,
     * что меню «открыто», и не откроет его. open=false также возвращает мышь в игру.
     */
    setPlayerMenuOpen(open) {
        const v = !!open;
        if (this._playerMenuOpen === v) return;
        this._playerMenuOpen = v;
        if (!v) {
            // меню закрыли из UI → вернуть управление камерой/мышью
            try { this.player?.setUiCursorMode?.(false); } catch (e) { /* ignore */ }
        }
    }

    /**
     * Колбэк изменения сцены (любая модификация блоков/моделей).
     * Используется 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) {}
    }

    /** Скрыть/показать только хотбар (5 слотов инвентаря снизу). */
    _setHotbarVisible(visible) {
        this._hotbarVisible = !!visible;
        try { this._onHotbarVisibilityChange?.(this._hotbarVisible); } catch (e) {}
    }

    /** Скрыть/показать только HP-индикатор (полоска жизней). */
    _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) {
        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) {}
            }
        }
    }

    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;
            // Синхронизируем physics.floorHalf — иначе игрок проваливается
            // сквозь baseplate за пределами центральных 80×80.
            if (this.physics) this.physics.floorHalf = 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,
                inventory2: this.invUI ? this.invUI.serialize() : null,   // задача 44
                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,
                skybox: this.skybox ? this.skybox.serialize() : null,
                leaderstats: this.leaderstats ? this.leaderstats.serialize() : null,
                achievements: this.achievements ? this.achievements.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: чем больше карта, тем меньше radius.
                // Поднял пороги (2026-05-27): на средних картах (1-3M voxels)
                // streaming-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();
        }

        // === Тип модели персонажа — РЕШАЕМ ДО предзагрузки/плеера ===
        // ВАЖНО: должно стоять ВЫШЕ _loadPrototype и до enterPlayMode, иначе
        // PlayerController прочитает старый _playerModelType (баг: пончик 2046
        // не ставился — skins.default применялся ниже, после предзагрузки).
        // Миграция: старые проекты сохраняли Kenney-модель ('character-a..g');
        // форсим R15 bacon-hair. Явно выбранные 'skin_*' не трогаем.
        if (state.scene.playerModelType) {
            const pmt = state.scene.playerModelType;
            // character-a..g (Kenney) и legacy R15 (skin_bacon-hair и др.)
            // мигрируем на новый дефолт skin_y-bot.
            this._playerModelType = pmt.startsWith('character-') ? 'skin_y-bot' : 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;
        }
        // Задача 12+05: конфиг экрана загрузки (через setLoadingConfig — единый маппинг).
        if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') {
            this.setLoadingConfig(state.scene.loadingScreen);
        } else {
            this._loadingConfig = null;
        }
        // Задача 13: конфиг главного меню (passthrough).
        this._mainMenuConfig = (state.scene.mainMenu && typeof state.scene.mainMenu === 'object')
            ? state.scene.mainMenu : null;

        // ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже —
        // PlayerController.start() её ждёт, но если предзагрузить сейчас,
        // на enterPlayMode она будет в кэше Babylon и стартует мгновенно.
        // ВАЖНО: R15-скины ('skin_*') — отдельная система (characters/<id>/
        // 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);
        }
        if (this.invUI && state.scene.inventory2) {   // задача 44
            this.invUI.load(state.scene.inventory2);
        }
        // Простановка 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?.();
        }
        // Удалена ли точка спавна (плеер: спавн в 0,0 при отсутствии).
        this._spawnEnabled = state.scene.spawnEnabled !== false;
        // === Авто-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); }
        // (Тип модели персонажа и skins решены выше — до предзагрузки модели.)
        // Пользовательские скрипты
        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);
        }
        // Графика/эффекты (шейдеры) — применяем если автор настроил и не 'off'.
        if (state.scene.graphics && state.scene.graphics.preset
            && state.scene.graphics.preset !== 'off') {
            try { this.setGraphics(state.scene.graphics); } catch (e) { /* ignore */ }
        }
        // Кастомное небо (задача 16)
        if (state.scene.skybox && this.skybox) {
            this.skybox.load(state.scene.skybox);
        }
        // Лидерборды и достижения (задача 20) — определения из проекта.
        if (state.scene.leaderstats && this.leaderstats) {
            this.leaderstats.load(state.scene.leaderstats);
        }
        if (state.scene.achievements && this.achievements) {
            this.achievements.load(state.scene.achievements);
        }
        // Аудио (фоновая музыка/амбиент)
        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 создаётся анимированная стрелка
     * (лента + парящий quest-marker) от источника к цели. from/to — из инспектора.
     */
    _activatePointers() {
        const pm = this.primitiveManager;
        const bm = this.beamManager;
        if (!pm || !bm) return;
        for (const inst of pm.instances.values()) {
            if (inst.type !== 'pointer') continue;
            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 {
                bm.addPointer({
                    from, to,
                    preset: inst.pointerPreset || 'guide',
                    color: inst.color, textureSpeed: inst.textureSpeed,
                    curved: inst.curved, curveHeight: inst.curveHeight,
                });
            } catch (e) {
                console.warn('[BabylonScene] addPointer failed:', 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) {}
        // Задача 20: чистим рантайм лидербордов/достижений (определения остаются).
        try { this.leaderstats?.resetRuntime?.(); } catch (e) {}
        try { this.achievements?.resetRuntime?.(); } catch (e) {}
        try { this.floaters?.resetRuntime?.(); } catch (e) {}   // задача 40
        try { this.invUI?.resetRuntime?.(); } catch (e) {}       // задача 44
        // Сбрасываем таймер прохождения
        this._timerRunning = false;
        this._timerStartedAt = null;
        // Отключаем picking voxel-террейна обратно (нужно только в play).
        try { this.terrainManager?.enablePickingForCamera?.(false); } catch (e) {}
        // Размораживаем 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;
        }

        // Placement mode (задача 11): сброс активной сессии + виджета магазина.
        if (this.vehicleManager) { try { this.vehicleManager.dispose(); } catch (e) {} }
        if (this.vehicleHud) { try { this.vehicleHud.dispose(); } catch (e) {} this.vehicleHud = null; }
        if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) {} this.placementManager = null; }
        if (this.shopInventoryUi) { try { this.shopInventoryUi.dispose(); } catch (e) {} this.shopInventoryUi = null; }
        if (this.loadingScreen) { try { this.loadingScreen.dispose(); } catch (e) {} this.loadingScreen = 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;
        }
    }
}
