Some checks failed
Задача 04 — модальные сцены (затемнение + GUI/3D-анимация + блок ввода): - engine/ModalManager.js (state-машина, fade, spotlight-проекция, HighlightLayer) - editor/ModalOverlay.jsx (CSS-overlay + mask-image для spotlight) - PlayerController.setInputBlocked/setCameraFrozen/captureCameraState/focusOnTarget - game.modal.open/close/update/isOpen/onClose + пресеты bossIntro/lootbox/dialog/confirmation - Фиксы ядра: клик GUI-кнопок (realId↔localId), modalOpened через globalEvent, guard от двойного срабатывания колбэков, кнопка ✓ на последней строке диалога Задача 07 — кастомные скины игрока + магазин: - non-humanoid скины (любая 3D-модель): загрузчик, процедурная анимация, настраиваемый AABB, центрирование через pivot-node - PlayerController.reloadSkin (смена скина в Play) - game.player.setSkin/unlockSkin/getAvailableSkins/onSkinChange/openSkinShop + локальная валюта - editor/SkinShopOverlay.jsx (встроенный магазин, клавиша B) + SkinManagerModal.jsx (вкладка «Скины») - сериализация scene.skins, кнопка «Скины» в тулбаре Вики — категория «Разбор готовых игр»: - docsGames.js: группа g5 + 4 карточки (Двор/Витрина GUI/Сундук/Парк) - docsLessons.jsx: 4 подробные статьи-урока для детей - «Открыть мою копию» создаёт копию проекта (оригинал не трогается) Адаптивная ширина кнопки billboard под длинный текст. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7572 lines
386 KiB
JavaScript
7572 lines
386 KiB
JavaScript
/**
|
||
* BabylonScene — обёртка над Babylon.js Engine + Scene с Roblox-style навигацией.
|
||
*
|
||
* Управление камерой (как в Roblox Studio):
|
||
* - ПКМ + drag : повернуть камеру (yaw/pitch вокруг своей оси)
|
||
* - ПКМ + WASD : полёт (вперёд/назад/влево/вправо относительно взгляда)
|
||
* - ПКМ + Q/E : вниз/вверх по миру
|
||
* - ПКМ + Shift : ускоренный полёт (×2.5)
|
||
* - Колесо : zoom (приближение по оси взгляда)
|
||
* - Средняя кнопка drag : pan (сдвиг параллельно экрану)
|
||
* - F : фокус на (0,0,0) — будет на выбранный объект позже
|
||
*
|
||
* Используем UniversalCamera + ручной обработчик мыши/клавиш для точной
|
||
* имитации Roblox-controls (стандартные attachControl делают не то что нужно).
|
||
*
|
||
* Этап 1, неделя 1: только сцена, камера и пол с сеткой. Блоки и физика — позже.
|
||
*/
|
||
import {
|
||
Engine,
|
||
Scene,
|
||
UniversalCamera,
|
||
Vector3,
|
||
Color3,
|
||
Color4,
|
||
HemisphericLight,
|
||
DirectionalLight,
|
||
ShadowGenerator,
|
||
CascadedShadowGenerator,
|
||
SSAORenderingPipeline,
|
||
MeshBuilder,
|
||
StandardMaterial,
|
||
DynamicTexture,
|
||
UtilityLayerRenderer,
|
||
TransformNode,
|
||
ParticleSystem,
|
||
Texture,
|
||
Ray,
|
||
PointerEventTypes,
|
||
Tools as BabylonTools,
|
||
} from '@babylonjs/core';
|
||
import { BlockManager } from './BlockManager';
|
||
import { TerrainManager, VOXEL_SIZE as TERRAIN_VOXEL_SIZE, TERRAIN_MATERIALS as TERRAIN_MATERIAL_DEFS } from './TerrainManager';
|
||
// Этап 1 voxel-движка: новые классы chunks-based архитектуры (см.
|
||
// RUBLOX_VOXEL_ENGINE_PLAN.md). Пока работают параллельно с legacy
|
||
// TerrainManager как shadow-копия — для замеров статистики чанков и
|
||
// готовности к Этапу 2 (greedy meshing).
|
||
import { VoxelWorld } from './voxel/VoxelWorld';
|
||
import { VoxelRenderer } from './voxel/VoxelRenderer';
|
||
import { WorldGenerator, DEFAULT_GENERATOR_PARAMS } from './voxel/WorldGenerator';
|
||
// Этап 6: deco-слой 0.05м — мелкие воксельные декорации (цветы/грибы/трава).
|
||
import { DecoManager } from './DecoManager';
|
||
import { GRASS_MODELS_POOL } from './DecoModels';
|
||
import { placeVoxelTree, TREE_TYPES } from './VoxelTreeBuilder';
|
||
import { UserModelManager, parseUserModelId, USER_MODEL_PREFIX } from './UserModelManager';
|
||
import { ModelManager } from './ModelManager';
|
||
import { PrimitiveManager } from './PrimitiveManager';
|
||
import { BillboardUiManager } from './BillboardUiManager';
|
||
import { getPrimitiveType } from './PrimitiveTypes';
|
||
import { FolderManager } from './FolderManager';
|
||
import { GuiManager } from './GuiManager';
|
||
import { ModalManager } from './ModalManager';
|
||
import { InventoryManager } from './InventoryManager';
|
||
import { WeaponSystem } from './WeaponSystem';
|
||
import { ZombieManager } from './ZombieManager';
|
||
import { NpcManager } from './NpcManager';
|
||
import { ConstraintManager } from './ConstraintManager';
|
||
import { BeamManager } from './BeamManager';
|
||
import { ZombieSpawnerManager } from './ZombieSpawnerManager';
|
||
import { DynamicsManager } from './DynamicsManager';
|
||
import { Environment } from './Environment';
|
||
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
|
||
import { GameAudioManager } from './GameAudioManager';
|
||
import { AssetManager } from './AssetManager';
|
||
import { SoundLibrary } from './SoundLibrary';
|
||
import { SoundManager } from './SoundManager';
|
||
import { GlbLibrary } from './GlbLibrary';
|
||
import { GdLevelManager } from './GdLevelManager';
|
||
import { GdSkybox } from './GdSkybox';
|
||
import { GdGroundSkin } from './GdGroundSkin';
|
||
import { GdSpikes } from './GdSpikes';
|
||
import { GdStartArch } from './GdStartArch';
|
||
import { GdPortalArch } from './GdPortalArch';
|
||
import { GdDiamond } from './GdDiamond';
|
||
import { GdPlayerModeSkin } from './GdPlayerModeSkin';
|
||
import { GdFinish } from './GdFinish';
|
||
import { GdForest } from './GdForest';
|
||
import { GdPlayerCube } from './GdPlayerCube';
|
||
import { GdPlayerTrail } from './GdPlayerTrail';
|
||
import { GdPostFx } from './GdPostFx';
|
||
import { PhysicsAABB } from './PhysicsAABB';
|
||
import { PlayerController } from './PlayerController';
|
||
import { SelectionManager } from './SelectionManager';
|
||
import { GizmoController } from './GizmoController';
|
||
import { HistoryManager } from './HistoryManager';
|
||
import { GameRuntime } from './GameRuntime';
|
||
import { attachConsoleHook, devlogReset } from './devlog';
|
||
import { TerrainMesh, CHUNK_SIZE as TERRAIN_MESH_CHUNK } from './terrain/TerrainMesh';
|
||
import { VoxelGrid } from './terrain/VoxelGrid';
|
||
import { RobloxTerrain, CHUNK_SIZE as ROBLOX_CHUNK_SIZE } from './robloxterrain/RobloxTerrain';
|
||
import { DensityGrid as RobloxDensityGrid, CELL_SIZE as ROBLOX_CELL_SIZE } from './robloxterrain/DensityGrid';
|
||
import { SmoothDecoManager } from './robloxterrain/SmoothDecoManager';
|
||
|
||
export class BabylonScene {
|
||
/**
|
||
* @param {HTMLCanvasElement} canvas — DOM-элемент <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)
|
||
this.spawnerManager = null; // спавнеры зомби
|
||
this.environment = null;
|
||
this.audioManager = null;
|
||
this.assetManager = null; // библиотека пользовательских картинок
|
||
this.soundLibrary = null; // библиотека пользовательских звуков (Фаза 5.5)
|
||
this.soundManager = null; // 3D-воспроизведение звука (Play-only)
|
||
this.glbLibrary = null; // импортированные .glb-модели (Фаза 5.8)
|
||
this.selection = null; // SelectionManager
|
||
// Тач-режим (мобилки/планшеты) — выставляется снаружи через
|
||
// setTouchMode() ДО enterPlayMode. Влияет на PlayerController.
|
||
this._touchMode = false;
|
||
this._activeTool = 'block'; // 'select' | 'block' | 'model' | 'primitive' | 'erase'
|
||
this._activeBlockType = 'grass';
|
||
this._activeModelType = null;
|
||
this._activePrimitiveType = 'cube';
|
||
this._ghostMesh = null;
|
||
this._ghostRotationY = 0; // угол поворота ghost-модели (R = +90°)
|
||
this._gizmo = null;
|
||
this._gizmoLayer = null;
|
||
this._gizmoDragging = false; // флаг что идёт drag гизмо
|
||
this._isDragPlacing = false; // флаг drag-постановки/удаления блоков
|
||
this._isTerrainBrushing = false; // флаг drag-кисти террейна
|
||
this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять
|
||
this._lastPlacedKey = null; // последняя клетка чтобы не ставить дважды
|
||
this._dragLockAxis = null; // 'y' | 'x' | 'z' — плоскость зафиксированная первым блоком
|
||
this._dragLockValue = 0; // значение по фиксированной оси
|
||
|
||
// Точка спавна игрока в режиме Play (обновляется setSpawnPoint)
|
||
this._spawnPoint = { x: 0, y: 5, z: 0 };
|
||
// Модель персонажа для режима Play.
|
||
// Дефолт — R15-скин bacon-hair (классический Roblox-вид).
|
||
// 'skin_*' грузится из characters/<id>/body.glb (R15-скелет),
|
||
// 'character-*' — старые Kenney-модели.
|
||
this._playerModelType = 'skin_bacon-hair';
|
||
// Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z.
|
||
// По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize().
|
||
this._worldHalf = 40;
|
||
// Видимость пола (можно «удалить» — пол исчезнет визуально и из физики)
|
||
this._floorEnabled = true;
|
||
// Множитель силы прыжка (1 = базовый, 1.5 = в 1.5 раза выше)
|
||
this._jumpPowerMul = 1;
|
||
// Прицел в Play: 'none' | 'dot' | 'cross' | 'circle'. По умолчанию выключен.
|
||
this._crosshair = 'none';
|
||
|
||
// Скрипты пользователя (массив { id, code, target? }).
|
||
// На этапе 2.1 — только один глобальный «scene script», без UI редактирования.
|
||
// Хранится в проекте через serialize/loadFromState.
|
||
this._scripts = [];
|
||
this.gameRuntime = null; // GameRuntime создаётся при enterPlayMode
|
||
|
||
// Режим Play
|
||
this.player = null; // PlayerController когда играем
|
||
this.physics = null; // PhysicsAABB
|
||
this._editorCameraSnapshot = null; // запоминаем позицию редактор-камеры
|
||
this._isPlaying = false;
|
||
|
||
// Drag-detection: чтобы не ставить блок при rotate (mouseup без movement
|
||
// = клик; с movement = drag).
|
||
this._mouseDownTime = 0;
|
||
this._mouseDownX = 0;
|
||
this._mouseDownY = 0;
|
||
this._mouseDownButton = -1;
|
||
|
||
// Слушатели — храним чтобы корректно отписаться
|
||
this._listeners = [];
|
||
this._resizeHandler = null;
|
||
}
|
||
|
||
init() {
|
||
// На тач-устройствах сразу отключаем anti-aliasing — это даёт
|
||
// заметный буст FPS на мобилах. Anti-aliasing полезен только на
|
||
// больших мониторах с низким DPR.
|
||
const isTouchDevice = (typeof window !== 'undefined') && (
|
||
'ontouchstart' in window || (navigator.maxTouchPoints || 0) > 0
|
||
);
|
||
const isSmallScreen = (typeof window !== 'undefined')
|
||
&& window.innerWidth <= 1024;
|
||
const useAA = !(isTouchDevice && isSmallScreen);
|
||
// MOBILE-OPT (этап 1): флаг для всех мобильных оптимизаций
|
||
// Можно принудительно отключить через ?desktop=1 в URL (для отладки).
|
||
const forceDesktop = (typeof window !== 'undefined')
|
||
&& new URLSearchParams(window.location.search).has('desktop');
|
||
this._isMobileMode = (isTouchDevice && isSmallScreen) && !forceDesktop;
|
||
this.engine = new Engine(this.canvas, useAA, {
|
||
preserveDrawingBuffer: true,
|
||
stencil: true,
|
||
// Parallel shader compile — критично для устранения фризов при
|
||
// повороте камеры. Когда новый material попадает во frustum,
|
||
// Babylon без этого синхронно компилит shader и блокирует UI.
|
||
// С параллельным compile рендер использует fallback shader и
|
||
// переключается на оптимизированный когда тот готов.
|
||
useHighPrecisionFloats: false,
|
||
powerPreference: 'high-performance',
|
||
}, true);
|
||
// MOBILE-OPT (этап 1.5): hardware scaling ОТКЛЮЧЁН.
|
||
// Логи показали что узкое место — CPU (draw calls растут до 60k),
|
||
// а не GPU fillrate. Скейлинг ухудшал картинку и не помогал FPS.
|
||
|
||
// PERF-METRICS: счётчики для perf-overlay. Накопительно за окно
|
||
// сэмплинга 5сек, потом overlay читает и сбрасывает.
|
||
this._perfMetrics = {
|
||
render_ms_sum: 0, render_count: 0,
|
||
physics_ms_sum: 0, physics_count: 0,
|
||
script_ms_sum: 0, script_count: 0,
|
||
// Замер idle-времени между концом prev-render и началом next-render.
|
||
// Если idle ≈ frame_ms - render_ms — значит мы GPU-bound (JS-поток
|
||
// ждёт GPU/V-Sync). Если idle мал — CPU-bound (что-то ещё в JS ест).
|
||
idle_ms_sum: 0, idle_count: 0,
|
||
_lastRenderEnd: 0,
|
||
};
|
||
|
||
this.scene = new Scene(this.engine);
|
||
this.scene.clearColor = new Color4(0.5, 0.7, 0.9, 1.0);
|
||
// ambient: материалы TerrainManager ставят mat.ambientColor=(1,1,1),
|
||
// но без scene.ambientColor != 0 это умножается на 0 и боковые грани
|
||
// вокселей остаются чёрными при тёмных пресетах (sunset/night).
|
||
this.scene.ambientColor = new Color3(0.3, 0.3, 0.3);
|
||
// Глобальный хендл для отладки из консоли: window.__BS — это инстанс
|
||
// BabylonScene; window.__SC — Babylon scene; window.__ENG — engine.
|
||
// window.__BJS — набор Babylon-классов для dev-инструментов
|
||
// (@babylonjs/core модульный, window.BABYLON не существует) —
|
||
// им пользуется съёмщик hero-кадров dev-tools/wiki-shots/shoot-hero.js.
|
||
if (typeof window !== 'undefined') {
|
||
window.__BS = this;
|
||
window.__SC = this.scene;
|
||
window.__ENG = this.engine;
|
||
window.__BJS = { UniversalCamera, Vector3, Tools: BabylonTools };
|
||
}
|
||
// ВАЖНО: blockMaterialDirtyMechanism НЕ включаем здесь.
|
||
// Когда true — ставим свойства материала (emissiveColor/disableLighting/
|
||
// alpha) у новых мешей (трейсеры выстрелов, debris при смерти,
|
||
// муззл-флэш, импакт), но шейдер пересчитывается с дефолтами и эти
|
||
// свойства не применяются. Эффект: трейсер/дебрис создаются, но
|
||
// НЕ ВИДНЫ. Включать только локально вокруг массовых операций
|
||
// (если когда-то появится нужда), сразу выключая обратно.
|
||
// Skip pointer-move picking — не делаем raycast от мыши на каждый
|
||
// mousemove. Игроку важны клик и hover-через-canvas, а не каждый move.
|
||
this.scene.skipPointerMovePicking = true;
|
||
// Параллельная компиляция шейдеров — фоновая компиляция новых
|
||
// материалов без блокировки рендера (если поддержано WebGL2).
|
||
if (this.engine.getCaps?.()?.parallelShaderCompile !== undefined) {
|
||
try {
|
||
// Babylon 6+ — это просто флаг capability, выставляется
|
||
// автоматически при поддержке. Логируем для отладки.
|
||
// eslint-disable-next-line no-console
|
||
console.log('[BabylonScene] parallel shader compile:',
|
||
!!this.engine.getCaps().parallelShaderCompile);
|
||
} catch (e) {}
|
||
}
|
||
|
||
// Возвращаем detachControl — наши mousedown-listeners на canvas с
|
||
// capture=true должны работать без вмешательства Babylon-pointerHandler.
|
||
// Гизмо запустим вручную через прямые pointerdown/move/up на utility-сцене.
|
||
this.scene.detachControl();
|
||
|
||
this._createCamera();
|
||
this._createLights();
|
||
this._createGroundGrid();
|
||
this._createGhostBlock();
|
||
this._createSpawnMarker();
|
||
this._setupInputControls();
|
||
|
||
// Менеджеры объектов
|
||
this.blockManager = new BlockManager(this.scene);
|
||
// При создании нового proto-меша блока — сразу регистрируем его
|
||
// как shadow caster (если генератор уже создан).
|
||
this.blockManager.setOnProtoCreated((proto) => {
|
||
this.addShadowCaster(proto);
|
||
});
|
||
|
||
// Менеджер декораций — Этап 6 voxel-движка.
|
||
// Мини-воксели 0.05м для цветов/грибов/травы. Без коллизий.
|
||
this.decoManager = new DecoManager(this.scene);
|
||
this.decoManager.setOnChange(() => {
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
});
|
||
|
||
// Менеджер ландшафта — отдельный voxel-слой 1×1×1, рисуемый кистями.
|
||
// Использует thin-instances per материал, как BlockManager.
|
||
this.terrainManager = new TerrainManager(this.scene);
|
||
// ОПТИМИЗАЦИЯ: НЕ регистрируем terrain как shadow caster. Большая
|
||
// карта с 150K voxel'ов в shadow renderList даёт +50-100% нагрузки
|
||
// на GPU. Тени от деревьев на земле выглядят не критично, а receiveShadows
|
||
// оставлен — тени от других объектов (моделей) показываются.
|
||
// this.terrainManager.setOnProtoCreated((proto) => {
|
||
// this.addShadowCaster(proto);
|
||
// });
|
||
this.terrainManager.setOnChange(() => {
|
||
// Пометить сцену как изменённую — автосохранение подхватит.
|
||
// Имя коллбэка — _onSceneChange (то же что у blockManager/
|
||
// modelManager/primitiveManager). Раньше тут было _onChange —
|
||
// несуществующее поле, из-за чего террейн не сохранялся
|
||
// автоматически. Только ручная кнопка «Сохранить» дёргает
|
||
// serialize() напрямую и попадала в БД.
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
});
|
||
|
||
// === Этап 1 voxel-движка: shadow-копия террейна в новой архитектуре ===
|
||
// Параллельно с TerrainManager работает VoxelWorld с теми же voxel'ами,
|
||
// но в формате chunks 32×32×32. Пока БЕЗ рендера (флаг useVoxelWorld=
|
||
// false) — только структура данных для замера chunk-статистики и
|
||
// подготовки к Этапу 2.
|
||
// window.__voxelWorldStats() — выведет в консоль текущую статистику.
|
||
// window.__voxelWorldRender(true/false) — переключит рендер на новый
|
||
// (когда будет готов greedy). Сейчас рендерит дублирующиеся mesh'и
|
||
// поверх старых — для визуальной валидации.
|
||
this.voxelWorld = new VoxelWorld();
|
||
this.voxelWorld.setOnChange(() => {
|
||
// Авто-rebuild dirty чанков при изменении (только если рендер включён)
|
||
if (this._voxelRenderEnabled && this.voxelRenderer) {
|
||
this.voxelRenderer.rebuildDirty();
|
||
}
|
||
});
|
||
this.voxelRenderer = new VoxelRenderer(this.voxelWorld, this.scene);
|
||
this.voxelRenderer.setOnMeshCreated((m) => this.addShadowCaster(m));
|
||
/** Включить/выключить рендер VoxelWorld. По умолчанию false — только
|
||
* loadFromArray в VoxelWorld для статистики, без отображения. */
|
||
this._voxelRenderEnabled = false;
|
||
/** Этап 4 streaming: рендерить только чанки в радиусе от камеры.
|
||
* false по умолчанию — рендерим все чанки (для маленьких карт).
|
||
* Включается через window.__voxelWorldStreaming(true, 64). */
|
||
this._voxelStreamingEnabled = false;
|
||
this._voxelStreamingRadius = 64; // метров
|
||
this._voxelStreamingLastUpdate = 0;
|
||
this._voxelStreamingInterval = 250; // мс между проверками
|
||
if (typeof window !== 'undefined') {
|
||
window.__voxelWorldStats = () => {
|
||
const s = this.voxelWorld.stats();
|
||
console.log('[VoxelWorld stats]', s);
|
||
return s;
|
||
};
|
||
// Диагностика FPS bottleneck'ов на больших картах.
|
||
// Запускать в консоли когда лагает: window.__voxelPerfReport()
|
||
// Debug-команды для диагностики FPS-проблем.
|
||
// Запускать в консоли — увидим что реально жрёт CPU/GPU.
|
||
window.__toggleShadows = (on) => {
|
||
this.setShadowQuality(on === false ? 'off' : 'soft');
|
||
console.log('[Debug] shadows:', on === false ? 'OFF' : 'ON');
|
||
};
|
||
window.__togglePostProcess = (on) => {
|
||
if (this.scene && this.scene.postProcessRenderPipelineManager) {
|
||
// Babylon не умеет тривиально выключать pipeline, поэтому
|
||
// просто отключаем все pipelines
|
||
const enabled = on !== false;
|
||
if (this._postProcessPipelines) {
|
||
for (const p of this._postProcessPipelines) {
|
||
try { p.setEnabled(enabled); } catch (e) {}
|
||
}
|
||
}
|
||
}
|
||
console.log('[Debug] post-process:', on === false ? 'OFF' : 'ON');
|
||
};
|
||
window.__toggleSceneOptim = (on) => {
|
||
// Глобальные оптимизации Babylon
|
||
const scn = this.scene;
|
||
if (on !== false) {
|
||
scn.freezeActiveMeshes();
|
||
scn.skipFrustumClipping = true;
|
||
scn.blockfreeActiveMeshesAndRenderingGroups = true;
|
||
console.log('[Debug] scene optim: freezeActiveMeshes + skipFrustumClipping ON');
|
||
} else {
|
||
scn.unfreezeActiveMeshes();
|
||
scn.skipFrustumClipping = false;
|
||
scn.blockfreeActiveMeshesAndRenderingGroups = false;
|
||
console.log('[Debug] scene optim: OFF');
|
||
}
|
||
};
|
||
window.__voxelPerfReport = () => {
|
||
const tm = this.terrainManager;
|
||
if (!tm) return console.warn('no terrainManager');
|
||
const scn = this.scene;
|
||
const eng = scn.getEngine();
|
||
const totalMeshes = scn.meshes.length;
|
||
let activeMeshes = 0;
|
||
let activeRegionMeshes = 0;
|
||
let activeDecoMeshes = 0;
|
||
for (const m of scn.meshes) {
|
||
if (m.isEnabled() && m.material) activeMeshes++;
|
||
}
|
||
if (tm._regionMeshes) {
|
||
for (const m of tm._regionMeshes.values()) {
|
||
if (m.isEnabled()) activeRegionMeshes++;
|
||
}
|
||
}
|
||
if (this.decoManager?._chunkMeshes) {
|
||
for (const colorMap of this.decoManager._chunkMeshes.values()) {
|
||
for (const m of colorMap.values()) {
|
||
if (m.isEnabled()) activeDecoMeshes++;
|
||
}
|
||
}
|
||
}
|
||
// FPS: Babylon engine.getFps() даёт усреднённый, instantaneous
|
||
// (1000/getDeltaTime) скачет хаотично из-за GC.
|
||
const stableFps = eng.getFps?.() ?? (1000 / eng.getDeltaTime());
|
||
const instFps = 1000 / eng.getDeltaTime();
|
||
// Подсчёт активных треугольников и draw calls.
|
||
// Babylon хранит sceneInstrumentation, но он opt-in.
|
||
// Считаем вручную из активных мешей.
|
||
let activeTriangles = 0;
|
||
let activeVertices = 0;
|
||
let activeDrawCalls = 0;
|
||
if (this.scene && this.scene.meshes) {
|
||
for (const m of this.scene.meshes) {
|
||
if (!m.isEnabled() || !m.material) continue;
|
||
// Frustum cull skip
|
||
const idxCount = m.getTotalIndices?.() ?? 0;
|
||
if (idxCount === 0) continue;
|
||
// thin-instances умножают
|
||
const instCount = m.thinInstanceCount > 0 ? m.thinInstanceCount : 1;
|
||
activeTriangles += (idxCount / 3) * instCount;
|
||
activeVertices += (m.getTotalVertices?.() ?? 0) * instCount;
|
||
// 1 draw call на меш (multimat = +submeshes)
|
||
const subMeshes = m.subMeshes ? m.subMeshes.length : 1;
|
||
activeDrawCalls += subMeshes;
|
||
}
|
||
}
|
||
// Frame time: целевые значения
|
||
// 60 FPS = 16.6мс/кадр
|
||
// 30 FPS = 33.3мс/кадр
|
||
// 23 FPS = 43.5мс/кадр ← наша проблема
|
||
const frameMs = eng.getDeltaTime();
|
||
// Visibility — Chrome даёт throttle до 20 FPS если таб неактивен
|
||
const docHidden = typeof document !== 'undefined' && document.hidden;
|
||
const winFocused = typeof document !== 'undefined' && document.hasFocus?.();
|
||
// PERF-DIAG: где теряется время?
|
||
// render_ms — сколько занимает scene.render() (GPU + babylon)
|
||
// idle_ms — промежуток между концом render и началом
|
||
// следующего кадра (если велик — GPU-bound
|
||
// ИЛИ браузер throttle; если мал, а frame_ms
|
||
// большой — узкое место в нашем JS до render).
|
||
const pm = this._perfMetrics;
|
||
const renderMsAvg = pm && pm.render_count
|
||
? (pm.render_ms_sum / pm.render_count) : 0;
|
||
const idleMsAvg = pm && pm.idle_count
|
||
? (pm.idle_ms_sum / pm.idle_count) : 0;
|
||
// Сбрасываем накопители — следующий отчёт за свежий период.
|
||
if (pm) {
|
||
pm.render_ms_sum = 0; pm.render_count = 0;
|
||
pm.idle_ms_sum = 0; pm.idle_count = 0;
|
||
}
|
||
console.log('[PerfReport]', {
|
||
fps_stable: stableFps.toFixed(1),
|
||
fps_instant: instFps.toFixed(1),
|
||
frame_ms: frameMs.toFixed(1),
|
||
render_ms: renderMsAvg.toFixed(1),
|
||
idle_ms: idleMsAvg.toFixed(1),
|
||
isPlaying: this._isPlaying,
|
||
triangles_K: (activeTriangles / 1000).toFixed(0) + 'K',
|
||
drawCalls: activeDrawCalls,
|
||
tab_hidden: docHidden,
|
||
win_focused: winFocused,
|
||
voxelCount: tm.voxels?.size ?? 0,
|
||
sceneMeshes: totalMeshes,
|
||
activeMeshes,
|
||
regionMeshes: tm._regionMeshes?.size ?? 0,
|
||
activeRegionMeshes,
|
||
decoMeshes: this.decoManager?._chunkMeshes ? this._decoMeshCount() : 0,
|
||
activeDecoMeshes,
|
||
streamingRadius: this._terrainStreamingRadius,
|
||
// Новый TerrainMesh (Roblox-style, voxel)
|
||
tmesh_chunks: this._terrainMesh?.chunks?.size ?? 0,
|
||
tmesh_pending: this._terrainMesh?._pendingChunks?.size ?? 0,
|
||
tmesh_tris: this._terrainMesh ? this._terrainMesh.getActiveTriangles() : 0,
|
||
// Roblox Smooth Terrain
|
||
rt_chunks: this._robloxTerrain?.chunks?.size ?? 0,
|
||
rt_pending: this._robloxTerrain?._pendingChunks?.size ?? 0,
|
||
rt_tris: this._robloxTerrain ? this._robloxTerrain.getStats().triangles : 0,
|
||
});
|
||
};
|
||
this._decoMeshCount = () => {
|
||
let n = 0;
|
||
if (!this.decoManager?._chunkMeshes) return 0;
|
||
for (const m of this.decoManager._chunkMeshes.values()) n += m.size;
|
||
return n;
|
||
};
|
||
// Автомониторинг FPS — каждые 2 сек пишет PerfReport в devlog.
|
||
// Активируется автоматически на localhost. На прод не работает.
|
||
window.__perfMonitorStart = (interval = 2000) => {
|
||
if (window.__perfMonitorTimer) {
|
||
clearInterval(window.__perfMonitorTimer);
|
||
}
|
||
window.__perfMonitorTimer = setInterval(() => {
|
||
try { window.__voxelPerfReport?.(); } catch (e) {}
|
||
}, interval);
|
||
console.log(`[PerfMonitor] started, interval=${interval}ms`);
|
||
};
|
||
window.__perfMonitorStop = () => {
|
||
if (window.__perfMonitorTimer) {
|
||
clearInterval(window.__perfMonitorTimer);
|
||
window.__perfMonitorTimer = null;
|
||
console.log('[PerfMonitor] stopped');
|
||
}
|
||
};
|
||
// Автостарт мониторинга на localhost — Claude читает devlog.txt
|
||
if (typeof window !== 'undefined'
|
||
&& (window.location.hostname === 'localhost'
|
||
|| window.location.hostname === '127.0.0.1')) {
|
||
setTimeout(() => { try { window.__perfMonitorStart?.(2000); } catch (e) {} }, 1000);
|
||
}
|
||
// === Тест нового TerrainMesh (Roblox/Minecraft-style) ===
|
||
//
|
||
// Создаёт VoxelGrid и заполняет его holmistym ландшафтом из
|
||
// sin-волн. Рендерится через Greedy Meshing.
|
||
// Использование в DevTools:
|
||
// __terrainTest(64) — небольшая карта 64×16×64м
|
||
// __terrainTest(150) — большая 150×24×150м
|
||
// __terrainTest(250) — целевая 250×32×250м
|
||
window.__terrainTest = (sizeMeters = 64) => {
|
||
if (!this._terrainMesh) {
|
||
this._terrainMesh = new TerrainMesh(this.scene);
|
||
}
|
||
const tm = this._terrainMesh;
|
||
// Удалим старый legacy terrain — он перекрывает картинку
|
||
try {
|
||
if (this.terrainManager) this.terrainManager.clear();
|
||
if (this.decoManager) this.decoManager.clear();
|
||
} catch (e) {}
|
||
tm.disposeAll();
|
||
|
||
const t0 = performance.now();
|
||
const sx = sizeMeters, sz = sizeMeters;
|
||
const sy = 32;
|
||
const grid = new VoxelGrid({
|
||
origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 },
|
||
size: { x: sx, y: sy, z: sz },
|
||
});
|
||
// Заполняем heightmap-картой: y = базовый + sin(x)*cos(z)
|
||
for (let z = 0; z < sz; z++) {
|
||
for (let x = 0; x < sx; x++) {
|
||
const fx = (x - sx / 2) / sx;
|
||
const fz = (z - sz / 2) / sz;
|
||
const h = Math.floor(
|
||
6 + Math.sin(fx * Math.PI * 3) * 4
|
||
+ Math.cos(fz * Math.PI * 4) * 3
|
||
+ Math.sin((fx + fz) * Math.PI * 6) * 2,
|
||
);
|
||
for (let y = 0; y < h && y < sy; y++) {
|
||
let mat;
|
||
if (y === h - 1) mat = 'grass';
|
||
else if (y >= h - 3) mat = 'dirt';
|
||
else mat = 'rock';
|
||
grid.set(x, y, z, mat);
|
||
}
|
||
}
|
||
}
|
||
const tFill = performance.now() - t0;
|
||
const solid = grid.countSolid();
|
||
console.log(`[TerrainTest] filled grid ${sx}×${sy}×${sz} (${solid} solid voxels) in ${tFill.toFixed(0)}ms`);
|
||
|
||
tm.loadFromGrid(grid);
|
||
|
||
// Сразу материализуем ВСЕ chunks (для теста, не lazy)
|
||
const t1 = performance.now();
|
||
const camX = this.camera?.position.x || 0;
|
||
const camZ = this.camera?.position.z || 0;
|
||
const r = tm.updateStreaming(camX, camZ, 9999, { maxBuild: 9999 });
|
||
const tBuild = performance.now() - t1;
|
||
const tris = tm.getActiveTriangles();
|
||
console.log(`[TerrainTest] built ${r.built} chunks in ${tBuild.toFixed(0)}ms — ${tris} triangles total`);
|
||
console.log(`[TerrainTest] open DevTools → __voxelPerfReport() через 2 сек → должно быть 60+ FPS`);
|
||
};
|
||
|
||
// Удалить тестовый terrain mesh
|
||
window.__terrainTestClear = () => {
|
||
if (this._terrainMesh) {
|
||
this._terrainMesh.disposeAll();
|
||
console.log('[TerrainTest] cleared');
|
||
}
|
||
};
|
||
|
||
// ============================================================
|
||
// Roblox-style Smooth Terrain test
|
||
// ============================================================
|
||
//
|
||
// Использование в DevTools:
|
||
// __robloxTest(50) — карта 50×16×50 ячеек = 200×64×200 м
|
||
// __robloxTest(125) — 500×64×500 м (огромная, ОК для smooth)
|
||
//
|
||
// Создаёт holmistyy ландшафт через density-функцию и рендерит
|
||
// через Surface Nets. Проверка что архитектура работает.
|
||
window.__robloxTest = async (gridSize = 50, userParams = null) => {
|
||
if (!this._robloxTerrain) {
|
||
this._robloxTerrain = new RobloxTerrain(this.scene);
|
||
// Подключить к физике — иначе игрок проваливается в smooth terrain
|
||
if (this.physics?.setRobloxTerrain) {
|
||
this.physics.setRobloxTerrain(this._robloxTerrain);
|
||
}
|
||
}
|
||
const rt = this._robloxTerrain;
|
||
try {
|
||
if (this.terrainManager) this.terrainManager.clear();
|
||
if (this.decoManager) this.decoManager.clear();
|
||
if (this.voxelWorld) {
|
||
const layer = this.voxelWorld.getLayer?.('terrain');
|
||
if (layer && layer.clear) layer.clear();
|
||
}
|
||
this._terrainStreamingEnabled = false;
|
||
} catch (e) {}
|
||
rt.disposeAll();
|
||
|
||
const t0 = performance.now();
|
||
const sx = gridSize, sz = gridSize;
|
||
const sy = 24; // высота карты в cells: 24 × 4м = 96м (для гор)
|
||
const grid = new RobloxDensityGrid({
|
||
origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 },
|
||
size: { x: sx, y: sy, z: sz },
|
||
});
|
||
|
||
// === Используем тот же WorldGenerator что и voxel-генератор ===
|
||
// 1 smooth-cell = 4м = 16 voxel-units.
|
||
// sampleHeight возвращает высоту в voxel-units (0.25м).
|
||
// sampleBiome → объект {topMaterial,softMaterial,hardMaterial,...}.
|
||
//
|
||
// userParams приходит из UI (TerrainGenPanel buildParams).
|
||
// Если null — берём дефолтные.
|
||
const params = userParams
|
||
? JSON.parse(JSON.stringify(userParams))
|
||
: JSON.parse(JSON.stringify(DEFAULT_GENERATOR_PARAMS));
|
||
console.log(`[RobloxTest] params: amp=${params.heightmap.amplitude}, scale=${params.heightmap.scale}, exp=${params.heightmap.exponent}, biomes=${params.biomes?.length}`);
|
||
const gen = new WorldGenerator(params);
|
||
|
||
// Маппинг материалов voxel-генератора → smooth (DensityGrid
|
||
// поддерживает только grass/rock/sand/snow).
|
||
// dirt → grass, остальные пропускаются.
|
||
const matMap = (m) => {
|
||
if (m === 'dirt') return 'grass';
|
||
if (m === 'grass' || m === 'rock' || m === 'sand' || m === 'snow') return m;
|
||
return 'grass';
|
||
};
|
||
|
||
// Шаг 1: heightmap + biome для каждой smooth-cell.
|
||
// x,z в smooth-grid — переводим в voxel-units: vx = (x + origin.x) * 16
|
||
const CELL_VOXELS = 16; // 4м / 0.25м per voxel = 16
|
||
const heightMap = new Float32Array(sx * sz);
|
||
const topMats = new Array(sx * sz);
|
||
const softMats = new Array(sx * sz);
|
||
const hardMats = new Array(sx * sz);
|
||
for (let z = 0; z < sz; z++) {
|
||
for (let x = 0; x < sx; x++) {
|
||
const vx = (x + grid.origin.x) * CELL_VOXELS + CELL_VOXELS / 2;
|
||
const vz = (z + grid.origin.z) * CELL_VOXELS + CELL_VOXELS / 2;
|
||
const hVoxels = gen.sampleHeight(vx, vz);
|
||
const biome = gen.sampleBiome(vx, vz);
|
||
const hCells = hVoxels / CELL_VOXELS;
|
||
heightMap[z * sx + x] = hCells;
|
||
topMats[z * sx + x] = matMap(biome.topMaterial);
|
||
softMats[z * sx + x] = matMap(biome.softMaterial);
|
||
hardMats[z * sx + x] = matMap(biome.hardMaterial);
|
||
}
|
||
}
|
||
|
||
// Шаг 2: density + материалы.
|
||
// Топ-слой: topMaterial биома.
|
||
// Средний (1..3 cells вглубь): softMaterial.
|
||
// Глубокий (>3 cells): hardMaterial.
|
||
for (let z = 0; z < sz; z++) {
|
||
for (let x = 0; x < sx; x++) {
|
||
const h = heightMap[z * sx + x];
|
||
const topMat = topMats[z * sx + x];
|
||
const softMat = softMats[z * sx + x];
|
||
const hardMat = hardMats[z * sx + x];
|
||
const h1 = x > 0 ? heightMap[z * sx + (x - 1)] : h;
|
||
const h2 = x < sx - 1 ? heightMap[z * sx + (x + 1)] : h;
|
||
const h3 = z > 0 ? heightMap[(z - 1) * sx + x] : h;
|
||
const h4 = z < sz - 1 ? heightMap[(z + 1) * sx + x] : h;
|
||
const slope = Math.max(
|
||
Math.abs(h - h1), Math.abs(h - h2),
|
||
Math.abs(h - h3), Math.abs(h - h4),
|
||
);
|
||
// На очень крутых обрывах (>3 cells = 12м перепад) —
|
||
// обнажение rock даже на травяных склонах.
|
||
const useRockSlope = slope > 3.0 && topMat !== 'sand' && topMat !== 'snow';
|
||
|
||
for (let y = 0; y < sy; y++) {
|
||
const delta = h - y;
|
||
let densityF;
|
||
if (delta > 2) densityF = 1;
|
||
else if (delta < -2) densityF = 0;
|
||
else densityF = (delta + 2) / 4;
|
||
const density = (densityF * 255) | 0;
|
||
if (density > 0) {
|
||
let mat;
|
||
if (useRockSlope) mat = 'rock';
|
||
else if (delta < 1) mat = topMat;
|
||
else if (delta < 3) mat = softMat;
|
||
else mat = hardMat;
|
||
grid.set(x, y, z, density, mat);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const tFill = performance.now() - t0;
|
||
console.log(`[RobloxTest] filled grid ${sx}×${sy}×${sz} (${grid.countSolid()} solid cells) in ${tFill.toFixed(0)}ms`);
|
||
|
||
rt.loadFromGrid(grid);
|
||
|
||
// Материализуем ВСЕ chunks сразу для теста.
|
||
const t1 = performance.now();
|
||
const camX = this.camera?.position.x || 0;
|
||
const camZ = this.camera?.position.z || 0;
|
||
const r = rt.updateStreaming(camX, camZ, 99999, { maxBuild: 9999 });
|
||
const tBuild = performance.now() - t1;
|
||
const stats = rt.getStats();
|
||
console.log(`[RobloxTest] built ${r.built} chunks in ${tBuild.toFixed(0)}ms — ${stats.triangles} triangles`);
|
||
|
||
// Мини-карта для свежесгенерированного гладкого ландшафта.
|
||
this._setupMinimapForRobloxTerrain();
|
||
|
||
// === Авто-спавн над поверхностью ===
|
||
// Находим в grid самую верхнюю solid-ячейку в столбце x=0, z=0.
|
||
// Spawn = top_y + 2м над ней.
|
||
const CS = 4; // CELL_SIZE
|
||
const cellX0 = 0 - grid.origin.x; // мировые (0,_,0) → cell
|
||
const cellZ0 = 0 - grid.origin.z;
|
||
let topCellY = -1;
|
||
for (let cy = sy - 1; cy >= 0; cy--) {
|
||
if (grid.isSolid(cellX0, cy, cellZ0)) { topCellY = cy; break; }
|
||
}
|
||
if (topCellY >= 0) {
|
||
const surfaceY = (grid.origin.y + topCellY + 1) * CS;
|
||
this._spawnPoint = { x: 0, y: surfaceY + 2, z: 0 };
|
||
this._updateSpawnMarker?.();
|
||
console.log(`[RobloxTest] auto-spawn at y=${surfaceY + 2} (surface at y=${surfaceY})`);
|
||
}
|
||
|
||
// Отключаем baseplate-пол — иначе он закрывает обзор и
|
||
// создаёт коллизии под smooth-ландшафтом.
|
||
try { this.setFloorEnabled(false); } catch (e) {}
|
||
|
||
// === Декорации (цветы / трава / грибы) ===
|
||
// Размещаем 3D-модели Kenney Nature Kit через thin-instances.
|
||
// Используем те же sampleHeight/sampleBiome из WorldGenerator
|
||
// что и для terrain — биомы определят какие декорации куда идут.
|
||
const decoOpts = userParams?.smoothDeco ?? {
|
||
flowersDensity: 0.025,
|
||
grassDensity: 0.10,
|
||
treesDensity: 0.4,
|
||
};
|
||
// Сохраняем параметры для сериализации (при load воссоздадим)
|
||
this._smoothDecoParams = {
|
||
flowersDensity: decoOpts.flowersDensity,
|
||
grassDensity: decoOpts.grassDensity,
|
||
treesDensity: decoOpts.treesDensity ?? 0.4,
|
||
seed: params.seed || 1337,
|
||
bbox: {
|
||
minX: -(sx * CS) / 2, maxX: (sx * CS) / 2,
|
||
minZ: -(sz * CS) / 2, maxZ: (sz * CS) / 2,
|
||
},
|
||
// Параметры WorldGenerator нужны для воссоздания biome-маппинга
|
||
genParams: params,
|
||
};
|
||
if (decoOpts.flowersDensity > 0 || decoOpts.grassDensity > 0 || (decoOpts.treesDensity ?? 0) > 0) {
|
||
try {
|
||
if (!this._smoothDecoManager) {
|
||
this._smoothDecoManager = new SmoothDecoManager(this.scene);
|
||
}
|
||
const tDeco0 = performance.now();
|
||
await this._smoothDecoManager.loadAll();
|
||
const tDecoLoad = performance.now() - tDeco0;
|
||
// bbox в мировых координатах (метры)
|
||
const halfMeters = (sx * CS) / 2;
|
||
const bbox = {
|
||
minX: -halfMeters, maxX: halfMeters,
|
||
minZ: -halfMeters, maxZ: halfMeters,
|
||
};
|
||
// Хелпер для surface raycast (использует физику)
|
||
const sampleSurfaceY = (x, z) => {
|
||
if (!this.physics?._sampleRobloxSurface) return null;
|
||
return this.physics._sampleRobloxSurface(x, z);
|
||
};
|
||
const sampleBiomeId = (x, z) => {
|
||
// x,z в метрах → voxel-units (×4)
|
||
const vx = x * 4;
|
||
const vz = z * 4;
|
||
const biome = gen.sampleBiome(vx, vz);
|
||
return biome?.id;
|
||
};
|
||
const tDeco1 = performance.now();
|
||
const r = this._smoothDecoManager.placeDecorations({
|
||
sampleSurfaceY, sampleBiomeId, bbox,
|
||
densityFlowers: decoOpts.flowersDensity,
|
||
densityGrass: decoOpts.grassDensity,
|
||
densityTrees: decoOpts.treesDensity ?? 0,
|
||
seed: params.seed || 1337,
|
||
});
|
||
const tDecoPlace = performance.now() - tDeco1;
|
||
console.log(`[RobloxTest] decorations: load ${tDecoLoad.toFixed(0)}ms + place ${tDecoPlace.toFixed(0)}ms → ${r.total} instances`);
|
||
// Регистрация tree-AABB в физике — игрок не пройдёт сквозь стволы.
|
||
if (this.physics?.setSmoothDecoTrees && r.treeColliders) {
|
||
this.physics.setSmoothDecoTrees(r.treeColliders);
|
||
}
|
||
} catch (e) {
|
||
console.error('[RobloxTest] decorations failed:', e);
|
||
}
|
||
}
|
||
|
||
// Перемещаем редактор-камеру повыше чтобы видеть весь рельеф
|
||
if (this.camera && topCellY >= 0) {
|
||
const surfaceY = (grid.origin.y + topCellY + 1) * CS;
|
||
this.camera.position.x = sx * CS * 0.3;
|
||
this.camera.position.y = surfaceY + 30;
|
||
this.camera.position.z = sz * CS * 0.3;
|
||
this.camera.setTarget?.(new Vector3(0, surfaceY, 0));
|
||
}
|
||
};
|
||
|
||
window.__robloxTestClear = () => this.clearRobloxTerrain();
|
||
|
||
// Этап 7a: процедурный генератор
|
||
// window.__voxelGenerate({size:160, params:{...}}) — генерирует
|
||
// террейн в bbox [-size..+size] и заменяет существующий terrain.
|
||
window.__voxelGenerate = async (opts = {}) => {
|
||
// ГЛОБАЛЬНЫЙ лок (на window, не на this!).
|
||
// Без него при HMR (hot module reload в dev) каждая копия
|
||
// BabylonScene имеет свой this._voxelGenerating, и команда
|
||
// в консоли вызывает все копии параллельно.
|
||
// window.__voxelGenLock виден ВСЕМ копиям сцены.
|
||
if (window.__voxelGenLock) {
|
||
console.warn('[VoxelGen] already running, ignoring duplicate call');
|
||
return null;
|
||
}
|
||
window.__voxelGenLock = true;
|
||
// size — half-size в voxel-units (0.25м/voxel).
|
||
// Картa = size × 2 × 0.25м.
|
||
// size=160 → 80×80м (~200K voxels, FPS 27) ← по умолчанию
|
||
// size=200 → 100×100м (~400K voxels, FPS 25) ← МАКСИМУМ
|
||
// Жёсткий лимит — 200 (карта 100×100м максимум).
|
||
// Для больших карт используйте Roblox-style smooth terrain.
|
||
try {
|
||
const MAX_SIZE = 200;
|
||
let size = opts.size ?? 160;
|
||
if (size > MAX_SIZE) {
|
||
console.warn(`[VoxelGen] size=${size} превышает лимит ${MAX_SIZE} (карта >100м). Обрезаю до ${MAX_SIZE}.`);
|
||
size = MAX_SIZE;
|
||
}
|
||
const params = opts.params ?? DEFAULT_GENERATOR_PARAMS;
|
||
// Сохраняем для мини-карты (MinimapOverlay читает window.__lastGenParams)
|
||
window.__lastGenParams = params;
|
||
window.__lastGenSize = size;
|
||
console.log(`[VoxelGen] generating ${size*2}×${size*2} voxel-units (${(size * 2 * 0.25).toFixed(0)}m × ${(size * 2 * 0.25).toFixed(0)}m)…`);
|
||
|
||
// ВАЖНО: пишем в LEGACY TerrainManager — он рендерит правильно
|
||
// (MultiCube для grass:top/side/bottom работает, текстуры
|
||
// настроены корректно). VoxelWorld остаётся как shadow-copy
|
||
// для RLE-сжатия в БД, но не для рендера.
|
||
//
|
||
// VoxelRenderer (новый) пока что выключен — он показывал
|
||
// серую кашу из-за проблем с MultiCube.
|
||
this._voxelRenderEnabled = false;
|
||
if (this.voxelRenderer) {
|
||
this.voxelRenderer.dispose();
|
||
this.voxelRenderer = new VoxelRenderer(this.voxelWorld, this.scene);
|
||
this.voxelRenderer.setOnMeshCreated((m) => this.addShadowCaster(m));
|
||
}
|
||
|
||
// Progress callback — UI подхватывает через window.__voxelGenProgress.
|
||
const onProgress = (done, total, phase) => {
|
||
const pct = Math.min(100, Math.round((done / total) * 100));
|
||
if (window.__voxelGenProgress) {
|
||
try { window.__voxelGenProgress(pct, phase); } catch (e) {}
|
||
}
|
||
};
|
||
onProgress(0, 100, 'starting');
|
||
|
||
// Этап C оптимизации: генерация в Web Worker'е.
|
||
// Main thread не блокируется, UI отзывчив, progress-bar плавный.
|
||
const { getTerrainGenWorkerUrl } = await import('./TerrainGenWorker');
|
||
const workerUrl = getTerrainGenWorkerUrl();
|
||
const worker = new Worker(workerUrl);
|
||
|
||
let voxels, decorations, treesPlaced, statsTimeMs;
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
worker.onmessage = (e) => {
|
||
const m = e.data;
|
||
if (m.type === 'progress') {
|
||
onProgress(m.done, m.total, m.phase);
|
||
} else if (m.type === 'done') {
|
||
voxels = m.voxels;
|
||
decorations = m.decorations;
|
||
treesPlaced = m.treesPlaced;
|
||
statsTimeMs = m.timeMs;
|
||
resolve();
|
||
} else if (m.type === 'error') {
|
||
reject(new Error('[Worker] ' + m.message));
|
||
}
|
||
};
|
||
worker.onerror = (err) => {
|
||
reject(new Error('[Worker] crash: ' + err.message));
|
||
};
|
||
worker.postMessage({
|
||
type: 'generate',
|
||
params,
|
||
bbox: { x0: -size, z0: -size, x1: size, z1: size },
|
||
});
|
||
});
|
||
} finally {
|
||
worker.terminate();
|
||
URL.revokeObjectURL(workerUrl);
|
||
}
|
||
console.log(`[VoxelGen] generated ${voxels.length} voxels in ${statsTimeMs}ms (worker), ${treesPlaced} trees, ${decorations?.length || 0} decorations`);
|
||
onProgress(95, 100, 'render');
|
||
await new Promise(r => setTimeout(r, 0));
|
||
|
||
// Заливаем в legacy TerrainManager (он отрендерит правильно).
|
||
// Очищаем TerrainMesh если был — на новых генерациях не нужен.
|
||
if (this._terrainMesh) {
|
||
try { this._terrainMesh.disposeAll(); } catch (e) {}
|
||
}
|
||
if (this.terrainManager) {
|
||
this.terrainManager.clear();
|
||
if (this.terrainManager.loadFromArray.constructor.name === 'AsyncFunction') {
|
||
await this.terrainManager.loadFromArray(voxels);
|
||
} else {
|
||
this.terrainManager.loadFromArray(voxels);
|
||
}
|
||
console.log(`[VoxelGen] loaded into legacy TerrainManager`);
|
||
|
||
// Также пишем в VoxelWorld для RLE-сжатия в БД
|
||
try {
|
||
const vwLayer = this.voxelWorld.getOrCreateLayer('terrain', 0.25);
|
||
vwLayer.clear();
|
||
vwLayer.loadFromArray(voxels);
|
||
} catch (e) { /* ignore */ }
|
||
|
||
// АВТОВКЛЮЧЕНИЕ STREAMING для больших карт.
|
||
const regionCount = this.terrainManager.getRegionCount?.() ?? 0;
|
||
if (regionCount > 0) {
|
||
this._terrainStreamingEnabled = true;
|
||
// Адаптивный radius по количеству вокселей: чем больше
|
||
// карта, тем меньше radius (иначе слишком много рендерится).
|
||
// <300K voxels → 40м (норма для маленьких карт)
|
||
// 300K-1M → 36м
|
||
// 1M-2M → 32м
|
||
// >2M → 28м (очень большие)
|
||
const vc = voxels.length;
|
||
let radius = 40;
|
||
if (vc > 2_000_000) radius = 28;
|
||
else if (vc > 1_000_000) radius = 32;
|
||
else if (vc > 300_000) radius = 36;
|
||
this._terrainStreamingRadius = radius;
|
||
this._terrainStreamingLastUpdate = 0;
|
||
const cam = this.camera;
|
||
if (cam) {
|
||
const r = this.terrainManager.updateStreaming(cam.position.x, cam.position.z, this._terrainStreamingRadius);
|
||
console.log(`[VoxelGen] streaming ON: radius=${this._terrainStreamingRadius}m (${vc} voxels), ${r.enabled}/${r.total} regions enabled`);
|
||
}
|
||
} else {
|
||
this._terrainStreamingEnabled = false;
|
||
}
|
||
}
|
||
|
||
// Этап 6: загружаем decorations (мини-воксельные цветы/грибы).
|
||
if (this.decoManager && decorations) {
|
||
this.decoManager.loadFromArray(decorations);
|
||
// Этап D: первый pass LOD streaming для деко.
|
||
// maxBuild=2 — деко достроятся плавно через updateStreaming.
|
||
if (this.camera && this.decoManager.updateStreaming) {
|
||
const decoRadius = Math.max(18, (this._terrainStreamingRadius || 40) * 0.35);
|
||
this.decoManager.updateStreaming(
|
||
this.camera.position.x, this.camera.position.z, decoRadius,
|
||
{ maxBuild: 2 },
|
||
);
|
||
}
|
||
}
|
||
|
||
onProgress(100, 100, 'done');
|
||
return { voxels: voxels.length, treesPlaced, decorations: decorations?.length || 0, timeMs: statsTimeMs };
|
||
} finally {
|
||
// Снять глобальный лок ОБЯЗАТЕЛЬНО.
|
||
window.__voxelGenLock = false;
|
||
}
|
||
};
|
||
// Готовые пресеты для быстрого теста
|
||
window.__voxelPresets = {
|
||
default: DEFAULT_GENERATOR_PARAMS,
|
||
mountains: {
|
||
...DEFAULT_GENERATOR_PARAMS,
|
||
heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 50, exponent: 2.0 },
|
||
},
|
||
flat: {
|
||
...DEFAULT_GENERATOR_PARAMS,
|
||
heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 3, exponent: 1.0 },
|
||
},
|
||
islands: {
|
||
...DEFAULT_GENERATOR_PARAMS,
|
||
heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 15, exponent: 2.5 },
|
||
},
|
||
forest: {
|
||
...DEFAULT_GENERATOR_PARAMS,
|
||
biomes: DEFAULT_GENERATOR_PARAMS.biomes.map(b =>
|
||
b.id === 'plain' || b.id === 'forest'
|
||
? { ...b, features: { ...b.features, trees: 1.5 } }
|
||
: b,
|
||
),
|
||
},
|
||
};
|
||
|
||
// Этап 4: streaming контроль
|
||
window.__voxelWorldStreaming = (enabled, radius = 64) => {
|
||
this._voxelStreamingEnabled = !!enabled;
|
||
this._voxelStreamingRadius = radius;
|
||
if (!this._voxelRenderEnabled) {
|
||
console.log('[VoxelWorld] streaming on, но render выключен. Включи render: window.__voxelWorldRender(true)');
|
||
return;
|
||
}
|
||
if (!this._voxelStreamingEnabled) {
|
||
// Сброс: загрузить все чанки обратно
|
||
this.voxelRenderer.rebuildAll();
|
||
console.log('[VoxelWorld] streaming OFF — все чанки видимы');
|
||
return;
|
||
}
|
||
// Стартовый update вокруг камеры
|
||
const cam = this.camera;
|
||
const center = { x: cam.position.x, z: cam.position.z };
|
||
const r = this.voxelRenderer.updateStreaming(center, radius);
|
||
console.log(`[VoxelWorld] streaming ON, radius=${radius}m: ${r.loaded} loaded, ${r.unloaded} unloaded, ${this.voxelRenderer.getActiveChunkCount()}/${r.total} active`);
|
||
};
|
||
// Этап 3 benchmark: сравнить размер legacy JSON vs RLE+base64
|
||
window.__voxelWorldBenchmarkRLE = () => {
|
||
const t0 = performance.now();
|
||
const rleData = this.voxelWorld.serialize();
|
||
const t1 = performance.now();
|
||
const rleJson = JSON.stringify(rleData);
|
||
const t2 = performance.now();
|
||
const rleBytes = new Blob([rleJson]).size;
|
||
|
||
// Legacy формат для сравнения — массив {x,y,z,m}
|
||
const legacyVoxels = [];
|
||
const layer = this.voxelWorld.getLayer('terrain');
|
||
if (layer) {
|
||
for (const ch of layer.chunks.values()) {
|
||
const ox = ch.voxelOriginX();
|
||
const oy = ch.voxelOriginY();
|
||
const oz = ch.voxelOriginZ();
|
||
for (let i = 0; i < 32768; i++) {
|
||
const idx = ch.data[i];
|
||
if (idx === 0) continue;
|
||
const m = layer.matIdxToId(idx);
|
||
const lx = i % 32;
|
||
const lz = ((i / 32) | 0) % 32;
|
||
const ly = (i / 1024) | 0;
|
||
legacyVoxels.push({ x: ox + lx, y: oy + ly, z: oz + lz, m });
|
||
}
|
||
}
|
||
}
|
||
const t3 = performance.now();
|
||
const legacyJson = JSON.stringify(legacyVoxels);
|
||
const legacyBytes = new Blob([legacyJson]).size;
|
||
const t4 = performance.now();
|
||
|
||
const ratio = (legacyBytes / rleBytes).toFixed(1);
|
||
const sizes = this.voxelWorld.measureSize();
|
||
console.log('[RLE Benchmark]');
|
||
console.log(` Legacy JSON: ${(legacyBytes / 1024).toFixed(0)} KB (serialize: ${(t4 - t2).toFixed(0)} ms)`);
|
||
console.log(` RLE+base64: ${(rleBytes / 1024).toFixed(0)} KB (serialize: ${(t2 - t0).toFixed(0)} ms)`);
|
||
console.log(` Уменьшение: ${ratio}× меньше`);
|
||
console.log(' Подробно:', sizes);
|
||
return { legacy: legacyBytes, rle: rleBytes, ratio };
|
||
};
|
||
|
||
// Чистый benchmark mesh-build без создания Babylon-meshей. Это
|
||
// показывает скорость алгоритма greedy в отрыве от GPU.
|
||
window.__voxelWorldBenchmark = async () => {
|
||
const { buildChunkGeometryGreedy } = await import('./voxel/GreedyMesher');
|
||
const { buildChunkGeometry } = await import('./voxel/ChunkMesher');
|
||
const layer = this.voxelWorld.getLayer('terrain');
|
||
if (!layer) { console.warn('no terrain layer'); return; }
|
||
const neighborMatIdx = (gx, gy, gz) => layer.getMatIdx(gx, gy, gz);
|
||
|
||
// Surface culling (Этап 1)
|
||
let totalFacesNonGreedy = 0;
|
||
const t1 = performance.now();
|
||
for (const ch of layer.chunks.values()) {
|
||
const r = buildChunkGeometry(ch, layer, neighborMatIdx);
|
||
totalFacesNonGreedy += r.totalFaces;
|
||
}
|
||
const dt1 = performance.now() - t1;
|
||
|
||
// Greedy (Этап 2)
|
||
let totalFacesGreedy = 0;
|
||
const t2 = performance.now();
|
||
for (const ch of layer.chunks.values()) {
|
||
const r = buildChunkGeometryGreedy(ch, layer, neighborMatIdx);
|
||
totalFacesGreedy += r.totalFaces;
|
||
}
|
||
const dt2 = performance.now() - t2;
|
||
|
||
const reduction = ((1 - totalFacesGreedy / totalFacesNonGreedy) * 100).toFixed(1);
|
||
console.log(`[Benchmark] Surface culling: ${totalFacesNonGreedy} quads in ${dt1.toFixed(0)}ms`);
|
||
console.log(`[Benchmark] Greedy meshing: ${totalFacesGreedy} quads in ${dt2.toFixed(0)}ms — на ${reduction}% меньше квадров`);
|
||
return { surfaceCulling: { quads: totalFacesNonGreedy, ms: dt1 },
|
||
greedy: { quads: totalFacesGreedy, ms: dt2 },
|
||
reduction: `${reduction}%` };
|
||
};
|
||
window.__voxelWorldRender = (enabled) => {
|
||
this._voxelRenderEnabled = !!enabled;
|
||
if (this._voxelRenderEnabled) {
|
||
// Прячем legacy TerrainManager mesh'и
|
||
for (const proto of this.terrainManager._protoMeshes?.values?.() ?? []) {
|
||
proto.setEnabled(false);
|
||
}
|
||
// Если streaming ON — грузим только видимые чанки
|
||
// (rebuildAll потом бы их сразу half-выгрузил, лишняя работа).
|
||
if (this._voxelStreamingEnabled && this.camera) {
|
||
const cam = this.camera;
|
||
const r = this.voxelRenderer.updateStreaming(
|
||
{ x: cam.position.x, z: cam.position.z },
|
||
this._voxelStreamingRadius,
|
||
);
|
||
console.log(`[VoxelWorld] render ENABLED (streaming): ${r.loaded} loaded, ${this.voxelRenderer.getActiveChunkCount()}/${r.total} active`);
|
||
} else {
|
||
this.voxelRenderer.rebuildAll();
|
||
console.log('[VoxelWorld] render ENABLED, legacy hidden');
|
||
}
|
||
} else {
|
||
// Показываем legacy обратно, скрываем новый
|
||
for (const proto of this.terrainManager._protoMeshes?.values?.() ?? []) {
|
||
proto.setEnabled(true);
|
||
}
|
||
this.voxelRenderer.dispose();
|
||
console.log('[VoxelWorld] render DISABLED, legacy restored');
|
||
}
|
||
};
|
||
}
|
||
// Состояние brush'а ландшафта, обновляется из TerrainPanel.
|
||
// tool — 'select'|'transform'|'fill'|'sealevel'|'draw'|'sculpt'|'smooth'|'paint'|'flatten'
|
||
// material — id из TERRAIN_MATERIALS
|
||
// brushSize — радиус кисти в voxel'ах
|
||
// strength — 1..100
|
||
// shape — 'sphere'|'cube'|'cylinder'
|
||
this._terrainBrush = {
|
||
tool: 'sculpt',
|
||
material: 'grass',
|
||
brushSize: 4,
|
||
strength: 50,
|
||
shape: 'sphere',
|
||
// terrainMode: 'voxel' (по умолчанию) | 'smooth'.
|
||
// В smooth-режиме кисти редактируют DensityGrid через SmoothBrushes,
|
||
// в voxel — TerrainManager.voxels (как раньше).
|
||
terrainMode: 'voxel',
|
||
};
|
||
// Полупрозрачный preview-меш под курсором (показывает где будет кисть)
|
||
this._terrainBrushPreview = null;
|
||
this.modelManager = new ModelManager(this.scene);
|
||
this.modelManager.setScene3D(this);
|
||
// Делаем ModelManager доступным через scene — MultiplayerSync.js
|
||
// подхватывает его для shared-кэша GLB-прототипов.
|
||
this.scene._kubikonModelManager = this.modelManager;
|
||
|
||
// Этап 5 редактора моделей: менеджер пользовательских voxel-моделей.
|
||
// API подключается отдельно через setUserModelsApi (см. ниже),
|
||
// потому что Kubikon3DService импортируется через ES-modules.
|
||
this.userModelManager = new UserModelManager(this.scene);
|
||
// Глобальная функция для отладки: window.__kubikonDebugColliders()
|
||
// выводит в консоль все коллайдеры моделей и примитивов.
|
||
if (typeof window !== 'undefined') {
|
||
window.__kubikonDebugColliders = () => {
|
||
const out = [];
|
||
if (this.modelManager) {
|
||
for (const data of this.modelManager.instances.values()) {
|
||
const a = data.localAABB;
|
||
if (a) {
|
||
const w = (a.maxX - a.minX).toFixed(2);
|
||
const h = (a.maxY - a.minY).toFixed(2);
|
||
const d = (a.maxZ - a.minZ).toFixed(2);
|
||
out.push({
|
||
kind: 'model', id: data.modelTypeId,
|
||
pos: [data.x.toFixed(1), data.y.toFixed(1), data.z.toFixed(1)],
|
||
size: [w, h, d],
|
||
canCollide: data.canCollide,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
if (this.primitiveManager) {
|
||
for (const data of this.primitiveManager.instances.values()) {
|
||
out.push({
|
||
kind: 'primitive', type: data.type,
|
||
pos: [data.x.toFixed(1), data.y.toFixed(1), data.z.toFixed(1)],
|
||
size: [data.sx, data.sy, data.sz],
|
||
canCollide: data.canCollide,
|
||
});
|
||
}
|
||
}
|
||
console.table(out);
|
||
return out;
|
||
};
|
||
}
|
||
this.primitiveManager = new PrimitiveManager(this.scene);
|
||
// Ссылка на обёртку BabylonScene — нужна для эмиттеров частиц
|
||
// (createEmitterParticles живёт на обёртке).
|
||
this.primitiveManager.scene3d = this;
|
||
// BillboardUiManager — отдельный модуль, рисует GUI на DynamicTexture
|
||
// для билбордов. Нужен PrimitiveManager-у чтобы при создании billboard
|
||
// (type='billboard') сразу применить текстуру с дефолтным пресетом.
|
||
this.billboardUiManager = new BillboardUiManager(this.scene);
|
||
this.primitiveManager.billboardUiManager = this.billboardUiManager;
|
||
this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager);
|
||
this.guiManager = new GuiManager();
|
||
this.modalManager = new ModalManager();
|
||
this.modalManager.attachScene(this);
|
||
this.modalManager.attachGui(this.guiManager);
|
||
this.inventory = new InventoryManager();
|
||
this.physics = new PhysicsAABB(this.blockManager);
|
||
this.physics.setPrimitiveManager(this.primitiveManager);
|
||
this.physics.setModelManager(this.modelManager);
|
||
this.physics.setUserModelManager(this.userModelManager);
|
||
// Voxel-террейн тоже участвует в физике. У террейна свой размер
|
||
// ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно.
|
||
this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE);
|
||
// Этап 4 voxel-движка: подключаем новый chunk-based VoxelWorld к физике.
|
||
// Физика проверяет коллизии в обоих источниках (legacy terrainManager +
|
||
// voxelWorld), что позволяет постепенно мигрировать без поломки.
|
||
if (this.physics.setVoxelWorld && this.voxelWorld) {
|
||
this.physics.setVoxelWorld(this.voxelWorld);
|
||
}
|
||
this.dynamics = new DynamicsManager(this);
|
||
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
|
||
this.audioManager = new AudioManager();
|
||
this.assetManager = new AssetManager();
|
||
// PrimitiveManager должен уметь брать dataURL картинки по id ассета,
|
||
// чтобы применять пользовательскую текстуру на грани примитива.
|
||
this.primitiveManager.assetManager = this.assetManager;
|
||
// Библиотека пользовательских звуков (Фаза 5.5) — постоянная.
|
||
this.soundLibrary = new SoundLibrary();
|
||
// Библиотека импортированных .glb-моделей (Фаза 5.8) — постоянная.
|
||
this.glbLibrary = new GlbLibrary();
|
||
this.selection = new SelectionManager(this.scene, this.blockManager, this.modelManager);
|
||
this.selection.setPrimitiveManager(this.primitiveManager);
|
||
this.selection.setUserModelManager(this.userModelManager);
|
||
this.selection.setScene3D(this);
|
||
|
||
// === Обработка кликов по 3D-табличкам (billboard) в Play-режиме ===
|
||
// При клике луч из позиции курсора (либо из центра экрана, если игрок
|
||
// в pointer-lock) → ищем под ним меш типа billboard → переводим точку
|
||
// пересечения в UV → BillboardUiManager.pickButtonAt → fireClick.
|
||
// Работает только когда _isPlaying=true (в редакторе клик гизмо или ничего).
|
||
// Прямой capture-phase mousedown на canvas — раньше PlayerController.
|
||
// Babylon onPointerObservable не получает события в pointer-lock,
|
||
// поэтому ловим сами и стреляем лучом по табличкам в Play.
|
||
const canvasEl = this.canvas;
|
||
const onBillboardMouseDown = (e) => {
|
||
if (!this._isPlaying) return;
|
||
if (e.button !== 0) return;
|
||
const inLock = (document.pointerLockElement != null);
|
||
let px, py;
|
||
if (inLock) {
|
||
px = this.engine.getRenderWidth() / 2;
|
||
py = this.engine.getRenderHeight() / 2;
|
||
} else {
|
||
const rect = canvasEl.getBoundingClientRect();
|
||
px = e.clientX - rect.left;
|
||
py = e.clientY - rect.top;
|
||
}
|
||
const pi = this.scene.pick(px, py, (m) => {
|
||
return m.metadata?.isPrimitive
|
||
&& this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard';
|
||
});
|
||
if (!pi || !pi.hit || !pi.pickedMesh) return;
|
||
const meta = pi.pickedMesh.metadata;
|
||
const data = this.primitiveManager.instances.get(meta.primitiveId);
|
||
if (!data || data.type !== 'billboard') return;
|
||
const uv = pi.getTextureCoordinates ? pi.getTextureCoordinates() : null;
|
||
if (!uv) return;
|
||
const buttonId = this.billboardUiManager.pickButtonAt(data, uv.x, uv.y);
|
||
if (buttonId) {
|
||
this.billboardUiManager.fireClick(data, buttonId);
|
||
// Предотвращаем PlayerController-обработчик (pointer-lock и т.д.)
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
}
|
||
};
|
||
canvasEl.addEventListener('mousedown', onBillboardMouseDown, true /* capture */);
|
||
|
||
// GizmoController — управляет 3 типами гизмо (move/rotate/scale).
|
||
// UtilityLayer — отдельный слой, чтобы гизмо рисовалось поверх сцены.
|
||
// Babylon автоматически активирует pointer-observable utility-сцены
|
||
// когда родительская scene control включён (мы убрали detachControl).
|
||
this._gizmoLayer = new UtilityLayerRenderer(this.scene);
|
||
|
||
this._gizmo = new GizmoController(this._gizmoLayer, this.scene);
|
||
this._gizmo.setMode('select'); // по умолчанию — без манипулятора
|
||
this._gizmo.setSnap(1.0); // снэп для блоков
|
||
|
||
// При окончании drag — синхронизируем
|
||
this._gizmo.setOnDragEnd(() => this._onGizmoDragEnd());
|
||
|
||
// Привязка гизмо к выделенному
|
||
this.selection.setOnSelectionChange((sel) => this._updateGizmoForSelection(sel));
|
||
|
||
// History (Undo/Redo). Сериализатор и восстановитель — методы этой сцены.
|
||
this.history = new HistoryManager(
|
||
() => {
|
||
try { return JSON.stringify(this.serialize()); }
|
||
catch (e) { return null; }
|
||
},
|
||
async (state) => {
|
||
// При undo/redo — снимаем выделение (mesh может быть пересоздан)
|
||
this.selection?.clear();
|
||
await this.loadFromState(state);
|
||
}
|
||
);
|
||
// На любые изменения сцены — markChange (debounced)
|
||
this.blockManager.setOnChange(() => {
|
||
this.history?.markChange();
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
});
|
||
this.modelManager.setOnChange(() => {
|
||
this.history?.markChange();
|
||
// Сбрасываем spatial-индекс физики — модели могли двигаться/добавляться.
|
||
this.physics?.setSpatialDirty?.();
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
});
|
||
this.primitiveManager.setOnChange(() => {
|
||
this.history?.markChange();
|
||
this.physics?.setSpatialDirty?.();
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
});
|
||
// Этап 5: подписка на изменения user-моделей.
|
||
this.userModelManager.setOnChange(() => {
|
||
this.history?.markChange();
|
||
this.physics?.setSpatialDirty?.();
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
});
|
||
this.folderManager.setOnChange(() => {
|
||
this.history?.markChange();
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
});
|
||
this.guiManager.setOnChange(() => {
|
||
this.history?.markChange();
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
if (this._onGuiChange) this._onGuiChange();
|
||
// Если в Play — обновляем зеркало в Worker'ах сразу
|
||
if (this._isPlaying && this.gameRuntime) {
|
||
this.gameRuntime.scheduleGuiSnapshot();
|
||
}
|
||
});
|
||
this.inventory.setOnChange(() => {
|
||
this.history?.markChange();
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
if (this._onInventoryChange) this._onInventoryChange();
|
||
});
|
||
|
||
// Запоминаем начальное (пустое) состояние как точку для undo.
|
||
this.history.initialize();
|
||
|
||
this.engine.runRenderLoop(() => {
|
||
// Если рендер на паузе (например, активен таб скрипта или вкладка
|
||
// браузера в фоне) — пропускаем тик целиком. Освобождаем CPU/GPU
|
||
// для Monaco, который иначе лагает на ввод.
|
||
if (this._renderingPaused) return;
|
||
if (this.scene && this.scene.activeCamera) {
|
||
this._updateCameraMovement();
|
||
this._updateGhostPosition();
|
||
const dt = this.engine.getDeltaTime() / 1000;
|
||
// Физика unanchored-объектов в Play-режиме
|
||
if (this._isPlaying && this.dynamics?.isEnabled()) {
|
||
this.dynamics.tick(dt);
|
||
}
|
||
// Цикл дня/ночи (только в Play-режиме, чтобы редактор не «убегал»)
|
||
if (this._isPlaying && this.environment) {
|
||
this.environment.tick(dt);
|
||
}
|
||
// Анимация жидкостей — работает всегда (и в редакторе)
|
||
if (this.blockManager) {
|
||
this.blockManager.tick(dt);
|
||
}
|
||
// LOD/culling далёких моделей (раз в 5 кадров — экономим CPU)
|
||
this._lodFrameCounter = (this._lodFrameCounter || 0) + 1;
|
||
if (this._lodFrameCounter % 5 === 0) {
|
||
this._updateModelLOD();
|
||
// Примитивы НЕ култим по дистанции — на компактных сценах
|
||
// (Squid Game) это убирает куклу/охранников вдали и пользователь
|
||
// видит пустое поле. Лучшее решение — пусть Babylon
|
||
// frustum-cull'ит сам, у нас уже freezeWorldMatrix.
|
||
}
|
||
// Этап 4 voxel-streaming: подгрузка/выгрузка чанков по радиусу
|
||
// от игрока (в Play) или камеры (в редакторе). Дёргаем раз в
|
||
// 250мс — этого достаточно при ходьбе.
|
||
// VoxelWorld streaming (новый рендер) — disabled by default,
|
||
// используется TerrainManager streaming ниже (legacy подход).
|
||
if (this._voxelStreamingEnabled && this._voxelRenderEnabled && this.voxelRenderer) {
|
||
const nowMs = performance.now();
|
||
if (nowMs - this._voxelStreamingLastUpdate > 200) {
|
||
this._voxelStreamingLastUpdate = nowMs;
|
||
let cx, cz;
|
||
if (this._isPlaying && this.player && this.player._pos) {
|
||
cx = this.player._pos.x; cz = this.player._pos.z;
|
||
} else if (this.camera && this.camera.position) {
|
||
cx = this.camera.position.x; cz = this.camera.position.z;
|
||
}
|
||
if (cx !== undefined) {
|
||
this.voxelRenderer.updateStreaming({ x: cx, z: cz }, this._voxelStreamingRadius);
|
||
}
|
||
}
|
||
}
|
||
|
||
// === LEGACY TerrainManager streaming (region-meshes) ===
|
||
// Главный механизм производительности для больших карт:
|
||
// enable/disable region-meshes legacy террейна по радиусу
|
||
// от игрока/камеры. Регионы за пределами radius — disabled,
|
||
// не рендерятся GPU.
|
||
if (this._terrainStreamingEnabled && this.terrainManager?.updateStreaming) {
|
||
const nowMs2 = performance.now();
|
||
// 200мс — реже чем раньше (было 80мс). Streaming = тяжёлая
|
||
// операция (обход всех region-meshes), не нужна каждые 80мс.
|
||
if (nowMs2 - (this._terrainStreamingLastUpdate || 0) > 200) {
|
||
this._terrainStreamingLastUpdate = nowMs2;
|
||
let cx, cz;
|
||
let radius = this._terrainStreamingRadius || 60;
|
||
if (this._isPlaying && this.player && this.player._pos) {
|
||
cx = this.player._pos.x; cz = this.player._pos.z;
|
||
} else if (this.camera && this.camera.position) {
|
||
cx = this.camera.position.x; cz = this.camera.position.z;
|
||
const camY = this.camera.position.y || 0;
|
||
// Editor radius = play × 1.3 + height bonus. Capped 60м.
|
||
// Раньше было ×1.6 + 30 = до 85м (47 регионов в кадре = 14M trianglов).
|
||
// Сейчас 32 × 1.3 + 20 = до 60м (~20-25 регионов = ~5M trianglов).
|
||
const heightBonus = Math.max(0, Math.min(20, camY * 0.3));
|
||
radius = Math.min(60, radius * 1.3 + heightBonus);
|
||
}
|
||
// SKIP если камера не сдвинулась >3м с прошлого пересчёта
|
||
const prevX = this._terrainStreamingPrevX;
|
||
const prevZ = this._terrainStreamingPrevZ;
|
||
if (cx !== undefined && prevX !== undefined) {
|
||
const ddx = cx - prevX, ddz = cz - prevZ;
|
||
if (ddx * ddx + ddz * ddz < 9) { // < 3м
|
||
cx = undefined; // отменяет следующий блок
|
||
}
|
||
}
|
||
if (cx !== undefined) {
|
||
this._terrainStreamingPrevX = cx;
|
||
this._terrainStreamingPrevZ = cz;
|
||
}
|
||
if (cx !== undefined) {
|
||
this.terrainManager.updateStreaming(cx, cz, radius);
|
||
// Этап D: deco streaming с МЕНЬШИМ радиусом.
|
||
// Декорации видны только вблизи. Минимум 35м чтобы
|
||
// chunk 64м не пропадал — иначе видны «дыры».
|
||
if (this.decoManager?.updateStreaming) {
|
||
const decoRadius = Math.max(18, radius * 0.35);
|
||
this.decoManager.updateStreaming(cx, cz, decoRadius);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Задача 04: modalManager.tick — независимо от runtime'а
|
||
if (this._isPlaying && this.modalManager?.tick) {
|
||
try { this.modalManager.tick(dt); } catch (e) {}
|
||
}
|
||
// Tick пользовательских скриптов: в Play-режиме или в solo-debug
|
||
if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) {
|
||
this.gameRuntime.tick(dt);
|
||
// Детекция touch-событий — раз в 3 кадра (для script onTouch).
|
||
// Это O(N×M) = скрипты × примитивы, на мобиле просаживало FPS
|
||
// при повороте. 3 кадра ≈ 50мс при 60fps — хватает для UX.
|
||
if (this._isPlaying) {
|
||
this._touchDetectFrame = (this._touchDetectFrame || 0) + 1;
|
||
if (this._touchDetectFrame >= 3) {
|
||
this._touchDetectFrame = 0;
|
||
this._detectTouchEvents();
|
||
}
|
||
}
|
||
}
|
||
// Анимация полоски перезарядки оружия
|
||
if (this._isPlaying && this.weapons) this.weapons.tick();
|
||
// PERF-METRICS: замеряем render() — обычно самая толстая часть.
|
||
const _rt0 = performance.now();
|
||
// Замер idle-времени = промежуток между концом предыдущего
|
||
// render и началом текущего. Большое idle = GPU-bound.
|
||
if (this._perfMetrics && this._perfMetrics._lastRenderEnd > 0) {
|
||
const idle = _rt0 - this._perfMetrics._lastRenderEnd;
|
||
if (idle > 0 && idle < 1000) {
|
||
this._perfMetrics.idle_ms_sum += idle;
|
||
this._perfMetrics.idle_count++;
|
||
}
|
||
}
|
||
this.scene.render();
|
||
if (this._perfMetrics) {
|
||
const _rt1 = performance.now();
|
||
this._perfMetrics.render_ms_sum += _rt1 - _rt0;
|
||
this._perfMetrics.render_count++;
|
||
this._perfMetrics._lastRenderEnd = _rt1;
|
||
}
|
||
}
|
||
});
|
||
|
||
// resize при изменении окна
|
||
this._resizeHandler = () => this.engine.resize();
|
||
window.addEventListener('resize', this._resizeHandler);
|
||
|
||
// Главное: ResizeObserver на canvas. React сначала рендерит canvas
|
||
// размером 0, потом раскладка применяет 100%/100%. Engine, созданный
|
||
// в момент init, считал размер 0 и backbuffer был пуст. Через RO
|
||
// ловим финальный размер и вызываем resize.
|
||
if (typeof ResizeObserver !== 'undefined') {
|
||
this._ro = new ResizeObserver(() => {
|
||
if (this.engine) this.engine.resize();
|
||
});
|
||
this._ro.observe(this.canvas);
|
||
}
|
||
|
||
// Принудительный resize чуть позже — на случай если RO не сработал
|
||
setTimeout(() => { if (this.engine) this.engine.resize(); }, 100);
|
||
}
|
||
|
||
/**
|
||
* UniversalCamera — позволяет ручное управление позицией и yaw/pitch.
|
||
* Стартовая позиция: смотрим на (0,0,0) сверху-сбоку.
|
||
*/
|
||
_createCamera() {
|
||
const camera = new UniversalCamera(
|
||
'editorCamera',
|
||
new Vector3(15, 15, -20),
|
||
this.scene
|
||
);
|
||
camera.setTarget(new Vector3(0, 0, 0));
|
||
camera.minZ = 0.1;
|
||
camera.maxZ = 1000;
|
||
camera.fov = 0.9;
|
||
|
||
// ОТКЛЮЧАЕМ стандартное управление — будем писать своё.
|
||
camera.inputs.clear();
|
||
|
||
this.camera = camera;
|
||
}
|
||
|
||
_createLights() {
|
||
const hemi = new HemisphericLight(
|
||
'hemiLight',
|
||
new Vector3(0, 1, 0),
|
||
this.scene
|
||
);
|
||
hemi.intensity = 0.65;
|
||
hemi.groundColor = new Color3(0.3, 0.3, 0.4);
|
||
|
||
const sun = new DirectionalLight(
|
||
'sunLight',
|
||
new Vector3(-0.5, -1, -0.3),
|
||
this.scene
|
||
);
|
||
sun.intensity = 0.8;
|
||
sun.position = new Vector3(20, 40, 20);
|
||
|
||
// Сохраняем ссылки чтобы Environment мог менять их свойства
|
||
this._hemiLight = hemi;
|
||
this._sunLight = sun;
|
||
|
||
// Тени — по умолчанию мягкие. Создаётся ShadowGenerator при первом
|
||
// вызове setShadowQuality, либо сразу через _ensureShadowGenerator.
|
||
// MOBILE-OPT (этап 1.5): на мобильном тени = 'hard' (жёсткие — без
|
||
// soft-blur, дешевле). 'off' давало плоскую картинку, теперь
|
||
// компромисс — есть тени но дешевле soft.
|
||
this._shadowQuality = this._isMobileMode ? 'hard' : 'soft';
|
||
this._shadowGenerator = null;
|
||
this._ensureShadowGenerator();
|
||
|
||
// SSAO2 — контактные тени (по умолчанию off, тяжёлый эффект).
|
||
this._ssaoPipeline = null;
|
||
this._ssaoEnabled = false;
|
||
}
|
||
|
||
/** Создаёт ShadowGenerator (если ещё нет) и применяет текущее качество.
|
||
*
|
||
* Уровни (Этап 2+5 теней, 2026-05-27):
|
||
* - 'off' — теней нет
|
||
* - 'hard' — резкие, 512px
|
||
* - 'soft' — мягкие, 1024px (на mobile 512), blurKernel 24
|
||
* - 'medium' — CSM 1024 × 3 каскада
|
||
* - 'high' — CSM 2048 × 4 каскада
|
||
*/
|
||
_ensureShadowGenerator() {
|
||
const q = this._shadowQuality;
|
||
if (q === 'off') {
|
||
if (this._shadowGenerator) {
|
||
try { this._shadowGenerator.dispose(); } catch (e) { /* ignore */ }
|
||
this._shadowGenerator = null;
|
||
}
|
||
return null;
|
||
}
|
||
const wantCsm = (q === 'medium' || q === 'high');
|
||
const haveCsm = this._shadowGenerator instanceof CascadedShadowGenerator;
|
||
if (this._shadowGenerator && wantCsm !== haveCsm) {
|
||
try { this._shadowGenerator.dispose(); } catch (e) { /* ignore */ }
|
||
this._shadowGenerator = null;
|
||
}
|
||
// PCF = Percentage Closer Filtering. Это правильная техника мягких
|
||
// теней (контурное сглаживание по shadow-map).
|
||
//
|
||
// bias/normalBias подобраны под воксельную графику:
|
||
// bias = 0.0005 — сдвиг по глубине, убирает acne на flat-граней
|
||
// normalBias = 0.005 — сдвиг вдоль нормали, убирает acne на острых
|
||
// кромках. БОЛЬШИЕ значения дают peter-panning
|
||
// (тень отрывается от объекта и улетает в
|
||
// сторону солнца). Было 0.012 → длинная
|
||
// "уехавшая" тень на скрине пользователя
|
||
// 2026-05-27. 0.005 — золотая середина для
|
||
// кубов 1м с прямыми гранями.
|
||
const PCF_BIAS = 0.0005;
|
||
const PCF_NORMAL_BIAS = 0.005;
|
||
|
||
if (!this._shadowGenerator) {
|
||
if (wantCsm) {
|
||
// CSM: больше разрешение каскадов — тени детальнее.
|
||
const size = (q === 'high') ? 4096 : 2048;
|
||
const numCascades = (q === 'high') ? 4 : 3;
|
||
const csm = new CascadedShadowGenerator(size, this._sunLight);
|
||
csm.numCascades = numCascades;
|
||
csm.stabilizeCascades = true;
|
||
csm.lambda = 0.8;
|
||
csm.cascadeBlendPercentage = 0.07;
|
||
csm.shadowMaxZ = (q === 'high') ? 200 : 120;
|
||
csm.bias = PCF_BIAS;
|
||
csm.normalBias = PCF_NORMAL_BIAS;
|
||
csm.usePercentageCloserFiltering = true;
|
||
csm.filteringQuality = (q === 'high')
|
||
? ShadowGenerator.QUALITY_HIGH
|
||
: ShadowGenerator.QUALITY_MEDIUM;
|
||
csm.darkness = 0.4;
|
||
csm.autoCalcDepthBounds = true;
|
||
this._shadowGenerator = csm;
|
||
} else {
|
||
// Обычный ShadowGenerator. Поднял разрешение для soft до 2048.
|
||
let shadowSize;
|
||
if (q === 'hard') {
|
||
shadowSize = this._isMobileMode ? 512 : 1024;
|
||
} else { // soft
|
||
shadowSize = this._isMobileMode ? 1024 : 2048;
|
||
}
|
||
const gen = new ShadowGenerator(shadowSize, this._sunLight);
|
||
gen.bias = PCF_BIAS;
|
||
gen.normalBias = PCF_NORMAL_BIAS;
|
||
if (gen.getShadowMap) {
|
||
const rtt = gen.getShadowMap();
|
||
if (rtt) rtt.refreshRate = 2;
|
||
}
|
||
this._shadowGenerator = gen;
|
||
}
|
||
}
|
||
const gen = this._shadowGenerator;
|
||
if (q === 'medium' || q === 'high') {
|
||
// параметры CSM выставлены при создании (PCF + high quality)
|
||
} else if (q === 'soft') {
|
||
// PCF medium quality — мягкие тени без шумного блюра
|
||
gen.usePercentageCloserFiltering = true;
|
||
gen.filteringQuality = ShadowGenerator.QUALITY_MEDIUM;
|
||
gen.useBlurExponentialShadowMap = false;
|
||
gen.useKernelBlur = false;
|
||
gen.usePoissonSampling = false;
|
||
gen.darkness = 0.4;
|
||
} else { // hard
|
||
gen.usePercentageCloserFiltering = false;
|
||
gen.useBlurExponentialShadowMap = false;
|
||
gen.useKernelBlur = false;
|
||
gen.usePoissonSampling = false;
|
||
gen.darkness = 0.55;
|
||
}
|
||
return gen;
|
||
}
|
||
|
||
/** Изменить качество теней. */
|
||
setShadowQuality(q) {
|
||
const allowed = ['off', 'hard', 'soft', 'medium', 'high'];
|
||
if (!allowed.includes(q)) return;
|
||
this._shadowQuality = q;
|
||
this._ensureShadowGenerator();
|
||
const ground = this.scene.getMeshByName('editorGround');
|
||
if (ground) ground.receiveShadows = q !== 'off';
|
||
if (q !== 'off') {
|
||
try { this.refreshAllShadows(); } catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
getShadowQuality() { return this._shadowQuality || 'soft'; }
|
||
|
||
/** Включить/выключить SSAO пост-эффект (контактные тени).
|
||
*
|
||
* Используем SSAORenderingPipeline v1 (не v2). v2 требует
|
||
* GeometryBufferRenderer (G-buffer для нормалей), что ломает рендер
|
||
* thin-instance мешей (BlockManager, TerrainManager) — блоки пропадают
|
||
* при включении. v1 использует только depthRenderer (одна depth-текстура),
|
||
* совместим со всеми типами мешей. Качество чуть хуже, но стабильно.
|
||
*/
|
||
setSsaoEnabled(on) {
|
||
const want = !!on;
|
||
if (this._ssaoEnabled === want && (!want || this._ssaoPipeline)) return;
|
||
if (!want) {
|
||
this._disposeSsaoPipeline();
|
||
this._ssaoEnabled = false;
|
||
return;
|
||
}
|
||
if (!this.scene.activeCamera) {
|
||
console.warn('[BabylonScene] SSAO: нет активной камеры');
|
||
return;
|
||
}
|
||
try {
|
||
// ratio: render-resolution для AO-пасса (0.5 = half-res, дешевле).
|
||
// combineRatio: финальная композиция всегда 1.0 (full-res).
|
||
const ratio = { ssaoRatio: 0.5, combineRatio: 1.0 };
|
||
const pipeline = new SSAORenderingPipeline(
|
||
'ssaopipeline', this.scene, ratio, [this.scene.activeCamera]
|
||
);
|
||
pipeline.fallOff = 0.000001;
|
||
pipeline.area = 0.0075;
|
||
pipeline.radius = 0.0001;
|
||
pipeline.totalStrength = 1.0; // насколько тёмные углы
|
||
pipeline.base = 0.5; // базовая яркость (1.0 = без AO)
|
||
this._ssaoPipeline = pipeline;
|
||
this._ssaoEnabled = true;
|
||
} catch (e) {
|
||
console.warn('[BabylonScene] SSAO не запустился:', e?.message || e);
|
||
this._disposeSsaoPipeline();
|
||
this._ssaoEnabled = false;
|
||
}
|
||
}
|
||
|
||
/** Полностью убрать SSAO пайплайн (detach + remove + dispose).
|
||
* Только dispose() оставляет null-объект в postProcessRenderPipelineManager
|
||
* → следующий кадр падает в null.isSupported. */
|
||
_disposeSsaoPipeline() {
|
||
if (!this._ssaoPipeline) return;
|
||
const mgr = this.scene.postProcessRenderPipelineManager;
|
||
const name = this._ssaoPipeline.name || 'ssaopipeline';
|
||
try {
|
||
if (mgr && this.scene.activeCamera) {
|
||
mgr.detachCamerasFromRenderPipeline?.(name, this.scene.activeCamera);
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
try {
|
||
// Удаляем из менеджера ДО dispose — иначе на следующем кадре
|
||
// менеджер обходит уже задиспозенный пайплайн.
|
||
if (mgr && typeof mgr.removePipeline === 'function') {
|
||
mgr.removePipeline(name);
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
try { this._ssaoPipeline.dispose(); } catch (e) { /* ignore */ }
|
||
this._ssaoPipeline = null;
|
||
}
|
||
|
||
getSsaoEnabled() { return this._ssaoEnabled; }
|
||
|
||
/**
|
||
* Установить свойства глобального освещения. Вызывается из Inspector
|
||
* (selection.type === 'lighting').
|
||
* patch: { sunIntensity?, hemiIntensity?, hemiGround?, fogEnabled?,
|
||
* fogDensity?, fogColor?, shadowQuality? }
|
||
*/
|
||
setLightingProps(patch) {
|
||
if (!patch) return;
|
||
// Время суток — пресет / минуты день/ночь
|
||
if (patch.envPreset && this.environment) {
|
||
try { this.environment.setPreset(patch.envPreset); } catch (e) { /* ignore */ }
|
||
}
|
||
if (typeof patch.dayDurationMin === 'number' && patch.dayDurationMin > 0 && this.environment) {
|
||
this.environment.setCycleDuration(patch.dayDurationMin, this.environment.nightDurationMin);
|
||
}
|
||
if (typeof patch.nightDurationMin === 'number' && patch.nightDurationMin > 0 && this.environment) {
|
||
this.environment.setCycleDuration(this.environment.dayDurationMin, patch.nightDurationMin);
|
||
}
|
||
if (typeof patch.sunIntensity === 'number' && this._sunLight) {
|
||
this._sunLight.intensity = Math.max(0, patch.sunIntensity);
|
||
}
|
||
if (typeof patch.hemiIntensity === 'number' && this._hemiLight) {
|
||
this._hemiLight.intensity = Math.max(0, patch.hemiIntensity);
|
||
}
|
||
if (this.environment && typeof this.environment.setFog === 'function') {
|
||
// Текущие значения берём из Environment, поверх накладываем patch
|
||
const enabled = (typeof patch.fogEnabled === 'boolean')
|
||
? patch.fogEnabled : this.environment.fogEnabled;
|
||
let color = this.environment.fogColor;
|
||
if (patch.fogColor && /^#[0-9a-fA-F]{6}$/.test(patch.fogColor)) {
|
||
color = [
|
||
parseInt(patch.fogColor.substr(1, 2), 16) / 255,
|
||
parseInt(patch.fogColor.substr(3, 2), 16) / 255,
|
||
parseInt(patch.fogColor.substr(5, 2), 16) / 255,
|
||
];
|
||
}
|
||
const density = (typeof patch.fogDensity === 'number')
|
||
? patch.fogDensity : this.environment.fogDensity;
|
||
if ('fogEnabled' in patch || 'fogDensity' in patch || 'fogColor' in patch) {
|
||
this.environment.setFog(enabled, color, density);
|
||
}
|
||
}
|
||
if (patch.shadowQuality) {
|
||
this.setShadowQuality(patch.shadowQuality);
|
||
this.refreshAllShadows();
|
||
}
|
||
if (typeof patch.ssaoEnabled === 'boolean') {
|
||
this.setSsaoEnabled(patch.ssaoEnabled);
|
||
}
|
||
// Обновить selection чтобы Inspector сразу показывал новые значения
|
||
if (this.selection?._selection?.type === 'lighting') {
|
||
this.selection.selectLighting();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Сгруппировать текущие выделенные объекты в новую папку (Ctrl+G).
|
||
* Если выделен один — кладёт его одного. Если ничего — no-op.
|
||
*/
|
||
groupSelected(name = null) {
|
||
if (!this.folderManager || !this.selection) return null;
|
||
const multi = this.selection.getMultiSelection();
|
||
const items = [];
|
||
if (multi.length > 0) {
|
||
for (const it of multi) items.push(it);
|
||
} else {
|
||
const s = this.selection.getSelection();
|
||
if (!s) return null;
|
||
if (s.type === 'block') items.push({ kind: 'block', ref: { x: s.gridX, y: s.gridY, z: s.gridZ } });
|
||
else if (s.type === 'model') items.push({ kind: 'model', ref: s.instanceId });
|
||
else if (s.type === 'primitive') items.push({ kind: 'primitive', ref: s.id });
|
||
else return null;
|
||
}
|
||
if (items.length === 0) return null;
|
||
const folderName = name || `Группа ${this.folderManager.getAll().length + 1}`;
|
||
const folderId = this.folderManager.createFolder(folderName, null);
|
||
for (const it of items) {
|
||
this.folderManager.assignToFolder(it.kind, it.ref, folderId);
|
||
}
|
||
return folderId;
|
||
}
|
||
|
||
/**
|
||
* Зарегистрировать меш как «отбрасывающий тень». Безопасно вызывать многократно.
|
||
* ВАЖНО: только настоящие Mesh (с геометрией), а не TransformNode-узлы.
|
||
* ShadowGenerator вызывает getBoundingInfo()/getVerticesData() — у TransformNode
|
||
* этих методов нет, что приводит к runtime-крашу.
|
||
*/
|
||
/** Удалено: пытались через ShadowGenerator, не сработало.
|
||
* Теперь тени делает GdGroundSkin через синтетические «тени-кружки». */
|
||
_enableGdShadows() { /* no-op */ }
|
||
|
||
addShadowCaster(mesh) {
|
||
if (!this._shadowGenerator || !mesh) return;
|
||
// TransformNode не имеет getBoundingInfo/getVerticesData
|
||
if (typeof mesh.getBoundingInfo !== 'function') return;
|
||
if (typeof mesh.getTotalVertices !== 'function') return;
|
||
if (mesh.getTotalVertices() <= 0) return;
|
||
try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ }
|
||
}
|
||
|
||
/**
|
||
* Зарегистрировать все текущие блоки/модели/примитивы как shadow casters.
|
||
* Полезно вызвать после loadFromState или смены качества теней.
|
||
*/
|
||
refreshAllShadows() {
|
||
if (!this._shadowGenerator) return;
|
||
if (this.blockManager) {
|
||
// Для thin-instance proto-мешей: один addShadowCaster на тип = все инстансы
|
||
if (this.blockManager._protoMeshes) {
|
||
for (const proto of this.blockManager._protoMeshes.values()) {
|
||
this.addShadowCaster(proto);
|
||
}
|
||
}
|
||
// Жидкости/legacy mesh
|
||
for (const m of this.blockManager.blocks.values()) {
|
||
if (m && typeof m.getBoundingInfo === 'function') this.addShadowCaster(m);
|
||
}
|
||
}
|
||
if (this.modelManager) {
|
||
for (const inst of this.modelManager.instances.values()) {
|
||
const root = inst.rootMesh;
|
||
if (!root) continue;
|
||
// root обычно TransformNode → пропускаем сам root, добавляем только child-mesh'ы
|
||
if (typeof root.getChildMeshes === 'function') {
|
||
for (const cm of root.getChildMeshes()) this.addShadowCaster(cm);
|
||
}
|
||
}
|
||
}
|
||
if (this.primitiveManager) {
|
||
for (const inst of this.primitiveManager.instances.values()) {
|
||
if (inst.mesh) this.addShadowCaster(inst.mesh);
|
||
}
|
||
}
|
||
}
|
||
|
||
_createGroundGrid() {
|
||
// Размер мира — настраивается через setWorldSize(). Пол идёт от -WORLD_HALF до +WORLD_HALF.
|
||
const WORLD_HALF = this._worldHalf;
|
||
|
||
const ground = MeshBuilder.CreateGround(
|
||
'editorGround',
|
||
{ width: WORLD_HALF * 2, height: WORLD_HALF * 2, subdivisions: 1 },
|
||
this.scene
|
||
);
|
||
|
||
// Baseplate-текстура (как в Roblox Studio): процедурный клетчатый паттерн.
|
||
// Рисуем в DynamicTexture — каждая «плитка» 1×1 на грани соответствует
|
||
// 1 единице мира. Делаем 64×64 пикселей — каждый пиксель = 1 квадрат.
|
||
const TEX_SIZE = 64;
|
||
const baseTex = new DynamicTexture('baseplateTex', { width: TEX_SIZE, height: TEX_SIZE }, this.scene, false);
|
||
baseTex.wrapU = 1; // wrap
|
||
baseTex.wrapV = 1;
|
||
baseTex.uScale = WORLD_HALF * 2 / 4; // одна повторка покрывает 4 клетки
|
||
baseTex.vScale = WORLD_HALF * 2 / 4;
|
||
baseTex.updateSamplingMode(DynamicTexture.NEAREST_SAMPLINGMODE);
|
||
const ctx = baseTex.getContext();
|
||
// Серая основа
|
||
ctx.fillStyle = '#7a8071';
|
||
ctx.fillRect(0, 0, TEX_SIZE, TEX_SIZE);
|
||
// Тёмные «рейки» по периметру плитки (Roblox-style)
|
||
ctx.strokeStyle = '#5d6358';
|
||
ctx.lineWidth = 4;
|
||
ctx.strokeRect(2, 2, TEX_SIZE - 4, TEX_SIZE - 4);
|
||
// Тонкая внутренняя сетка 4 на плитке
|
||
ctx.strokeStyle = '#6c7268';
|
||
ctx.lineWidth = 1;
|
||
for (let i = 1; i < 4; i++) {
|
||
const p = (i * TEX_SIZE) / 4;
|
||
ctx.beginPath();
|
||
ctx.moveTo(p, 2); ctx.lineTo(p, TEX_SIZE - 2);
|
||
ctx.moveTo(2, p); ctx.lineTo(TEX_SIZE - 2, p);
|
||
ctx.stroke();
|
||
}
|
||
baseTex.update();
|
||
|
||
const mat = new StandardMaterial('groundMat', this.scene);
|
||
mat.diffuseTexture = baseTex;
|
||
mat.specularColor = new Color3(0, 0, 0);
|
||
ground.material = mat;
|
||
ground.receiveShadows = true;
|
||
|
||
// Только две осевые линии (X и Z), цветные — для ориентации в редакторе.
|
||
// Сетку делает сама baseplate-текстура.
|
||
const axisMatX = new StandardMaterial('axisMatX', this.scene);
|
||
axisMatX.diffuseColor = new Color3(0.85, 0.25, 0.25);
|
||
axisMatX.emissiveColor = new Color3(0.5, 0.1, 0.1);
|
||
axisMatX.specularColor = new Color3(0, 0, 0);
|
||
|
||
const axisMatZ = new StandardMaterial('axisMatZ', this.scene);
|
||
axisMatZ.diffuseColor = new Color3(0.25, 0.4, 0.85);
|
||
axisMatZ.emissiveColor = new Color3(0.1, 0.2, 0.5);
|
||
axisMatZ.specularColor = new Color3(0, 0, 0);
|
||
|
||
// Ось X (красная) — линия вдоль X на z=0
|
||
const axisX = MeshBuilder.CreateBox('axisX',
|
||
{ width: WORLD_HALF * 2, height: 0.02, depth: 0.1 }, this.scene);
|
||
axisX.position = new Vector3(0, 0.011, 0);
|
||
axisX.material = axisMatX;
|
||
axisX.isPickable = false;
|
||
this._gridLines = [axisX];
|
||
|
||
// Ось Z (синяя) — линия вдоль Z на x=0
|
||
const axisZ = MeshBuilder.CreateBox('axisZ',
|
||
{ width: 0.1, height: 0.02, depth: WORLD_HALF * 2 }, this.scene);
|
||
axisZ.position = new Vector3(0, 0.011, 0);
|
||
axisZ.material = axisMatZ;
|
||
axisZ.isPickable = false;
|
||
this._gridLines.push(axisZ);
|
||
}
|
||
|
||
/**
|
||
* Изменить размер пола (worldSize × worldSize). Пересоздаёт пол и осевые линии.
|
||
* @param {number} worldSize — полный размер стороны пола в юнитах (например 100, 200, 500).
|
||
*/
|
||
setWorldSize(worldSize) {
|
||
const half = Math.max(10, Math.round(worldSize / 2));
|
||
if (half === this._worldHalf) return;
|
||
this._worldHalf = half;
|
||
// Удалить старый пол + осевые линии
|
||
const oldGround = this.scene.getMeshByName('editorGround');
|
||
if (oldGround) try { oldGround.dispose(); } catch (e) { /* ignore */ }
|
||
if (Array.isArray(this._gridLines)) {
|
||
for (const line of this._gridLines) {
|
||
try { line.dispose(); } catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
this._gridLines = [];
|
||
this._createGroundGrid();
|
||
}
|
||
|
||
/** Текущий размер пола в юнитах (worldSize, не worldHalf). */
|
||
getWorldSize() { return this._worldHalf * 2; }
|
||
|
||
/** Включить/выключить пол (визуально и физически). */
|
||
setFloorEnabled(enabled) {
|
||
this._floorEnabled = !!enabled;
|
||
if (!this.scene) return;
|
||
const ground = this.scene.getMeshByName('editorGround');
|
||
if (ground) ground.setEnabled(this._floorEnabled);
|
||
// Линии осей тоже
|
||
if (Array.isArray(this._gridLines)) {
|
||
for (const line of this._gridLines) {
|
||
if (line && line.setEnabled) line.setEnabled(this._floorEnabled);
|
||
}
|
||
}
|
||
// Физика: отключаем коллизию с baseplate, чтобы игрок проваливался
|
||
if (this.physics) this.physics.floorEnabled = this._floorEnabled;
|
||
}
|
||
isFloorEnabled() { return this._floorEnabled !== false; }
|
||
|
||
/**
|
||
* Очистить гладкий ландшафт (RobloxTerrain) — убирает все chunks,
|
||
* отвязывает от физики, возвращает baseplate-пол, ставит spawn по умолчанию.
|
||
* Вызывается из UI (кнопка «✖» в Генератор-панели).
|
||
*/
|
||
clearRobloxTerrain() {
|
||
let hadTerrain = false;
|
||
if (this._robloxTerrain) {
|
||
try { this._robloxTerrain.disposeAll(); hadTerrain = true; } catch (e) {}
|
||
// ВАЖНО: обнуляем ссылку, иначе __robloxTest при новой генерации
|
||
// решит что terrain уже есть и НЕ переподключит его к physics.
|
||
this._robloxTerrain = null;
|
||
}
|
||
// Декорации тоже чистим (thin-instances очищаются, prototype остаётся
|
||
// в памяти для следующего применения).
|
||
if (this._smoothDecoManager) {
|
||
try { this._smoothDecoManager.clear(); } catch (e) {}
|
||
}
|
||
this._smoothDecoParams = null;
|
||
if (this.physics?.setRobloxTerrain) {
|
||
this.physics.setRobloxTerrain(null);
|
||
}
|
||
if (this.physics?.setSmoothDecoTrees) {
|
||
this.physics.setSmoothDecoTrees(null);
|
||
}
|
||
try { this.setFloorEnabled(true); } catch (e) {}
|
||
this._spawnPoint = { x: 0, y: 5, z: 0 };
|
||
try { this._updateSpawnMarker?.(); } catch (e) {}
|
||
// Прячем мини-карту гладкого ландшафта — grid больше нет.
|
||
if (window.__robloxMinimapGrid) {
|
||
window.__robloxMinimapGrid = null;
|
||
this._terrainStreamingEnabled = false;
|
||
}
|
||
// Помечаем dirty чтобы автосейв записал пустой robloxTerrain
|
||
try { this._onSceneChange?.(); } catch (e) {}
|
||
console.log(`[BabylonScene] clearRobloxTerrain (hadTerrain=${hadTerrain})`);
|
||
}
|
||
|
||
/**
|
||
* Множитель силы прыжка игрока. 1 = базовый (~8 у/с), 1.5 = в 1.5 раза выше.
|
||
* Применяется при enterPlayMode и через player.setJumpPower.
|
||
*/
|
||
setPlayerJumpPower(mul) {
|
||
const m = Math.max(0.2, Math.min(5, Number(mul) || 1));
|
||
this._jumpPowerMul = m;
|
||
if (this.player) this.player._jumpPowerMul = m;
|
||
}
|
||
getPlayerJumpPower() { return this._jumpPowerMul ?? 1; }
|
||
|
||
/** Тип прицела в Play: 'none' | 'dot' | 'cross' | 'circle'. */
|
||
setCrosshair(type) {
|
||
const allowed = ['none', 'dot', 'cross', 'circle'];
|
||
if (!allowed.includes(type)) return;
|
||
this._crosshair = type;
|
||
}
|
||
getCrosshair() { return this._crosshair || 'none'; }
|
||
|
||
/**
|
||
* LOD/culling для моделей: модели дальше LOD_FREEZE замораживают мировую
|
||
* матрицу (экономия CPU), модели дальше LOD_CULL — отключаются от рендера.
|
||
* Запускается в render-loop из tick().
|
||
*/
|
||
_updateModelLOD() {
|
||
if (!this.modelManager || !this.camera) return;
|
||
const cam = this.camera.position;
|
||
const LOD_FREEZE = 60; // юниты — за этим расстоянием freezeWorldMatrix
|
||
const LOD_FREEZE_SQ = LOD_FREEZE * LOD_FREEZE;
|
||
const LOD_CULL = 600; // юниты — за этим расстоянием полностью скрываем
|
||
// (было 200; увеличено чтобы модели на удалённых уровнях не пропадали при editor-камере)
|
||
const LOD_CULL_SQ = LOD_CULL * LOD_CULL;
|
||
for (const data of this.modelManager.instances.values()) {
|
||
const root = data.rootMesh;
|
||
if (!root) continue;
|
||
// Динамические объекты (зомби, спавнеры, runtime-спавнутые) НЕ
|
||
// подвергаем LOD-freeze — за ними двигает свой менеджер.
|
||
const gp = data.gameplay;
|
||
if (gp?.isZombie || gp?.isZombieSpawner || data._spawnedAtRuntime) continue;
|
||
const dx = root.position.x - cam.x;
|
||
const dy = root.position.y - cam.y;
|
||
const dz = root.position.z - cam.z;
|
||
const distSq = dx * dx + dy * dy + dz * dz;
|
||
// Cull
|
||
const shouldCull = distSq > LOD_CULL_SQ && data.visible !== false;
|
||
if (shouldCull && root._kubikonCulled !== true) {
|
||
root.setEnabled(false);
|
||
root._kubikonCulled = true;
|
||
} else if (!shouldCull && root._kubikonCulled === true) {
|
||
root.setEnabled(data.visible !== false);
|
||
root._kubikonCulled = false;
|
||
}
|
||
// Freeze
|
||
const shouldFreeze = distSq > LOD_FREEZE_SQ;
|
||
if (shouldFreeze && root._kubikonFrozen !== true) {
|
||
try { root.freezeWorldMatrix(); } catch (e) { /* ignore */ }
|
||
root._kubikonFrozen = true;
|
||
} else if (!shouldFreeze && root._kubikonFrozen === true) {
|
||
try { root.unfreezeWorldMatrix(); } catch (e) { /* ignore */ }
|
||
root._kubikonFrozen = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* LOD-cull для примитивов: далёкие декорации скрываем, ближние видны.
|
||
* Только в Play-режиме (в редакторе пользователь должен видеть всю сцену
|
||
* чтобы редактировать). На больших проектах (Only Up: 568 примитивов на
|
||
* вертикальной башне) это критично — без LOD при повороте камеры Babylon
|
||
* frustum-cull'ит сотни мешей и FPS падает в пол.
|
||
*/
|
||
_updatePrimitiveLOD() {
|
||
if (!this._isPlaying) return;
|
||
if (!this.primitiveManager || !this.camera) return;
|
||
const cam = this.camera.position;
|
||
const CULL = 120;
|
||
const CULL_SQ = CULL * CULL;
|
||
for (const data of this.primitiveManager.instances.values()) {
|
||
const m = data.mesh;
|
||
if (!m) continue;
|
||
// Не трогаем явно скрытые/невидимые скриптом
|
||
if (data.visible === false) continue;
|
||
const dx = data.x - cam.x;
|
||
const dy = data.y - cam.y;
|
||
const dz = data.z - cam.z;
|
||
const distSq = dx * dx + dy * dy + dz * dz;
|
||
const shouldCull = distSq > CULL_SQ;
|
||
if (shouldCull && m._kubikonPrimCulled !== true) {
|
||
m.setEnabled(false);
|
||
m._kubikonPrimCulled = true;
|
||
} else if (!shouldCull && m._kubikonPrimCulled === true) {
|
||
m.setEnabled(true);
|
||
m._kubikonPrimCulled = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Roblox-style input handlers.
|
||
* Мышиные события — на canvas (только когда мышь над сценой).
|
||
* Клавиатурные — на window (работают при любом фокусе, как в реальных
|
||
* 3D-редакторах). Используем e.code (KeyW, KeyA, KeyS, KeyD, KeyQ, KeyE, KeyF)
|
||
* чтобы клавиши работали на любой раскладке (русская/английская).
|
||
*/
|
||
_setupInputControls() {
|
||
const canvas = this.canvas;
|
||
|
||
// === МЫШЬ ===
|
||
// mousedown на canvas в capture-фазе → срабатывает первым,
|
||
// даже если поверх есть другие listeners.
|
||
const onMouseDown = (e) => {
|
||
if (this._isPlaying) {
|
||
// В Play-режиме ЛКМ — клик игрока в forward-направлении.
|
||
// Pointer Lock — курсор всё равно в центре экрана.
|
||
if (e.button === 0) this._handlePlayClick();
|
||
return;
|
||
}
|
||
// Обновляем pointer координаты для raycast и Gizmo
|
||
const r = canvas.getBoundingClientRect();
|
||
this.scene.pointerX = e.clientX - r.left;
|
||
this.scene.pointerY = e.clientY - r.top;
|
||
|
||
// Если это ЛКМ — пробуем pickнуть гизмо. Если попали в гизмо —
|
||
// отдаём событие Babylon GizmoManager и выходим (не ставим блок).
|
||
// Проверка attachedMesh || attachedNode — у разных версий Babylon
|
||
// и при attachToMesh vs attachToNode заполняется разное поле.
|
||
const hasAttachment = this._gizmo &&
|
||
(this._gizmo.manager.attachedMesh || this._gizmo.manager.attachedNode);
|
||
if (e.button === 0 && hasAttachment) {
|
||
const ulScene = this._gizmoLayer?.utilityLayerScene;
|
||
if (ulScene) {
|
||
const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY);
|
||
if (ulPick && ulPick.hit) {
|
||
// Симулируем pointer-events для GizmoManager на utility-scene
|
||
ulScene.simulatePointerDown(ulPick);
|
||
this._gizmoDragging = true;
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Запоминаем стартовую точку любого нажатия — для drag-detection.
|
||
this._mouseDownButton = e.button;
|
||
this._mouseDownX = e.clientX;
|
||
this._mouseDownY = e.clientY;
|
||
this._mouseDownTime = Date.now();
|
||
|
||
// ЛКМ + tool=block/erase → активируем drag-постановку.
|
||
// Сразу же ставим первый блок в клетке под курсором.
|
||
if (e.button === 0 && !e.shiftKey
|
||
&& (this._activeTool === 'block' || this._activeTool === 'erase')) {
|
||
this._isDragPlacing = true;
|
||
this._lastPlacedKey = null;
|
||
this._dragLockAxis = null;
|
||
this._dragPlaceTick(false, /*isFirst*/ true);
|
||
e.preventDefault();
|
||
}
|
||
// ЛКМ + tool=terrain → активируем drag-кисть террейна.
|
||
// Shift модификатор обрабатывается внутри _terrainBrushTick (стирание).
|
||
else if (e.button === 0 && this._activeTool === 'terrain') {
|
||
this._isTerrainBrushing = true;
|
||
this._terrainDragLockY = null;
|
||
this._terrainHistoryOpen(); // снапшот для undo
|
||
this._setTerrainBrushPreviewActive(true);
|
||
this._terrainBrushTick(e.shiftKey, /*isFirst*/ true);
|
||
e.preventDefault();
|
||
}
|
||
// Shift+ЛКМ — drag-удаление (даже если tool=block)
|
||
else if (e.button === 0 && e.shiftKey) {
|
||
this._isDragPlacing = true;
|
||
this._lastPlacedKey = null;
|
||
this._dragLockAxis = null;
|
||
this._dragPlaceTick(true, /*isFirst*/ true);
|
||
e.preventDefault();
|
||
}
|
||
|
||
if (e.button === 2) {
|
||
this._isRotating = true;
|
||
this._lastMouseX = e.clientX;
|
||
this._lastMouseY = e.clientY;
|
||
canvas.style.cursor = 'grabbing';
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
} else if (e.button === 1) {
|
||
this._isPanning = true;
|
||
this._lastMouseX = e.clientX;
|
||
this._lastMouseY = e.clientY;
|
||
canvas.style.cursor = 'move';
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
// ЛКМ (button === 0) ничего не запускает сразу — обрабатывается на mouseup
|
||
// только если был "клик" (не drag).
|
||
};
|
||
|
||
// mouseup и mousemove — на window, чтобы drag работал даже когда
|
||
// курсор вышел за пределы canvas (стандартное поведение для drag).
|
||
const onMouseMove = (e) => {
|
||
// Babylon без detachControl сам не пишет в scene.pointerX/Y —
|
||
// делаем это руками. Нужны для raycast (scene.pick) и для гизмо.
|
||
const r = canvas.getBoundingClientRect();
|
||
this.scene.pointerX = e.clientX - r.left;
|
||
this.scene.pointerY = e.clientY - r.top;
|
||
|
||
// Если идёт drag гизмо — проксируем move в utility-scene
|
||
if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) {
|
||
const ulScene = this._gizmoLayer.utilityLayerScene;
|
||
const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY);
|
||
ulScene.simulatePointerMove(ulPick);
|
||
return;
|
||
}
|
||
|
||
// Если идёт drag-постановка блоков — пытаемся поставить в новой клетке
|
||
if (this._isDragPlacing) {
|
||
this._dragPlaceTick(e.shiftKey);
|
||
return;
|
||
}
|
||
|
||
// Если идёт drag-кисть террейна — продолжаем рисовать.
|
||
// Двигаем preview-меш под курсор ВНУТРИ drag тоже, иначе сфера
|
||
// зависает в точке первого клика, пока юзер водит мышью.
|
||
if (this._isTerrainBrushing) {
|
||
this._terrainBrushTick(e.shiftKey, /*isFirst*/ false);
|
||
this._updateTerrainBrushPosition();
|
||
return;
|
||
}
|
||
|
||
// Когда tool=terrain без drag'а — подвигаем preview-меш под курсор
|
||
if (this._activeTool === 'terrain') {
|
||
this._updateTerrainBrushPosition();
|
||
}
|
||
|
||
if (!this._isRotating && !this._isPanning) return;
|
||
const dx = e.clientX - this._lastMouseX;
|
||
const dy = e.clientY - this._lastMouseY;
|
||
this._lastMouseX = e.clientX;
|
||
this._lastMouseY = e.clientY;
|
||
|
||
if (this._isRotating) {
|
||
this.camera.rotation.y += dx * this.ROTATE_SENSITIVITY;
|
||
this.camera.rotation.x += dy * this.ROTATE_SENSITIVITY;
|
||
const limit = Math.PI / 2 - 0.05;
|
||
if (this.camera.rotation.x > limit) this.camera.rotation.x = limit;
|
||
if (this.camera.rotation.x < -limit) this.camera.rotation.x = -limit;
|
||
} else if (this._isPanning) {
|
||
const right = this._getCameraRight();
|
||
const up = this._getCameraUp();
|
||
this.camera.position.addInPlace(right.scale(-dx * this.PAN_SENSITIVITY));
|
||
this.camera.position.addInPlace(up.scale(dy * this.PAN_SENSITIVITY));
|
||
}
|
||
};
|
||
|
||
const onMouseUp = (e) => {
|
||
// Если идёт drag гизмо — отдаём pointerup и завершаем
|
||
if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) {
|
||
const ulScene = this._gizmoLayer.utilityLayerScene;
|
||
const r = canvas.getBoundingClientRect();
|
||
this.scene.pointerX = e.clientX - r.left;
|
||
this.scene.pointerY = e.clientY - r.top;
|
||
const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY);
|
||
ulScene.simulatePointerUp(ulPick);
|
||
this._gizmoDragging = false;
|
||
this._mouseDownButton = -1;
|
||
return;
|
||
}
|
||
// Если был drag-кисть террейна — сбрасываем флаг
|
||
if (this._isTerrainBrushing) {
|
||
this._isTerrainBrushing = false;
|
||
this._terrainDragLockY = null;
|
||
this._smoothBrushLockY = null;
|
||
this._smoothBrushLastPos = null;
|
||
this._terrainHistoryClose(); // фиксируем undo-снапшот
|
||
this._setTerrainBrushPreviewActive(false);
|
||
this._mouseDownButton = -1;
|
||
return;
|
||
}
|
||
// Если был drag-place — просто сбрасываем флаг, клик не обрабатываем
|
||
// (первая постановка уже сделана при mousedown).
|
||
if (this._isDragPlacing) {
|
||
this._isDragPlacing = false;
|
||
this._lastPlacedKey = null;
|
||
this._dragLockAxis = null;
|
||
this._mouseDownButton = -1;
|
||
return;
|
||
}
|
||
// Если это была ЛКМ и НЕ drag (курсор не сдвинулся существенно)
|
||
// → это клик; обрабатываем как редактор-клик (поставить/удалить блок).
|
||
if (e.button === 0 && this._mouseDownButton === 0) {
|
||
const dx = Math.abs(e.clientX - this._mouseDownX);
|
||
const dy = Math.abs(e.clientY - this._mouseDownY);
|
||
const dt = Date.now() - this._mouseDownTime;
|
||
if (dx < 4 && dy < 4 && dt < 400) {
|
||
this._handleEditorClick(e.shiftKey, e.ctrlKey || e.metaKey);
|
||
}
|
||
}
|
||
this._mouseDownButton = -1;
|
||
|
||
if (e.button === 2) {
|
||
this._isRotating = false;
|
||
canvas.style.cursor = 'default';
|
||
} else if (e.button === 1) {
|
||
this._isPanning = false;
|
||
canvas.style.cursor = 'default';
|
||
}
|
||
};
|
||
|
||
const onWheel = (e) => {
|
||
e.preventDefault();
|
||
const forward = this._getCameraForward();
|
||
const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED;
|
||
this.camera.position.addInPlace(forward.scale(delta));
|
||
};
|
||
|
||
const onContextMenu = (e) => {
|
||
e.preventDefault();
|
||
};
|
||
|
||
// === КЛАВИАТУРА ===
|
||
// Используем e.code (KeyW, KeyA, ...) — независимо от раскладки.
|
||
// ВАЖНО: e.key на русской раскладке возвращает кириллицу ('ц', 'ы', ...),
|
||
// поэтому надёжно использовать только e.code.
|
||
|
||
/**
|
||
* Игнорировать события клавиатуры если фокус в input/textarea/contenteditable.
|
||
* Иначе пробел/буквы из ввода в модалке двигают камеру и блокируют ввод.
|
||
* Также игнорируем когда открыта модалка (z-index overlay).
|
||
*/
|
||
const 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;
|
||
// Monaco-редактор — у его внутренних элементов tagName бывает 'div',
|
||
// фокус живёт на скрытой textarea, но в зависимости от роутинга
|
||
// событий e.target может оказаться родительским div. Проверяем
|
||
// принадлежность дереву Monaco — там точно идёт набор текста.
|
||
if (typeof target.closest === 'function' && target.closest('.monaco-editor')) 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 });
|
||
}
|
||
if (e.code === 'KeyF') {
|
||
this._focusOnTarget(new Vector3(0, 0, 0));
|
||
}
|
||
// Ctrl+D — дублировать выделенное
|
||
if (e.code === 'KeyD' && (e.ctrlKey || e.metaKey) && !this._isPlaying) {
|
||
e.preventDefault();
|
||
this.duplicateSelected();
|
||
return;
|
||
}
|
||
// Ctrl+C — копировать выделенное в буфер (Фаза 5.10).
|
||
if (e.code === 'KeyC' && (e.ctrlKey || e.metaKey) && !this._isPlaying) {
|
||
e.preventDefault();
|
||
this.copySelected();
|
||
return;
|
||
}
|
||
// Ctrl+V — вставить из буфера (работает и между проектами).
|
||
if (e.code === 'KeyV' && (e.ctrlKey || e.metaKey) && !this._isPlaying) {
|
||
e.preventDefault();
|
||
this.pasteFromClipboard();
|
||
return;
|
||
}
|
||
// Ctrl+Z — undo, Ctrl+Shift+Z или Ctrl+Y — redo
|
||
if ((e.ctrlKey || e.metaKey) && !this._isPlaying) {
|
||
if (e.code === 'KeyZ' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
this.undo?.();
|
||
return;
|
||
}
|
||
if ((e.code === 'KeyZ' && e.shiftKey) || e.code === 'KeyY') {
|
||
e.preventDefault();
|
||
this.redo?.();
|
||
return;
|
||
}
|
||
// Ctrl+G — сгруппировать выделенное в новую папку
|
||
if (e.code === 'KeyG') {
|
||
e.preventDefault();
|
||
this.groupSelected();
|
||
return;
|
||
}
|
||
// Ctrl+A — выделить всё
|
||
if (e.code === 'KeyA') {
|
||
e.preventDefault();
|
||
this.selection?.selectAll();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// R — повернуть ghost-модель на 90° (или выделенную модель)
|
||
if (e.code === 'KeyR' && !this._isPlaying) {
|
||
const sel = this.selection?.getSelection();
|
||
if (sel?.type === 'model') {
|
||
const newAngle = (sel.rotationY || 0) + Math.PI / 2;
|
||
this.selection.rotateSelectedModel(newAngle);
|
||
} else if (this._activeTool === 'model') {
|
||
this._ghostRotationY = (this._ghostRotationY + Math.PI / 2) % (Math.PI * 2);
|
||
}
|
||
}
|
||
// Delete / Backspace — удалить выделенный
|
||
if ((e.code === 'Delete' || e.code === 'Backspace') && !this._isPlaying) {
|
||
// Приоритет: выбранная инструментом «Выбрать деко» декорация.
|
||
if (this._decoSelection) {
|
||
this._deleteSelectedDeco();
|
||
e.preventDefault();
|
||
} else if (this.selection?.getSelection()) {
|
||
this.selection.deleteSelected();
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
// Escape — снять выделение + переключиться на инструмент «Выделить»
|
||
// (в режиме игры Esc обрабатывает PlayerController — выход из Play).
|
||
if (e.code === 'Escape' && !this._isPlaying) {
|
||
this.selection?.clear();
|
||
if (this._onEditorEscape) {
|
||
try { this._onEditorEscape(); } catch (err) { /* ignore */ }
|
||
}
|
||
}
|
||
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) {
|
||
e.preventDefault();
|
||
}
|
||
};
|
||
|
||
const onKeyUp = (e) => {
|
||
if (isTypingTarget(e.target)) return;
|
||
this._codes.delete(e.code);
|
||
if (!e.shiftKey) this._shiftDown = false;
|
||
if (this._isPlaying && this.gameRuntime) {
|
||
const key = this._normalizeKey(e);
|
||
this.gameRuntime.routeGlobalEvent('keyup', { key, code: e.code });
|
||
}
|
||
};
|
||
|
||
const onBlur = () => {
|
||
this._codes.clear();
|
||
this._shiftDown = false;
|
||
this._isRotating = false;
|
||
this._isPanning = false;
|
||
canvas.style.cursor = 'default';
|
||
};
|
||
|
||
// Регистрация:
|
||
// - mousedown/move/up на CANVAS в capture-фазе. Это самое надёжное место
|
||
// для перехвата мыши над сценой; наш обработчик отрабатывает первым,
|
||
// до Babylon-овских стандартных listeners.
|
||
// - keydown/keyup — на window (клавиатуру всегда слушаем глобально).
|
||
// - wheel/contextmenu — на canvas в capture.
|
||
canvas.addEventListener('mousedown', onMouseDown, true);
|
||
canvas.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||
canvas.addEventListener('contextmenu', onContextMenu, true);
|
||
// mousemove/mouseup на window — для drag за пределами canvas.
|
||
window.addEventListener('mousemove', onMouseMove);
|
||
window.addEventListener('mouseup', onMouseUp);
|
||
window.addEventListener('keydown', onKeyDown);
|
||
window.addEventListener('keyup', onKeyUp);
|
||
window.addEventListener('blur', onBlur);
|
||
|
||
this._listeners = [
|
||
{ target: canvas, type: 'mousedown', fn: onMouseDown, opts: true },
|
||
{ target: canvas, type: 'wheel', fn: onWheel, opts: { capture: true } },
|
||
{ target: canvas, type: 'contextmenu', fn: onContextMenu, opts: true },
|
||
{ target: window, type: 'mousemove', fn: onMouseMove },
|
||
{ target: window, type: 'mouseup', fn: onMouseUp },
|
||
{ target: window, type: 'keydown', fn: onKeyDown },
|
||
{ target: window, type: 'keyup', fn: onKeyUp },
|
||
{ target: window, type: 'blur', fn: onBlur },
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Двигаем камеру по WASDQE — работает всегда, не требует зажатой ПКМ
|
||
* (Minecraft Creative-style — удобнее чем Roblox для редактирования сцены).
|
||
* ПКМ нужна только для поворота камеры.
|
||
* Вызывается каждый кадр из render loop.
|
||
* Используем e.code — независимо от раскладки клавиатуры.
|
||
*/
|
||
_updateCameraMovement() {
|
||
if (this._isPlaying) return; // в режиме игры редактор-камера не движется
|
||
const c = this._codes;
|
||
if (c.size === 0) return;
|
||
|
||
const dt = this.engine.getDeltaTime() / 1000;
|
||
const speed = this.MOVE_SPEED * dt * (this._shiftDown ? this.SHIFT_MULTIPLIER : 1);
|
||
|
||
const forward = this._getCameraForward();
|
||
const right = this._getCameraRight();
|
||
const worldUp = new Vector3(0, 1, 0);
|
||
const move = Vector3.Zero();
|
||
|
||
if (c.has('KeyW') || c.has('ArrowUp')) move.addInPlace(forward.scale(speed));
|
||
if (c.has('KeyS') || c.has('ArrowDown')) move.addInPlace(forward.scale(-speed));
|
||
if (c.has('KeyD') || c.has('ArrowRight')) move.addInPlace(right.scale(speed));
|
||
if (c.has('KeyA') || c.has('ArrowLeft')) move.addInPlace(right.scale(-speed));
|
||
if (c.has('KeyE') || c.has('Space')) move.addInPlace(worldUp.scale(speed));
|
||
if (c.has('KeyQ')) move.addInPlace(worldUp.scale(-speed));
|
||
|
||
if (move.lengthSquared() > 0) {
|
||
this.camera.position.addInPlace(move);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Единичный вектор «вперёд» камеры (с учётом её поворота).
|
||
*/
|
||
_getCameraForward() {
|
||
const yaw = this.camera.rotation.y;
|
||
const pitch = this.camera.rotation.x;
|
||
return new Vector3(
|
||
Math.sin(yaw) * Math.cos(pitch),
|
||
-Math.sin(pitch),
|
||
Math.cos(yaw) * Math.cos(pitch)
|
||
).normalize();
|
||
}
|
||
|
||
/**
|
||
* Единичный вектор «вправо» камеры (перпендикуляр forward в горизонтальной плоскости).
|
||
*/
|
||
_getCameraRight() {
|
||
const yaw = this.camera.rotation.y;
|
||
return new Vector3(Math.cos(yaw), 0, -Math.sin(yaw)).normalize();
|
||
}
|
||
|
||
/**
|
||
* Единичный вектор «вверх» относительно камеры.
|
||
*/
|
||
_getCameraUp() {
|
||
const forward = this._getCameraForward();
|
||
const right = this._getCameraRight();
|
||
return Vector3.Cross(right, forward).normalize();
|
||
}
|
||
|
||
/**
|
||
* Фокус на точке: ставим камеру в 15 единицах от target по текущему направлению.
|
||
* Будет использоваться для F — focus on selected.
|
||
*/
|
||
_focusOnTarget(target) {
|
||
const offset = this._getCameraForward().scale(-15);
|
||
this.camera.position = target.add(offset);
|
||
this.camera.setTarget(target);
|
||
}
|
||
|
||
// === ИНСТРУМЕНТЫ И БЛОКИ ===========================================
|
||
|
||
/**
|
||
* Создать "призрачный" блок — полупрозрачный преview, показывает где
|
||
* появится блок при клике.
|
||
*/
|
||
_createGhostBlock() {
|
||
const ghost = MeshBuilder.CreateBox('ghostBlock', { size: 1.02 }, this.scene);
|
||
const mat = new StandardMaterial('ghostMat', this.scene);
|
||
mat.diffuseColor = new Color3(0.4, 0.9, 0.4);
|
||
mat.alpha = 0.35;
|
||
mat.specularColor = new Color3(0, 0, 0);
|
||
mat.disableLighting = true;
|
||
ghost.material = mat;
|
||
ghost.isPickable = false; // raycast его игнорирует
|
||
ghost.setEnabled(false);
|
||
this._ghostMesh = ghost;
|
||
}
|
||
|
||
/**
|
||
* Создать видимый маркер точки спавна — полупрозрачный жёлтый цилиндр со
|
||
* светящейся вершиной. Виден только в редакторе, скрывается в Play.
|
||
*/
|
||
_createSpawnMarker() {
|
||
// Базовый цилиндр-подставка
|
||
const base = MeshBuilder.CreateCylinder(
|
||
'spawnMarkerBase',
|
||
{ diameterTop: 1.0, diameterBottom: 1.2, height: 0.15, tessellation: 24 },
|
||
this.scene
|
||
);
|
||
const baseMat = new StandardMaterial('spawnBaseMat', this.scene);
|
||
baseMat.diffuseColor = new Color3(0.95, 0.75, 0.2);
|
||
baseMat.emissiveColor = new Color3(0.3, 0.2, 0);
|
||
baseMat.specularColor = new Color3(0, 0, 0);
|
||
baseMat.alpha = 0.85;
|
||
base.material = baseMat;
|
||
|
||
// Внутренний светящийся столб
|
||
const beam = MeshBuilder.CreateCylinder(
|
||
'spawnMarkerBeam',
|
||
{ diameter: 0.4, height: 2.5, tessellation: 16 },
|
||
this.scene
|
||
);
|
||
const beamMat = new StandardMaterial('spawnBeamMat', this.scene);
|
||
beamMat.diffuseColor = new Color3(1, 0.9, 0.3);
|
||
beamMat.emissiveColor = new Color3(1, 0.85, 0.2);
|
||
beamMat.specularColor = new Color3(0, 0, 0);
|
||
beamMat.alpha = 0.4;
|
||
beamMat.disableLighting = true;
|
||
beam.material = beamMat;
|
||
beam.position.y = 1.3;
|
||
|
||
// Группируем base+beam в TransformNode чтобы двигать как одно
|
||
const root = new TransformNode('spawnMarker', this.scene);
|
||
base.parent = root;
|
||
beam.parent = root;
|
||
root.position = new Vector3(this._spawnPoint.x, this._spawnPoint.y, this._spawnPoint.z);
|
||
|
||
// Делаем маркер pickable, чтобы можно было кликнуть и выделить.
|
||
// Метаданные для SelectionManager: { isSpawn: true }.
|
||
base.isPickable = true;
|
||
beam.isPickable = true;
|
||
base.metadata = { isSpawn: true };
|
||
beam.metadata = { isSpawn: true };
|
||
|
||
this._spawnMarker = root;
|
||
this._spawnMarkerMeshes = [base, beam];
|
||
}
|
||
|
||
/** Обновить позицию визуального маркера спавна. */
|
||
_updateSpawnMarker() {
|
||
if (!this._spawnMarker) return;
|
||
this._spawnMarker.position.set(
|
||
this._spawnPoint.x,
|
||
this._spawnPoint.y,
|
||
this._spawnPoint.z
|
||
);
|
||
}
|
||
|
||
/** Скрыть/показать маркер спавна. */
|
||
_setSpawnMarkerVisible(visible) {
|
||
if (!this._spawnMarker) return;
|
||
this._spawnMarker.setEnabled(visible);
|
||
// КРИТИЧНО: при скрытии маркера в Play также делаем его непикаемым.
|
||
// Babylon `pickWithRay` ловит меши даже при `setEnabled(false)` если
|
||
// disabled у parent TransformNode. Без isPickable=false луч стрельбы
|
||
// попадает в столб маркера в 5м перед игроком.
|
||
if (this._spawnMarkerMeshes) {
|
||
for (const m of this._spawnMarkerMeshes) {
|
||
if (m) m.isPickable = visible;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Raycast от курсора в сцену.
|
||
* Возвращает { mesh, point, normal } либо null если ни во что не попали.
|
||
* Игнорирует ghost-блок и линии сетки.
|
||
*/
|
||
/**
|
||
* Нормализация клавиши из KeyboardEvent в простую строку для game.onKey.
|
||
* KeyW → 'w', Space → 'space', ArrowUp → 'arrowup', ShiftLeft → 'shift', ...
|
||
*/
|
||
_normalizeKey(e) {
|
||
const code = e.code || '';
|
||
// Буквы KeyA..KeyZ → 'a'..'z'
|
||
if (/^Key[A-Z]$/.test(code)) return code.charAt(3).toLowerCase();
|
||
// Цифры Digit0..Digit9 → '0'..'9'
|
||
if (/^Digit\d$/.test(code)) return code.charAt(5);
|
||
// Спецклавиши
|
||
const map = {
|
||
Space: 'space',
|
||
Enter: 'enter',
|
||
NumpadEnter: 'enter',
|
||
Escape: 'escape',
|
||
Tab: 'tab',
|
||
Backspace: 'backspace',
|
||
ShiftLeft: 'shift', ShiftRight: 'shift',
|
||
ControlLeft: 'ctrl', ControlRight: 'ctrl',
|
||
AltLeft: 'alt', AltRight: 'alt',
|
||
ArrowUp: 'arrowup', ArrowDown: 'arrowdown',
|
||
ArrowLeft: 'arrowleft', ArrowRight: 'arrowright',
|
||
};
|
||
if (map[code]) return map[code];
|
||
// Fallback — сам key в lower-case
|
||
return String(e.key || code).toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* Pick по центру экрана (для Play-режима где курсор залочен).
|
||
* Используется для game.self.onClick — клик луч-форвард игрока.
|
||
*/
|
||
_pickFromCenter() {
|
||
const w = this.engine?.getRenderWidth?.() || this.canvas.width;
|
||
const h = this.engine?.getRenderHeight?.() || this.canvas.height;
|
||
const pi = this.scene.pick(w / 2, h / 2, (mesh) => {
|
||
if (!mesh.isPickable) return false;
|
||
if (mesh === this._ghostMesh) return false;
|
||
if (mesh.name && mesh.name.startsWith('gridLine')) return false;
|
||
return true;
|
||
});
|
||
if (!pi || !pi.hit) return null;
|
||
let mesh = pi.pickedMesh;
|
||
if (mesh?.metadata?._isBlockProto && this.blockManager) {
|
||
const proxy = this.blockManager.findProxyByPickInfo(pi);
|
||
if (proxy) mesh = proxy;
|
||
}
|
||
return { mesh, point: pi.pickedPoint, pickInfo: pi };
|
||
}
|
||
|
||
/**
|
||
* Извлечь target {kind, ref} из mesh (proxy/прим/модель).
|
||
* Используется при клике/touch в Play.
|
||
*/
|
||
_meshToTarget(mesh) {
|
||
if (!mesh || !mesh.metadata) return null;
|
||
const md = mesh.metadata;
|
||
if (md.isBlock) {
|
||
return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } };
|
||
}
|
||
if (md.isModel) return { kind: 'model', id: md.instanceId };
|
||
if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId };
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Детекция касания игроком объектов с target-скриптами.
|
||
* Для каждого target-скрипта проверяем AABB-overlap с игроком.
|
||
* Событие 'touch' эмитится один раз на «вход» (на rising edge) — пока
|
||
* игрок не выйдет из объекта и не вернётся, повторно touch не вызывается.
|
||
*/
|
||
_detectTouchEvents() {
|
||
const rt = this.gameRuntime;
|
||
if (!rt || !this.player?._pos) return;
|
||
const scripts = this._scripts || [];
|
||
if (scripts.length === 0) return;
|
||
// Кэш «контакта»: scriptId → true если сейчас касается
|
||
if (!this._touchState) this._touchState = new Map();
|
||
const seen = new Set();
|
||
const px = this.player._pos.x;
|
||
const py = this.player._pos.y; // центр капсулы
|
||
const pz = this.player._pos.z;
|
||
const phw = this.player.HALF_W ?? 0.3;
|
||
const phh = this.player.HALF_H ?? 0.9;
|
||
const phd = this.player.HALF_D ?? 0.3;
|
||
|
||
// EPS — допуск касания. Когда игрок СТОИТ на объекте сверху,
|
||
// низ его капсулы строго совпадает с верхом объекта (зазор 0),
|
||
// и строгое сравнение AABB даёт «не пересекаются». Расширяем
|
||
// зону на EPS, чтобы «стоит на объекте/вплотную» = касание.
|
||
// Без этого onTouch финиша/плитки не срабатывает (игрок встал).
|
||
const EPS = 0.25;
|
||
|
||
// 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId)
|
||
for (const s of scripts) {
|
||
if (!s.target) continue;
|
||
const key = 's:' + s.id;
|
||
seen.add(key);
|
||
const aabb = this._targetAABB(s.target);
|
||
if (!aabb) continue;
|
||
const overlap =
|
||
px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS &&
|
||
py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS &&
|
||
pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS;
|
||
const wasTouching = this._touchState.get(key);
|
||
if (overlap && !wasTouching) {
|
||
this._touchState.set(key, true);
|
||
rt.routeEvent(s.target, 'touch', {});
|
||
rt.routeGlobalEvent('playerTouch', { target: s.target });
|
||
} else if (!overlap && wasTouching) {
|
||
this._touchState.set(key, false);
|
||
rt.routeEvent(s.target, 'untouch', {});
|
||
}
|
||
}
|
||
|
||
// 2) Касания примитивов-триггеров (type === 'trigger') БЕЗ скрипта —
|
||
// шлём глобальное playerTouch с target. Это позволяет писать
|
||
// логику чек-поинтов в одном глобальном скрипте без скриптов на каждом
|
||
// триггере. Ключ: 'p:'+id, чтобы не пересекаться со скриптами.
|
||
// Сюда же — примитивы, заспавненные скриптом (data._scriptSpawned):
|
||
// для них тоже шлём playerTouch, чтобы игры «поймай объект»
|
||
// могли ловить падающие кубы через game.onPlayerTouch.
|
||
const prims = this.primitiveManager?.instances;
|
||
if (prims && prims.size > 0) {
|
||
for (const data of prims.values()) {
|
||
const isTrigger = data?.type === 'trigger';
|
||
const isSpawned = data?._scriptSpawned === true;
|
||
if (!isTrigger && !isSpawned) continue;
|
||
const id = data.id;
|
||
// Если на этот примитив УЖЕ повешен target-скрипт — он
|
||
// обработан в блоке выше, чтобы не дублировать события.
|
||
const hasScript = scripts.some(s =>
|
||
s.target?.kind === 'primitive' && (s.target.id ?? s.target.ref) === id
|
||
);
|
||
if (hasScript) continue;
|
||
const key = 'p:' + id;
|
||
seen.add(key);
|
||
const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2;
|
||
const overlap =
|
||
px + phw > data.x - hx - EPS && px - phw < data.x + hx + EPS &&
|
||
py + phh > data.y - hy - EPS && py - phh < data.y + hy + EPS &&
|
||
pz + phd > data.z - hz - EPS && pz - phd < data.z + hz + EPS;
|
||
const wasTouching = this._touchState.get(key);
|
||
if (overlap && !wasTouching) {
|
||
this._touchState.set(key, true);
|
||
// target — строка-ref 'primitive:<id>': её можно
|
||
// передать в game.scene.delete и сравнивать.
|
||
rt.routeGlobalEvent('playerTouch', {
|
||
target: 'primitive:' + id,
|
||
});
|
||
} else if (!overlap && wasTouching) {
|
||
this._touchState.set(key, false);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Чистим устаревшие записи (удалённые скрипты/триггеры)
|
||
for (const id of this._touchState.keys()) {
|
||
if (!seen.has(id)) this._touchState.delete(id);
|
||
}
|
||
}
|
||
|
||
/** Получить мировой AABB target-объекта (для touch-детекции). */
|
||
_targetAABB(target) {
|
||
if (!target) return null;
|
||
try {
|
||
if (target.kind === 'block') {
|
||
const r = target.ref || target;
|
||
return {
|
||
minX: r.x - 0.5, maxX: r.x + 0.5,
|
||
minY: r.y, maxY: r.y + 1,
|
||
minZ: r.z - 0.5, maxZ: r.z + 0.5,
|
||
};
|
||
}
|
||
if (target.kind === 'model') {
|
||
const id = target.id ?? target.ref;
|
||
return this.modelManager?.getInstanceAABB?.(id) || null;
|
||
}
|
||
if (target.kind === 'primitive') {
|
||
const id = target.id ?? target.ref;
|
||
const data = this.primitiveManager?.instances?.get(id);
|
||
if (!data) return null;
|
||
const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2;
|
||
return {
|
||
minX: data.x - hx, maxX: data.x + hx,
|
||
minY: data.y - hy, maxY: data.y + hy,
|
||
minZ: data.z - hz, maxZ: data.z + hz,
|
||
};
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Обработка клика в Play-режиме.
|
||
* Делает forward-pick и роутит click-событие:
|
||
* - в self-обработчики скриптов (routeEvent с target)
|
||
* - в глобальные обработчики (game.onClick) с event.target
|
||
*/
|
||
_handlePlayClick() {
|
||
if (!this._isPlaying) return;
|
||
|
||
// Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу.
|
||
// Используем forward-вектор игрока (XZ-плоскость) — куда смотрит,
|
||
// туда и выстрел. На сервере дальше идёт raycast по другим игрокам.
|
||
if (this._mpSync && this.player?._pos) {
|
||
try {
|
||
const yaw = this.player._yaw || 0;
|
||
// forward в плоскости XZ: yaw=0 — смотрим в +Z
|
||
const dirX = Math.sin(yaw);
|
||
const dirZ = Math.cos(yaw);
|
||
this._mpSync.sendShoot(
|
||
this.player._pos.x,
|
||
this.player._pos.z,
|
||
dirX, dirZ,
|
||
);
|
||
} catch (e) { /* room closed / mpSync disposed */ }
|
||
}
|
||
|
||
if (!this.gameRuntime) return;
|
||
const pick = this._pickFromCenter();
|
||
const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null;
|
||
const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null;
|
||
// 1) Self-onClick — только если target есть
|
||
if (target) {
|
||
this.gameRuntime.routeEvent(target, 'click', { point });
|
||
}
|
||
// 2) Глобальный onClick — всегда (даже если попали в пустоту)
|
||
this.gameRuntime.routeGlobalEvent('click', { point, target });
|
||
// 3) toolUse — если в активном слоте инвентаря есть инструмент/оружие.
|
||
// Для game.player.onToolUse(fn) из скриптов (Фаза 4.2).
|
||
// Phase 6.4: прокидываем customToolId если это кастомный tool из game.tools.create.
|
||
try {
|
||
const active = this.inventory?.getActive?.();
|
||
if (active) {
|
||
const tool = {
|
||
kind: active.kind,
|
||
modelTypeId: active.modelTypeId,
|
||
name: active.name,
|
||
};
|
||
if (active.params && active.params._customToolId) {
|
||
tool.customToolId = active.params._customToolId;
|
||
}
|
||
this.gameRuntime.routeGlobalEvent('toolUse', { tool, point, target });
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
/**
|
||
* Установить мультиплеер-синхронизатор. Вызывается из KubikonPlayer
|
||
* после joinOrCreate. При null — отключаем мультиплеер.
|
||
*/
|
||
/** Тач-режим — управление через виртуальный джойстик и тач-свайп камеры.
|
||
* Должно вызываться ДО enterPlayMode, иначе PlayerController создастся
|
||
* с дефолтным mouse/keyboard-управлением. */
|
||
setTouchMode(enabled) {
|
||
this._touchMode = !!enabled;
|
||
// Если уже в Play и есть player — пробрасываем
|
||
if (this.player && typeof this.player.setTouchMode === 'function') {
|
||
this.player.setTouchMode(this._touchMode);
|
||
}
|
||
// На тач-устройствах (= мобила/планшет) включаем low-perf-режим
|
||
// автоматически: уменьшаем разрешение рендера, отключаем тени,
|
||
// увеличиваем dt физики. Это даёт ×2-3 прирост FPS.
|
||
if (this._touchMode) this.applyLowPerfMode();
|
||
}
|
||
|
||
/**
|
||
* Включить «лёгкий» режим рендера для слабых устройств / мобилок.
|
||
* Можно вызвать вручную и на десктопе если FPS проседает.
|
||
*
|
||
* Главная идея: не пикселим картинку, а уменьшаем нагрузку безболезненно:
|
||
* 1. DPR-нормализация: рендерим в DPR=1 (а не ×2-3 как Retina по умолчанию).
|
||
* Это даёт х2-х9 буст FPS без видимой потери качества — глаз
|
||
* телефонного экрана не различает разницу между DPR=2 и DPR=1.
|
||
* В отличие от scalingLevel=2 (рендер в половину родного), здесь
|
||
* текстуры остаются чёткими — рендерим в реальное число css-пикселей.
|
||
* 2. Отключаем тени — ShadowGenerator самый дорогой эффект.
|
||
* 3. skipPointerMovePicking — не делаем raycast от мыши на каждый move.
|
||
* 4. maxZ=200 — урезаем дальность рендера.
|
||
*
|
||
* НЕ делаем (после теста — слишком ухудшало картинку и плавность):
|
||
* - hardwareScalingLevel > 1 (давало пикселизацию текстур)
|
||
* - FPS-cap 30 через frame-skip (давало дёрганье движения)
|
||
* - ZombieManager тик через кадр (тоже дёрганье)
|
||
*/
|
||
applyLowPerfMode() {
|
||
if (this._lowPerfApplied) return;
|
||
this._lowPerfApplied = true;
|
||
// НЕ трогаем hardwareScalingLevel — оставляем нативное разрешение
|
||
// экрана (включая DPR). На современных телефонах GPU справляется,
|
||
// а текстуры и текст ника остаются чёткими. Прирост FPS даём за
|
||
// счёт отключённых теней / AA / maxZ=200, а не уменьшения буфера.
|
||
// Тени — выключаем
|
||
try {
|
||
if (this._shadowGenerator) {
|
||
this._shadowGenerator.dispose();
|
||
this._shadowGenerator = null;
|
||
}
|
||
this._shadowQuality = 'off';
|
||
} catch (e) { /* ignore */ }
|
||
// Скип pointer-move picking — каждый кадр не делаем raycast от мыши
|
||
try { this.scene.skipPointerMovePicking = true; } catch (e) {}
|
||
// НЕ включаем blockMaterialDirtyMechanism — он ломает свойства
|
||
// материалов трейсеров/дебриса (создаются после старта, шейдер не
|
||
// пересчитывается, emissiveColor/alpha/disableLighting не применяются).
|
||
try {
|
||
if (this.scene) {
|
||
this.scene.autoClear = true;
|
||
this.scene.autoClearDepthAndStencil = true;
|
||
}
|
||
} catch (e) {}
|
||
// Уменьшаем дальность рендера если камера далеко смотрит
|
||
try {
|
||
if (this.camera && this.camera.maxZ) {
|
||
this.camera.maxZ = Math.min(this.camera.maxZ, 200);
|
||
}
|
||
} catch (e) {}
|
||
// FPS-cap НЕ ставим — лучше нативные 60 FPS если устройство тянет.
|
||
this._lowPerfFrameSkip = false;
|
||
// eslint-disable-next-line no-console
|
||
console.log('[BabylonScene] low-perf mode applied (DPR-normalized, no shadows, no frame-skip)');
|
||
}
|
||
|
||
setMultiplayerSync(sync) {
|
||
this._mpSync = sync;
|
||
// Сразу шлём текущее активное оружие, чтобы remote-клиенты
|
||
// увидели его в руке модели сразу после нашего onAdd.
|
||
if (sync) {
|
||
try {
|
||
const active = this.inventory?.getActive?.();
|
||
const modelId = (active && active.kind === 'weapon')
|
||
? (active.modelTypeId || '')
|
||
: '';
|
||
sync.sendWeapon(modelId);
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
_pickFromMouse() {
|
||
// 1) Стандартный pick — для моделей, примитивов, пола, ghost'ов и т.п.
|
||
// Блоки рисуются через thin-instances; их proto-меш ИГНОРИРУЕМ
|
||
// в этом проходе (он бы вернул thin instance с потерянным индексом
|
||
// в новых версиях Babylon — старая боль с выделением и постановкой).
|
||
const pi = this.scene.pick(
|
||
this.scene.pointerX,
|
||
this.scene.pointerY,
|
||
(mesh) => {
|
||
if (!mesh.isPickable) return false;
|
||
if (mesh === this._ghostMesh) return false;
|
||
if (mesh.name && mesh.name.startsWith('gridLine')) return false;
|
||
if (mesh.metadata?._isBlockProto) return false; // ⬅ важно!
|
||
return true;
|
||
}
|
||
);
|
||
|
||
// 2) Отдельный пик блоков через свой raycast по AABB-сетке.
|
||
// Гораздо надёжнее thin-instance pick'а: даём гарантированный
|
||
// proxy + нормаль грани попадания.
|
||
const blockHit = this._pickBlockManually();
|
||
|
||
// Выбираем ближайший: либо стандартный pick, либо блок.
|
||
if (pi && pi.hit && blockHit) {
|
||
// Сравниваем по дистанции от камеры
|
||
const cam = this.scene.activeCamera?.position;
|
||
if (cam) {
|
||
const d1Sq = (pi.pickedPoint.x - cam.x) ** 2
|
||
+ (pi.pickedPoint.y - cam.y) ** 2
|
||
+ (pi.pickedPoint.z - cam.z) ** 2;
|
||
const d2Sq = (blockHit.point.x - cam.x) ** 2
|
||
+ (blockHit.point.y - cam.y) ** 2
|
||
+ (blockHit.point.z - cam.z) ** 2;
|
||
if (d2Sq < d1Sq) {
|
||
return blockHit;
|
||
}
|
||
}
|
||
return {
|
||
mesh: pi.pickedMesh,
|
||
point: pi.pickedPoint,
|
||
normal: pi.getNormal(true),
|
||
pickInfo: pi,
|
||
};
|
||
}
|
||
if (blockHit) return blockHit;
|
||
if (pi && pi.hit) {
|
||
return {
|
||
mesh: pi.pickedMesh,
|
||
point: pi.pickedPoint,
|
||
normal: pi.getNormal(true),
|
||
pickInfo: pi,
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Свой raycast по блокам. Идёт от камеры в направлении курсора, проходит
|
||
* по сетке и проверяет каждую клетку: есть ли блок в blockManager.blocks?
|
||
* Возвращает { mesh: proxy, point, normal } или null.
|
||
*
|
||
* Используется DDA (digital differential analyzer) — самый быстрый алгоритм
|
||
* для voxel-raycast.
|
||
*/
|
||
_pickBlockManually() {
|
||
if (!this.blockManager || !this.scene.activeCamera) return null;
|
||
// Получаем ray из курсора
|
||
const camera = this.scene.activeCamera;
|
||
const ray = this.scene.createPickingRay(
|
||
this.scene.pointerX, this.scene.pointerY, null, camera
|
||
);
|
||
const origin = ray.origin;
|
||
const dir = ray.direction;
|
||
|
||
// DDA для voxel-сетки.
|
||
// Стартуем с клетки в которой находится origin
|
||
let x = Math.round(origin.x);
|
||
let y = Math.floor(origin.y);
|
||
let z = Math.round(origin.z);
|
||
|
||
// Шаги по каждой оси
|
||
const stepX = dir.x > 0 ? 1 : -1;
|
||
const stepY = dir.y > 0 ? 1 : -1;
|
||
const stepZ = dir.z > 0 ? 1 : -1;
|
||
|
||
// Длина шага вдоль луча для перехода на следующую клетку
|
||
const tDeltaX = Math.abs(dir.x) > 1e-8 ? Math.abs(1 / dir.x) : Infinity;
|
||
const tDeltaY = Math.abs(dir.y) > 1e-8 ? Math.abs(1 / dir.y) : Infinity;
|
||
const tDeltaZ = Math.abs(dir.z) > 1e-8 ? Math.abs(1 / dir.z) : Infinity;
|
||
|
||
// Расстояние до первой границы клетки
|
||
// Блок (x,y,z) занимает X: x-0.5..x+0.5, Y: y..y+1, Z: z-0.5..z+0.5
|
||
const nextBoundaryX = x + 0.5 * stepX;
|
||
const nextBoundaryY = stepY > 0 ? (y + 1) : y;
|
||
const nextBoundaryZ = z + 0.5 * stepZ;
|
||
|
||
let tMaxX = Math.abs(dir.x) > 1e-8 ? (nextBoundaryX - origin.x) / dir.x : Infinity;
|
||
let tMaxY = Math.abs(dir.y) > 1e-8 ? (nextBoundaryY - origin.y) / dir.y : Infinity;
|
||
let tMaxZ = Math.abs(dir.z) > 1e-8 ? (nextBoundaryZ - origin.z) / dir.z : Infinity;
|
||
|
||
const MAX_STEPS = 200; // максимум 200 клеток по лучу
|
||
const MAX_DIST = 100; // и не дальше 100м
|
||
|
||
// Какая ось пересечена последней (для вычисления нормали)
|
||
let lastAxis = -1;
|
||
|
||
for (let i = 0; i < MAX_STEPS; i++) {
|
||
// Проверяем клетку (x, y, z)
|
||
if (y >= 0 && y < 200) {
|
||
const key = `${x},${y},${z}`;
|
||
const proxy = this.blockManager.blocks.get(key);
|
||
if (proxy && proxy.metadata?.canCollide !== false) {
|
||
// Нашли! Вычисляем точку контакта и нормаль.
|
||
let t;
|
||
let nx = 0, ny = 0, nz = 0;
|
||
if (lastAxis === 0) {
|
||
// Зашли через X-грань
|
||
t = tMaxX - tDeltaX;
|
||
nx = -stepX;
|
||
} else if (lastAxis === 1) {
|
||
t = tMaxY - tDeltaY;
|
||
ny = -stepY;
|
||
} else if (lastAxis === 2) {
|
||
t = tMaxZ - tDeltaZ;
|
||
nz = -stepZ;
|
||
} else {
|
||
// Стартуем уже внутри клетки — нормаль вверх по умолчанию
|
||
t = 0;
|
||
ny = 1;
|
||
}
|
||
if (t > MAX_DIST) return null;
|
||
const point = {
|
||
x: origin.x + dir.x * t,
|
||
y: origin.y + dir.y * t,
|
||
z: origin.z + dir.z * t,
|
||
};
|
||
return {
|
||
mesh: proxy,
|
||
point: { x: point.x, y: point.y, z: point.z, clone() { return { x: this.x, y: this.y, z: this.z }; } },
|
||
normal: { x: nx, y: ny, z: nz },
|
||
pickInfo: null,
|
||
};
|
||
}
|
||
}
|
||
// Шаг по ближайшей оси
|
||
if (tMaxX < tMaxY && tMaxX < tMaxZ) {
|
||
if (tMaxX > MAX_DIST) return null;
|
||
x += stepX;
|
||
tMaxX += tDeltaX;
|
||
lastAxis = 0;
|
||
} else if (tMaxY < tMaxZ) {
|
||
if (tMaxY > MAX_DIST) return null;
|
||
y += stepY;
|
||
tMaxY += tDeltaY;
|
||
lastAxis = 1;
|
||
} else {
|
||
if (tMaxZ > MAX_DIST) return null;
|
||
z += stepZ;
|
||
tMaxZ += tDeltaZ;
|
||
lastAxis = 2;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
|
||
/**
|
||
* Обновить позицию ghost-блока под курсором.
|
||
* Вызывается каждый кадр когда tool='block'.
|
||
*/
|
||
_updateGhostPosition() {
|
||
if (!this._ghostMesh) return;
|
||
if (this._isPlaying) {
|
||
this._ghostMesh.setEnabled(false);
|
||
return;
|
||
}
|
||
if (this._activeTool !== 'block' && this._activeTool !== 'model') {
|
||
this._ghostMesh.setEnabled(false);
|
||
return;
|
||
}
|
||
const pick = this._pickFromMouse();
|
||
if (!pick) {
|
||
this._ghostMesh.setEnabled(false);
|
||
return;
|
||
}
|
||
const target = this._computePlacementCell(pick);
|
||
if (!target) {
|
||
this._ghostMesh.setEnabled(false);
|
||
return;
|
||
}
|
||
// Не показываем ghost если в этой клетке уже блок (только для tool=block)
|
||
if (this._activeTool === 'block' &&
|
||
this.blockManager?.hasBlock(target.x, target.y, target.z)) {
|
||
this._ghostMesh.setEnabled(false);
|
||
return;
|
||
}
|
||
this._ghostMesh.position = new Vector3(target.x, target.y + 0.5, target.z);
|
||
// Для модели — отображаем угол поворота через rotation (визуальная подсказка)
|
||
if (this._activeTool === 'model') {
|
||
this._ghostMesh.rotation.y = this._ghostRotationY;
|
||
} else {
|
||
this._ghostMesh.rotation.y = 0;
|
||
}
|
||
this._ghostMesh.setEnabled(true);
|
||
}
|
||
|
||
/**
|
||
* Высчитать целочисленную клетку (gridX, gridY, gridZ) куда ставить блок.
|
||
* Координаты — это нижний-передний-левый угол клетки (блок занимает
|
||
* (gridX..gridX+1, gridY..gridY+1, gridZ..gridZ+1)).
|
||
*
|
||
* Попали в блок: новая клетка = соседняя по нормали грани.
|
||
* Попали в пол: новая клетка = (round(p.x - 0.5), 0, round(p.z - 0.5)).
|
||
*
|
||
* Почему -0.5: точка p.x на полу — это координата в мире (0..40). Сетка
|
||
* целочисленная: блок «(0,0,0)» занимает (-0.5..0.5, 0..1, -0.5..0.5).
|
||
* Чтобы клик точно в центр клетки попал в (0,0,0), нужно округление
|
||
* без сдвига. Math.round(0.4) = 0, Math.round(0.6) = 1 — правильно.
|
||
*/
|
||
_computePlacementCell(pick) {
|
||
const p = pick.point;
|
||
const n = pick.normal || new Vector3(0, 1, 0);
|
||
const mesh = pick.mesh;
|
||
|
||
if (mesh?.metadata?.isBlock) {
|
||
// Соседняя клетка по нормали грани, в которую попали
|
||
const m = mesh.metadata;
|
||
const nx = Math.round(n.x);
|
||
const ny = Math.round(n.y);
|
||
const nz = Math.round(n.z);
|
||
const cell = {
|
||
x: m.gridX + nx,
|
||
y: m.gridY + ny,
|
||
z: m.gridZ + nz,
|
||
};
|
||
if (cell.y < 0) return null;
|
||
return cell;
|
||
}
|
||
|
||
// Попали в ТЕРРЕЙН (воксельный region-mesh или гладкий roblox-terrain).
|
||
// У этих мешей нет metadata.isBlock, но есть свои метки. Берём
|
||
// РЕАЛЬНУЮ точку пересечения луча (p.y) — это высота поверхности
|
||
// там, куда кликнули. Без этого модель вставала на y=0 (baseplate).
|
||
const md = mesh?.metadata;
|
||
const isTerrain = md && (md._isTerrainProto || md._isRegionMesh || md._isRobloxTerrain);
|
||
if (isTerrain) {
|
||
return {
|
||
x: Math.round(p.x),
|
||
y: p.y, // реальная высота поверхности под курсором
|
||
z: Math.round(p.z),
|
||
};
|
||
}
|
||
|
||
// Попали в пол / прочее. Точка p — мировая. Блок «(ix,iy,iz)» имеет
|
||
// центр на (ix, iy+0.5, iz), его горизонтальные грани (x,z) от (ix-0.5)
|
||
// до (ix+0.5). Поэтому простое Math.round(p.x) даёт верный gridX.
|
||
const x = Math.round(p.x);
|
||
const z = Math.round(p.z);
|
||
// Если попали в верхнюю грань пола → ставим на y=0.
|
||
// Если попали под низ пола (камера ниже сцены) → не ставим.
|
||
if (n.y < 0.5) return null;
|
||
return { x, y: 0, z };
|
||
}
|
||
|
||
/**
|
||
* Обработать клик мыши (вызывается из mouseup если это был клик, не drag).
|
||
* tool: 'block' / 'model' / 'erase' / 'select'.
|
||
*/
|
||
_handleEditorClick(shiftKey, ctrlKey = false) {
|
||
if (this._isPlaying) return;
|
||
if (!this.blockManager) return;
|
||
const pick = this._pickFromMouse();
|
||
if (!pick) {
|
||
if (this._activeTool === 'select' && !ctrlKey) {
|
||
this.selection?.clear();
|
||
}
|
||
return;
|
||
}
|
||
|
||
const tool = shiftKey ? 'erase' : this._activeTool;
|
||
|
||
if (tool === 'select') {
|
||
if (this.selection) {
|
||
// Для надёжности: если pick.mesh почему-то остался прото-мешем
|
||
// (без metadata.isBlock), пробуем разрезолвить через
|
||
// findProxyByPickInfo ещё раз.
|
||
let selectMesh = pick.mesh;
|
||
if (selectMesh?.metadata?._isBlockProto && this.blockManager) {
|
||
const proxy = this.blockManager.findProxyByPickInfo(pick.pickInfo);
|
||
if (proxy) selectMesh = proxy;
|
||
}
|
||
if (ctrlKey) {
|
||
this.selection.toggleMeshSelection(selectMesh);
|
||
} else {
|
||
this.selection.selectByMesh(selectMesh);
|
||
}
|
||
}
|
||
} else if (tool === 'block') {
|
||
const target = this._computePlacementCell(pick);
|
||
if (!target) return;
|
||
// Блоки живут в целочисленной сетке. Если кликнули по террейну,
|
||
// _computePlacementCell вернёт нецелый y (реальная высота
|
||
// поверхности) — округляем, чтобы блок встал ровно в клетку.
|
||
const by = Math.round(target.y);
|
||
const mesh = this.blockManager.addBlock(target.x, by, target.z, this._activeBlockType);
|
||
this._lastPlacedKey = `${target.x},${by},${target.z}`;
|
||
// Авто-выделение поставленного блока. Тени уже работают через proto-меш
|
||
// (зарегистрирован в refreshAllShadows и обновляется автоматически).
|
||
if (mesh) {
|
||
this.selection?.selectBlockAt(target.x, by, target.z);
|
||
if (this._onPostPlace) this._onPostPlace();
|
||
}
|
||
} else if (tool === 'model') {
|
||
if (!this._activeModelType) return;
|
||
const cell = this._computePlacementCell(pick);
|
||
if (!cell) return;
|
||
// Пользовательская voxel-модель (id 'user:<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, не зависит от того
|
||
// загружена ли уже текстура. Превью кисти просто тонируется в основной
|
||
// цвет выбранного материала.
|
||
let matCol = null;
|
||
try {
|
||
const def = TERRAIN_MATERIAL_DEFS?.[material];
|
||
if (def?.color) matCol = Color3.FromHexString(def.color);
|
||
} catch (e) {}
|
||
|
||
// Удаляем старый preview если форма/размер/режим изменились
|
||
if (this._terrainBrushPreview) {
|
||
const md = this._terrainBrushPreview.metadata || {};
|
||
const curMode = this._terrainBrush?.terrainMode || 'voxel';
|
||
if (md.shape !== shape || md.radius !== r || md.terrainMode !== curMode) {
|
||
try { this._terrainBrushPreview.dispose(); } catch (e) {}
|
||
this._terrainBrushPreview = null;
|
||
}
|
||
}
|
||
|
||
if (!this._terrainBrushPreview) {
|
||
// Размер кисти в МИРОВЫХ единицах:
|
||
// voxel-режим: r (voxel) × VOXEL_SIZE × 2 + 1 voxel (центр)
|
||
// smooth-режим: r — это РАДИУС В МЕТРАХ, диаметр = r*2
|
||
const isSmooth = this._terrainBrush?.terrainMode === 'smooth';
|
||
const worldDiameter = isSmooth
|
||
? r * 2
|
||
: (r * 2 + 1) * TERRAIN_VOXEL_SIZE;
|
||
let mesh;
|
||
if (shape === 'cube') {
|
||
mesh = MeshBuilder.CreateBox('__terrainBrushPreview', { size: worldDiameter }, this.scene);
|
||
} else if (shape === 'cylinder') {
|
||
mesh = MeshBuilder.CreateCylinder('__terrainBrushPreview', { height: worldDiameter, diameter: worldDiameter }, this.scene);
|
||
} else {
|
||
mesh = MeshBuilder.CreateSphere('__terrainBrushPreview', { diameter: worldDiameter, segments: 16 }, this.scene);
|
||
}
|
||
const mat = new StandardMaterial('__terrainBrushPreviewMat', this.scene);
|
||
mat.emissiveColor = matCol || new Color3(0.2, 0.7, 0.3);
|
||
mat.diffuseColor = new Color3(0, 0, 0);
|
||
mat.alpha = 0.22;
|
||
mat.disableLighting = false;
|
||
mat.backFaceCulling = false;
|
||
mesh.material = mat;
|
||
mesh.isPickable = false;
|
||
mesh.metadata = {
|
||
_isTerrainBrushPreview: true,
|
||
shape, radius: r,
|
||
terrainMode: this._terrainBrush?.terrainMode || 'voxel',
|
||
_baseAlpha: 0.22,
|
||
_activeAlpha: 0.45,
|
||
};
|
||
mesh.setEnabled(this._activeTool === 'terrain');
|
||
this._terrainBrushPreview = mesh;
|
||
} else {
|
||
// Обновляем цвет
|
||
if (matCol) {
|
||
try { this._terrainBrushPreview.material.emissiveColor = matCol; } catch (e) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Вычислить точку клика для кисти террейна. Возвращает {x, y, z} в
|
||
* voxel-координатах (целые) или null.
|
||
*
|
||
* Логика:
|
||
* 1. Если попали по существующему voxel'у — берём клетку или соседнюю
|
||
* по нормали (в зависимости от инструмента).
|
||
* 2. Иначе делаем raycast на плоскость y=0 (пол сцены) — это даёт
|
||
* площадку «где начнётся ландшафт».
|
||
*/
|
||
_pickTerrainCell(forNewVoxel) {
|
||
const pi = this.scene.pick(
|
||
this.scene.pointerX,
|
||
this.scene.pointerY,
|
||
(mesh) => {
|
||
if (!mesh.isPickable) return false;
|
||
if (mesh === this._ghostMesh) return false;
|
||
if (mesh === this._terrainBrushPreview) return false;
|
||
if (mesh.metadata?._isBlockProto) return false;
|
||
if (mesh.metadata?._isTerrainProto) return false;
|
||
if (mesh.name && mesh.name.startsWith('gridLine')) return false;
|
||
return true;
|
||
}
|
||
);
|
||
|
||
// Сначала — попытка raycast по существующему террейну (DDA)
|
||
const camera = this.scene.activeCamera;
|
||
const ray = this.scene.createPickingRay(
|
||
this.scene.pointerX,
|
||
this.scene.pointerY,
|
||
null,
|
||
camera,
|
||
false,
|
||
);
|
||
const tHit = this.terrainManager?.pickVoxelByRay(ray.origin, ray.direction, 200);
|
||
if (tHit) {
|
||
if (forNewVoxel) {
|
||
return {
|
||
x: tHit.cell.x + tHit.normal.x,
|
||
y: tHit.cell.y + tHit.normal.y,
|
||
z: tHit.cell.z + tHit.normal.z,
|
||
};
|
||
}
|
||
return tHit.cell;
|
||
}
|
||
|
||
// Иначе — попадание по обычным мешам (пол/блок). Делим мировые
|
||
// координаты на TERRAIN_VOXEL_SIZE чтобы получить voxel-индекс.
|
||
if (pi?.hit && pi.pickedPoint) {
|
||
const p = pi.pickedPoint;
|
||
const n = pi.getNormal?.(true) || { x: 0, y: 1, z: 0 };
|
||
// Идём на 0.001 внутрь по противоположной нормали — чтобы координата
|
||
// упала в правильный voxel.
|
||
const inside = {
|
||
x: p.x - n.x * 0.001,
|
||
y: p.y - n.y * 0.001,
|
||
z: p.z - n.z * 0.001,
|
||
};
|
||
const S = TERRAIN_VOXEL_SIZE;
|
||
const baseX = Math.floor(inside.x / S);
|
||
const baseY = Math.floor(inside.y / S);
|
||
const baseZ = Math.floor(inside.z / S);
|
||
if (forNewVoxel) {
|
||
return {
|
||
x: baseX + Math.round(n.x),
|
||
y: baseY + Math.round(n.y),
|
||
z: baseZ + Math.round(n.z),
|
||
};
|
||
}
|
||
return { x: baseX, y: baseY, z: baseZ };
|
||
}
|
||
|
||
// Fallback — raycast на плоскость y=0. Преобразуем мировые
|
||
// координаты в voxel-индексы делением на TERRAIN_VOXEL_SIZE.
|
||
if (Math.abs(ray.direction.y) > 1e-4) {
|
||
const t = -ray.origin.y / ray.direction.y;
|
||
if (t > 0 && t < 200) {
|
||
const hx = ray.origin.x + ray.direction.x * t;
|
||
const hz = ray.origin.z + ray.direction.z * t;
|
||
const S = TERRAIN_VOXEL_SIZE;
|
||
const cx = Math.floor(hx / S);
|
||
const cz = Math.floor(hz / S);
|
||
// Прилипание к верху столбца: если в этом столбце уже
|
||
// есть voxel'ы террейна, используем Y верхнего + 1.
|
||
// Это убирает «прыжок» кисти когда юзер начинает рисовать
|
||
// на пол рядом с уже существующим холмом — она остаётся
|
||
// на уровне его поверхности.
|
||
let topY = -1;
|
||
if (this.terrainManager) {
|
||
const found = this.terrainManager._findTopY?.(cx, cz, 200, -50);
|
||
if (found !== null && found !== undefined) topY = found;
|
||
}
|
||
return {
|
||
x: cx,
|
||
y: forNewVoxel && topY >= 0 ? topY + 1 : (topY >= 0 ? topY : 0),
|
||
z: cz,
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Один «тик» кисти террейна. Вызывается при mousedown и mousemove с зажатой
|
||
* ЛКМ когда activeTool === 'terrain'.
|
||
*
|
||
* shiftKey — модификатор «обратное действие» (стереть/опустить).
|
||
*/
|
||
_terrainBrushTick(shiftKey, isFirst) {
|
||
if (this._isPlaying) return;
|
||
if (this._activeTool !== 'terrain') return;
|
||
|
||
// === Smooth-режим: редактируем DensityGrid через SmoothBrushes ===
|
||
if (this._terrainBrush?.terrainMode === 'smooth') {
|
||
this._smoothBrushTick(shiftKey, isFirst);
|
||
return;
|
||
}
|
||
|
||
if (!this.terrainManager) return;
|
||
const tool = this._terrainBrush?.tool || 'draw';
|
||
|
||
// === Rate-limit voxel-кисти ===
|
||
// mousemove приходит ~100Hz, каждый тик brushDraw/sculpt с radius=16
|
||
// = ~17000 thinInstanceAdd. Даже с GPU-batch это съедает кадр.
|
||
// Ограничиваем тики до ~25 Hz (40ms) — кисть всё равно плавно
|
||
// покрывает поверхность за счёт drag по экрану.
|
||
if (!isFirst) {
|
||
const now = performance.now();
|
||
const last = this._voxelBrushLastTick || 0;
|
||
const radius = this._terrainBrush?.brushSize || 4;
|
||
// Чем больше кисть, тем реже тики (защита от лагов).
|
||
const minInterval = radius <= 4 ? 30 : radius <= 8 ? 50 : radius <= 16 ? 80 : 120;
|
||
if (now - last < minInterval) return;
|
||
this._voxelBrushLastTick = now;
|
||
} else {
|
||
this._voxelBrushLastTick = performance.now();
|
||
}
|
||
|
||
// === Plant-кисти voxel-режима: размещение мини-воксельных моделей ===
|
||
// plantGrass / plantFlower / plantMushroom / plantTree.
|
||
// Shift = стереть декорации в зоне.
|
||
if (tool === 'plantGrass' || tool === 'plantFlower'
|
||
|| tool === 'plantMushroom' || tool === 'plantTree') {
|
||
// При новом клике сбрасываем rate-limit pos, чтобы первый клик
|
||
// в той же точке всегда срабатывал.
|
||
if (isFirst) {
|
||
this._voxelTreeLastPos = null;
|
||
this._voxelPlantLastPos = null;
|
||
}
|
||
const cell = this._pickTerrainCell(true);
|
||
if (!cell) return;
|
||
const brush = {
|
||
x: cell.x, y: cell.y, z: cell.z,
|
||
radius: this._terrainBrush.brushSize || 4,
|
||
shape: this._terrainBrush.shape || 'sphere',
|
||
strength: this._terrainBrush.strength ?? 50,
|
||
};
|
||
if (shiftKey) {
|
||
this._eraseDecorationsInBrush(brush);
|
||
} else {
|
||
this._placeVoxelPlantsAtBrush(brush, tool);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Для перекраски/выровнять — берём клетку с поверхности (не «над»)
|
||
// Для рисования — клетку «над» (по нормали)
|
||
const wantsAdjacent = (tool === 'draw' || tool === 'sculpt');
|
||
const cell = this._pickTerrainCell(wantsAdjacent);
|
||
if (!cell) return;
|
||
|
||
// На скульпте и выровнять — фиксируем Y первой точки, чтобы при drag
|
||
// не перепрыгивать на разные слои каждым движением мыши.
|
||
if (isFirst) {
|
||
this._terrainDragLockY = cell.y;
|
||
}
|
||
|
||
const brush = {
|
||
x: cell.x,
|
||
y: (tool === 'flatten' || tool === 'sculpt' || tool === 'smooth')
|
||
? this._terrainDragLockY ?? cell.y
|
||
: cell.y,
|
||
z: cell.z,
|
||
radius: this._terrainBrush.brushSize || 4,
|
||
shape: this._terrainBrush.shape || 'sphere',
|
||
};
|
||
const matId = this._terrainBrush.material || 'grass';
|
||
const strength = this._terrainBrush.strength ?? 50;
|
||
|
||
if (shiftKey) {
|
||
// Shift = обратная кисть. Для sculpt — опускает (Sculpt Down).
|
||
// Для всех остальных — стирает (voxels + декорации в зоне).
|
||
if (tool === 'sculpt') {
|
||
this.terrainManager.brushSculpt(brush, -1, matId, strength);
|
||
} else {
|
||
this.terrainManager.brushErase(brush);
|
||
this._eraseDecorationsInBrush(brush);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// === Деко-материалы → мини-воксельные модели ===
|
||
// Если выбран материал-декорация (трава/цветы/грибы/листья) — кисть
|
||
// ставит МОДЕЛЬ из DecoModels, а не плоский voxel. Это для tool=draw,
|
||
// sculpt (рисование) — где пользователь "красит" декорациями.
|
||
// smooth/paint/flatten — стандартный voxel-rendering.
|
||
if (this._isDecoMaterial(matId) && (tool === 'draw' || tool === 'sculpt')) {
|
||
this._placeDecoModelsAtBrush(brush, matId);
|
||
return;
|
||
}
|
||
|
||
// Если выбран деко-материал но инструмент НЕ ставит модели (smooth/paint/flatten),
|
||
// нельзя засыпать столбцы декорациями — fallback на 'grass'.
|
||
const safeMatId = this._isDecoMaterial(matId) ? 'grass' : matId;
|
||
|
||
switch (tool) {
|
||
case 'draw':
|
||
this.terrainManager.brushDraw(brush, matId);
|
||
break;
|
||
case 'sculpt':
|
||
this.terrainManager.brushSculpt(brush, +1, matId, strength);
|
||
break;
|
||
case 'smooth':
|
||
// Smooth работает БЕЗ выбранного материала: засыпка идёт
|
||
// тем материалом, что уже есть у соседних solid voxels.
|
||
this.terrainManager.brushSmooth(brush, null);
|
||
break;
|
||
case 'paint':
|
||
this.terrainManager.brushPaint(brush, safeMatId);
|
||
break;
|
||
case 'flatten':
|
||
this.terrainManager.brushFlatten(brush, safeMatId);
|
||
break;
|
||
case 'erase':
|
||
// Стираем И voxels И декорации в зоне кисти.
|
||
this.terrainManager.brushErase(brush);
|
||
this._eraseDecorationsInBrush(brush);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
/** Переключение «активного» состояния preview-меша кисти террейна.
|
||
* active=true делает кисть ярче (alpha 0.45 vs 0.22) — пока зажата ЛКМ. */
|
||
_setTerrainBrushPreviewActive(active) {
|
||
const m = this._terrainBrushPreview;
|
||
if (!m || !m.material) return;
|
||
try {
|
||
const meta = m.metadata || {};
|
||
m.material.alpha = active ? (meta._activeAlpha || 0.45) : (meta._baseAlpha || 0.22);
|
||
} catch (e) {}
|
||
}
|
||
|
||
// ========================================================================
|
||
// Undo / Redo для террейна
|
||
//
|
||
// Хранится стек снапшотов всего террейна (массив serialize'данных).
|
||
// Один drag-мазок кистью = один снапшот. История ограничена 30 шагами
|
||
// (чтобы не съесть RAM при больших террейнах).
|
||
//
|
||
// Использование:
|
||
// _terrainHistoryOpen() — перед началом мазка (mousedown по террейну)
|
||
// _terrainHistoryClose() — после конца мазка (mouseup): если что-то
|
||
// изменилось, фиксируем «открытый» снапшот
|
||
// undoTerrain() / redoTerrain() — горячие клавиши Ctrl+Z / Ctrl+Y
|
||
// ========================================================================
|
||
|
||
/**
|
||
* Маппинг "деко-материалов" (выбираемых в палитре voxel-режима) на
|
||
* `modelId` из DECO_MODELS. Если material есть в этой карте — кисть
|
||
* ставит МОДЕЛЬ через DecoManager, а не плоский voxel.
|
||
*/
|
||
_decoMaterialToModels(matId) {
|
||
switch (matId) {
|
||
case 'tall_grass':
|
||
// Случайная модель травы из pool — каждый клик ставит разные
|
||
return GRASS_MODELS_POOL;
|
||
case 'flower_red': return ['poppy'];
|
||
case 'flower_blue': return ['cornflower'];
|
||
case 'flower_yellow': return ['dandelion', 'daisy'];
|
||
case 'mushroom_red': return ['fly_mushroom'];
|
||
// Эти материалы можно тоже спрятать под деко если хочется,
|
||
// но пока оставляем как voxels (они нужны для деревьев и т.п.)
|
||
// case 'leaves': case 'leaves_orange': case 'rock_moss': case 'trunk':
|
||
default: return null;
|
||
}
|
||
}
|
||
|
||
/** True если material — декорация (ставится моделью). */
|
||
_isDecoMaterial(matId) {
|
||
return this._decoMaterialToModels(matId) !== null;
|
||
}
|
||
|
||
/**
|
||
* Поставить мини-воксельные модели в зоне кисти (sphere/cube).
|
||
* Плотность — 30% точек grid в радиусе → штук 5-15 на клик.
|
||
*/
|
||
_placeDecoModelsAtBrush(brush, matId) {
|
||
if (!this.decoManager) return;
|
||
const models = this._decoMaterialToModels(matId);
|
||
if (!models || models.length === 0) return;
|
||
const TERRAIN_VOXEL = 0.25;
|
||
const r = brush.radius;
|
||
// brush.x/y/z в voxel-индексах террейна (cells 0.25м).
|
||
// Мировые координаты центра brush в МЕТРАХ.
|
||
const cx = (brush.x + 0.5) * TERRAIN_VOXEL;
|
||
const cz = (brush.z + 0.5) * TERRAIN_VOXEL;
|
||
// Top-surface Y: ищем верх solid voxels под brush.x,brush.z.
|
||
// Если на этой колонне нет voxel — ставим прямо на baseplate y=0.
|
||
let topVoxY = brush.y;
|
||
// count = ~ 8 случайных позиций
|
||
const COUNT = 10;
|
||
const placedKeys = new Set();
|
||
for (let i = 0; i < COUNT; i++) {
|
||
// Случайная точка в круге radius (в voxel-units)
|
||
const angle = Math.random() * Math.PI * 2;
|
||
const rr = Math.sqrt(Math.random()) * r;
|
||
const vx = brush.x + Math.cos(angle) * rr;
|
||
const vz = brush.z + Math.sin(angle) * rr;
|
||
const worldX = (vx + 0.5) * TERRAIN_VOXEL;
|
||
const worldZ = (vz + 0.5) * TERRAIN_VOXEL;
|
||
// Y: top surface — используем brush.y + 1 voxel (над поверхностью)
|
||
const worldY = (topVoxY + 1) * TERRAIN_VOXEL;
|
||
// Защита от дублирования: не ставим 2 модели в одну сетку 0.5м
|
||
const key = `${Math.floor(worldX*2)},${Math.floor(worldZ*2)}`;
|
||
if (placedKeys.has(key)) continue;
|
||
placedKeys.add(key);
|
||
// Случайная модель из набора
|
||
const modelId = models[Math.floor(Math.random() * models.length)];
|
||
const rotation = Math.random() * Math.PI * 2;
|
||
const scale = 0.9 + Math.random() * 0.3;
|
||
this.decoManager.placeModel(worldX, worldY, worldZ, modelId, rotation, scale);
|
||
}
|
||
try { this._onSceneChange?.(); } catch (e) {}
|
||
}
|
||
|
||
/**
|
||
* Поставить декорации plant-кистью voxel-режима по типу инструмента.
|
||
* tool: plantGrass | plantFlower | plantMushroom | plantTree.
|
||
* Trees — из voxel-блоков (trunk + leaves), остальные — мини-модели.
|
||
*/
|
||
_placeVoxelPlantsAtBrush(brush, tool) {
|
||
if (tool === 'plantTree') {
|
||
this._placeVoxelTreesAtBrush(brush);
|
||
return;
|
||
}
|
||
// Подбираем пул моделей по типу инструмента
|
||
let models;
|
||
let countMul; // множитель плотности на тик (как в smooth: grass=густо, flower=средне)
|
||
switch (tool) {
|
||
case 'plantGrass':
|
||
models = GRASS_MODELS_POOL;
|
||
countMul = 1.5;
|
||
break;
|
||
case 'plantFlower':
|
||
models = ['daisy', 'cornflower', 'poppy', 'dandelion'];
|
||
countMul = 1.0;
|
||
break;
|
||
case 'plantMushroom':
|
||
models = ['fly_mushroom', 'brown_mushroom'];
|
||
countMul = 0.5;
|
||
break;
|
||
default:
|
||
return;
|
||
}
|
||
if (!this.decoManager || !models || models.length === 0) return;
|
||
// Rate-limit между тиками: не ставим если кисть не сдвинулась.
|
||
const r = brush.radius;
|
||
const minDist = Math.max(1, r * 0.3);
|
||
const minDist2 = minDist * minDist;
|
||
if (this._voxelPlantLastPos) {
|
||
const dx = brush.x - this._voxelPlantLastPos.x;
|
||
const dz = brush.z - this._voxelPlantLastPos.z;
|
||
if (dx * dx + dz * dz < minDist2) return;
|
||
}
|
||
this._voxelPlantLastPos = { x: brush.x, z: brush.z };
|
||
const TERRAIN_VOXEL = 0.25;
|
||
const topVoxY = brush.y;
|
||
// Кол-во точек пропорционально радиусу и типу декорации.
|
||
const COUNT = Math.max(2, Math.min(16, Math.round(r * countMul)));
|
||
const placedKeys = new Set();
|
||
for (let i = 0; i < COUNT; i++) {
|
||
const angle = Math.random() * Math.PI * 2;
|
||
const rr = Math.sqrt(Math.random()) * r;
|
||
const vx = brush.x + Math.cos(angle) * rr;
|
||
const vz = brush.z + Math.sin(angle) * rr;
|
||
const worldX = (vx + 0.5) * TERRAIN_VOXEL;
|
||
const worldZ = (vz + 0.5) * TERRAIN_VOXEL;
|
||
const worldY = (topVoxY + 1) * TERRAIN_VOXEL;
|
||
const key = `${Math.floor(worldX*2)},${Math.floor(worldZ*2)}`;
|
||
if (placedKeys.has(key)) continue;
|
||
placedKeys.add(key);
|
||
const modelId = models[Math.floor(Math.random() * models.length)];
|
||
const rotation = Math.random() * Math.PI * 2;
|
||
const scale = 0.9 + Math.random() * 0.3;
|
||
this.decoManager.placeModel(worldX, worldY, worldZ, modelId, rotation, scale);
|
||
}
|
||
try { this._onSceneChange?.(); } catch (e) {}
|
||
}
|
||
|
||
/**
|
||
* Поставить ОДНО красивое процедурное дерево из voxel-блоков под кистью.
|
||
*
|
||
* Логика как в smooth-режиме (`_smoothBrushTickPlant`):
|
||
* - 1 дерево за тик (а не пучок)
|
||
* - rate-limit: если кисть не сдвинулась далеко, пропускаем тик
|
||
* - случайный выбор типа: oak / birch / autumn
|
||
*
|
||
* Использует `placeVoxelTree` из VoxelTreeBuilder.js (тот же алгоритм,
|
||
* который генерирует деревья в процедурном мире — толстый ствол,
|
||
* корни, ветви-зигзаги, главная крона + кроны на ветвях).
|
||
*/
|
||
_placeVoxelTreesAtBrush(brush) {
|
||
if (!this.terrainManager) return;
|
||
const tm = this.terrainManager;
|
||
|
||
// === Rate-limit между тиками ===
|
||
// Один тик = одно дерево. Если кисть не сдвинулась более чем на
|
||
// 0.4×radius (в voxel-units), пропускаем. Это убирает спам деревьев
|
||
// друг на друге при удержании ЛКМ.
|
||
const r = brush.radius;
|
||
const minDist = Math.max(2, r * 0.4);
|
||
const minDist2 = minDist * minDist;
|
||
if (this._voxelTreeLastPos) {
|
||
const dx = brush.x - this._voxelTreeLastPos.x;
|
||
const dz = brush.z - this._voxelTreeLastPos.z;
|
||
if (dx * dx + dz * dz < minDist2) return;
|
||
}
|
||
this._voxelTreeLastPos = { x: brush.x, z: brush.z };
|
||
|
||
// === Случайная точка в круге кисти (jitter) ===
|
||
const angle = Math.random() * Math.PI * 2;
|
||
const rr = Math.sqrt(Math.random()) * r * 0.5; // не до края — деревья ближе к центру
|
||
const tx = Math.round(brush.x + Math.cos(angle) * rr);
|
||
const tz = Math.round(brush.z + Math.sin(angle) * rr);
|
||
|
||
// === Top-surface для этой XZ ===
|
||
const topY = tm._findTopY?.(tx, tz, brush.y + r * 4, brush.y - r * 4);
|
||
const baseY = (topY === null || topY === undefined) ? brush.y : topY;
|
||
|
||
// === Размер дерева от strength (1..100) ===
|
||
// strength=10 → саженец (sizeScale=0.5)
|
||
// strength=50 → стандарт (sizeScale=1.0)
|
||
// strength=100 → большое (sizeScale=2.0)
|
||
const strength = brush.strength ?? 50;
|
||
const sizeScale = 0.5 + (strength / 100) * 1.5;
|
||
|
||
// === Случайный тип дерева ===
|
||
const type = TREE_TYPES[Math.floor(Math.random() * TREE_TYPES.length)];
|
||
|
||
// === Уникальный seed на каждое дерево — даёт разную форму ===
|
||
const seed = (Math.random() * 0x7fffffff) | 0;
|
||
|
||
// === Ставим voxels через batched setVoxel-fn ===
|
||
// tm._addInstance не обновляет GPU buffer в batch-режиме, делаем
|
||
// один flushBatch в конце. Это превращает ~300 add'ов в один upload.
|
||
tm._beginBatch?.();
|
||
let placed = 0;
|
||
try {
|
||
const setVoxelFn = (x, y, z, matId) => {
|
||
const k = `${x},${y},${z}`;
|
||
if (tm.voxels.has(k)) return;
|
||
tm._addInstance?.(k, x, y, z, matId);
|
||
tm.voxels.set(k, matId);
|
||
placed++;
|
||
};
|
||
placeVoxelTree(setVoxelFn, tx, baseY, tz, type, sizeScale, seed);
|
||
} finally {
|
||
tm._flushBatch?.();
|
||
}
|
||
if (placed > 0) {
|
||
try { tm._emit?.(); } catch (e) {}
|
||
try { this._onSceneChange?.(); } catch (e) {}
|
||
}
|
||
}
|
||
|
||
/** Удалить все декорации в зоне кисти (по миру). */
|
||
_eraseDecorationsInBrush(brush) {
|
||
if (!this.decoManager || !this.decoManager.placements) return;
|
||
const TERRAIN_VOXEL = 0.25;
|
||
const cx = (brush.x + 0.5) * TERRAIN_VOXEL;
|
||
const cz = (brush.z + 0.5) * TERRAIN_VOXEL;
|
||
const r = brush.radius * TERRAIN_VOXEL * 1.2; // чуть больше для удобства
|
||
const r2 = r * r;
|
||
const keep = [];
|
||
let removed = 0;
|
||
for (const p of this.decoManager.placements) {
|
||
const dx = p.x - cx;
|
||
const dz = p.z - cz;
|
||
if (dx * dx + dz * dz <= r2) {
|
||
removed++;
|
||
} else {
|
||
keep.push(p);
|
||
}
|
||
}
|
||
if (removed > 0) {
|
||
// Перезагружаем decoManager с обновлённым списком
|
||
this.decoManager.clear();
|
||
this.decoManager.loadFromArray(keep);
|
||
try { this._onSceneChange?.(); } catch (e) {}
|
||
}
|
||
}
|
||
|
||
_terrainHistoryEnsure() {
|
||
if (!this._terrainHistory) {
|
||
this._terrainHistory = { stack: [], cursor: -1, pending: null };
|
||
}
|
||
return this._terrainHistory;
|
||
}
|
||
_terrainHistoryOpen() {
|
||
const tm = this.terrainManager;
|
||
if (!tm) return;
|
||
const h = this._terrainHistoryEnsure();
|
||
// Снапшот до изменения
|
||
h.pending = tm.serialize();
|
||
}
|
||
_terrainHistoryClose() {
|
||
const tm = this.terrainManager;
|
||
if (!tm) return;
|
||
const h = this._terrainHistoryEnsure();
|
||
if (!h.pending) return;
|
||
const after = tm.serialize();
|
||
// Сравниваем размером — если ничего не изменилось, не пушим
|
||
if (after.length === h.pending.length && this._terrainSerEqual(after, h.pending)) {
|
||
h.pending = null;
|
||
return;
|
||
}
|
||
// Обрубаем все «впереди-курсора» (redo-стек после нового действия)
|
||
if (h.cursor < h.stack.length - 1) {
|
||
h.stack.length = h.cursor + 1;
|
||
}
|
||
h.stack.push(h.pending);
|
||
h.cursor = h.stack.length - 1;
|
||
// Ограничение 30 шагов
|
||
const MAX = 30;
|
||
while (h.stack.length > MAX) {
|
||
h.stack.shift();
|
||
h.cursor--;
|
||
}
|
||
h.pending = null;
|
||
}
|
||
_terrainSerEqual(a, b) {
|
||
// Сравнение двух serialize-массивов. O(n) с использованием Set.
|
||
if (a.length !== b.length) return false;
|
||
const sa = new Set();
|
||
for (const v of a) sa.add(`${v.x},${v.y},${v.z},${v.m}`);
|
||
for (const v of b) if (!sa.has(`${v.x},${v.y},${v.z},${v.m}`)) return false;
|
||
return true;
|
||
}
|
||
undoTerrain() {
|
||
const tm = this.terrainManager;
|
||
if (!tm) return false;
|
||
const h = this._terrainHistoryEnsure();
|
||
if (h.cursor < 0) return false;
|
||
// Текущий стейт в редо-позицию (cursor+1)
|
||
const current = tm.serialize();
|
||
const target = h.stack[h.cursor];
|
||
// Если на cursor лежит снапшот «до», нам нужно вернуться к нему.
|
||
// Cursor указывает на последнее ВОЗВРАТНОЕ состояние.
|
||
tm.loadFromArray(target);
|
||
// Записываем текущий стейт в позицию cursor+1 для возможного redo
|
||
h.stack[h.cursor + 1] = current;
|
||
h.cursor--;
|
||
return true;
|
||
}
|
||
redoTerrain() {
|
||
const tm = this.terrainManager;
|
||
if (!tm) return false;
|
||
const h = this._terrainHistoryEnsure();
|
||
if (h.cursor + 1 >= h.stack.length - 0) return false;
|
||
const target = h.stack[h.cursor + 2];
|
||
if (!target) return false;
|
||
tm.loadFromArray(target);
|
||
h.cursor++;
|
||
return true;
|
||
}
|
||
|
||
// ========================================================================
|
||
// Регион террейна (инструменты «Выделить», «Заполнить», «Преобразовать»)
|
||
//
|
||
// Регион — это объёмная коробка, заданная двумя углами в voxel-индексах.
|
||
// Визуализируется wireframe-боксом. Создаётся drag-rectangle'ом на земле:
|
||
// первый mousedown в режиме «Выделить» — стартовый угол, drag — второй.
|
||
// Высота коробки фиксируется ±radius по Y вокруг плоскости клика.
|
||
//
|
||
// Регион используется:
|
||
// • «Заполнить» — залить регион выбранным материалом
|
||
// • «Преобразовать» — переместить все voxel'ы региона в новое место
|
||
// (плоский drag по XZ, без поворота на этапе 2).
|
||
// ========================================================================
|
||
|
||
/** Текущее выделение или null. Структура: {x0,y0,z0,x1,y1,z1} (включительно). */
|
||
getTerrainRegion() { return this._terrainRegion || null; }
|
||
|
||
/** Очистить выделение и убрать визуализацию. */
|
||
clearTerrainRegion() {
|
||
this._terrainRegion = null;
|
||
if (this._terrainRegionMesh) {
|
||
try { this._terrainRegionMesh.dispose(); } catch (e) {}
|
||
this._terrainRegionMesh = null;
|
||
}
|
||
}
|
||
|
||
/** Обновить wireframe-визуализацию региона по this._terrainRegion. */
|
||
_updateTerrainRegionVisual() {
|
||
const r = this._terrainRegion;
|
||
if (this._terrainRegionMesh) {
|
||
try { this._terrainRegionMesh.dispose(); } catch (e) {}
|
||
this._terrainRegionMesh = null;
|
||
}
|
||
if (!r) return;
|
||
|
||
const S = TERRAIN_VOXEL_SIZE;
|
||
const minX = Math.min(r.x0, r.x1);
|
||
const maxX = Math.max(r.x0, r.x1);
|
||
const minY = Math.min(r.y0, r.y1);
|
||
const maxY = Math.max(r.y0, r.y1);
|
||
const minZ = Math.min(r.z0, r.z1);
|
||
const maxZ = Math.max(r.z0, r.z1);
|
||
// Размер в мире — кол-во клеток × VOXEL_SIZE. +1 потому что включительно.
|
||
const sizeX = (maxX - minX + 1) * S;
|
||
const sizeY = (maxY - minY + 1) * S;
|
||
const sizeZ = (maxZ - minZ + 1) * S;
|
||
const cx = (minX + 0.5) * S + (sizeX - S) / 2;
|
||
const cy = (minY + 0.5) * S + (sizeY - S) / 2;
|
||
const cz = (minZ + 0.5) * S + (sizeZ - S) / 2;
|
||
|
||
const mesh = MeshBuilder.CreateBox('__terrainRegion', {
|
||
width: sizeX, height: sizeY, depth: sizeZ,
|
||
}, this.scene);
|
||
mesh.position.set(cx, cy, cz);
|
||
mesh.isPickable = false;
|
||
const mat = new StandardMaterial('__terrainRegionMat', this.scene);
|
||
mat.wireframe = true;
|
||
mat.emissiveColor = new Color3(0.20, 0.55, 1.00);
|
||
mat.diffuseColor = new Color3(0, 0, 0);
|
||
mat.alpha = 0.9;
|
||
mesh.material = mat;
|
||
mesh.metadata = { _isTerrainRegion: true };
|
||
this._terrainRegionMesh = mesh;
|
||
}
|
||
|
||
/** Запустить выделение региона: pickStart — voxel-клетка начала. */
|
||
_terrainBeginRegion(pickStart) {
|
||
const radius = this._terrainBrush?.brushSize || 4;
|
||
// Высота региона по умолчанию = ±radius от Y клика. При drag юзер
|
||
// может уточнить — но через первый MVP оставим фиксированной.
|
||
this._terrainRegion = {
|
||
x0: pickStart.x, y0: Math.max(0, pickStart.y - radius), z0: pickStart.z,
|
||
x1: pickStart.x, y1: pickStart.y + radius, z1: pickStart.z,
|
||
};
|
||
this._terrainRegionDragging = true;
|
||
this._updateTerrainRegionVisual();
|
||
}
|
||
|
||
/** Обновить второй угол региона по новой voxel-клетке. */
|
||
_terrainUpdateRegion(pickEnd) {
|
||
if (!this._terrainRegion) return;
|
||
this._terrainRegion.x1 = pickEnd.x;
|
||
this._terrainRegion.z1 = pickEnd.z;
|
||
// Y оставляем как поставили при начале — drag по плоскости XZ
|
||
this._updateTerrainRegionVisual();
|
||
}
|
||
|
||
/** Завершить drag выделения. */
|
||
_terrainEndRegion() {
|
||
this._terrainRegionDragging = false;
|
||
}
|
||
|
||
/**
|
||
* Инициализировать пустой smooth-terrain для скульптинга с нуля.
|
||
* Создаёт DensityGrid 100×24×100 cells (400×96×400м) с density=0 везде.
|
||
* Первый клик sculpt-кистью сразу породит холм в нужном месте.
|
||
*/
|
||
_initEmptySmoothTerrain() {
|
||
if (this._robloxTerrain) {
|
||
try { this._robloxTerrain.disposeAll(); } catch (e) {}
|
||
}
|
||
this._robloxTerrain = new RobloxTerrain(this.scene);
|
||
if (this.physics?.setRobloxTerrain) {
|
||
this.physics.setRobloxTerrain(this._robloxTerrain);
|
||
}
|
||
const sx = 100, sy = 24, sz = 100;
|
||
const grid = new RobloxDensityGrid({
|
||
origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 },
|
||
size: { x: sx, y: sy, z: sz },
|
||
});
|
||
// Регистрируем стандартные материалы в палитре (нужно для brushes).
|
||
// Index 0 = пусто, далее по порядку для совместимости с web/Android.
|
||
for (const matKey of ['grass', 'rock', 'sand', 'snow', 'dirt']) {
|
||
// Hack: set одной ячейки потом обнуляем, чтобы добавить в palette.
|
||
grid.set(0, 0, 0, 0, matKey);
|
||
}
|
||
// Сбрасываем (0,0,0) обратно в пусто — но matData[0] остался matId
|
||
// последнего set'а. Обнуляем явно.
|
||
grid.densityData[0] = 0;
|
||
grid.matData[0] = 0;
|
||
// skipEmpty: true — НЕ добавляем 98 пустых chunks в pending,
|
||
// mesher будет работать только после первой кисти.
|
||
this._robloxTerrain.loadFromGrid(grid, { skipEmpty: true });
|
||
// НЕ отключаем baseplate сразу — нужен чтобы raycast мог пикать
|
||
// плоскость y=0 при первых кликах. Отключим когда появится хоть один
|
||
// solid chunk (см. updateStreaming / applyBrushAndRebuild).
|
||
console.log('[BabylonScene] _initEmptySmoothTerrain: 100×24×100 grid created (skipEmpty=true)');
|
||
}
|
||
|
||
/**
|
||
* Тик smooth-кисти. Делает raycast по mesh-ам smooth-terrain,
|
||
* получает worldPosition и вызывает applyBrush в этой точке.
|
||
*
|
||
* Если smooth-terrain ещё не создан (свежий проект, нажимаем sculpt
|
||
* на пустой сцене) — создаём пустой DensityGrid 100×24×100 cells
|
||
* (400×96×400м) при первом клике sculpt/fill, как в Roblox Studio.
|
||
*/
|
||
_smoothBrushTick(shiftKey, isFirst) {
|
||
const tool = this._terrainBrush?.tool || 'sculpt';
|
||
// === Инструмент «Выбрать деко» — поштучный клик-выбор декораций ===
|
||
// Не модифицирует ландшафт, работает только на клик (не drag).
|
||
if (tool === 'pickDeco') {
|
||
if (isFirst) this._pickDecoTick();
|
||
return;
|
||
}
|
||
const terrainEmpty = !this._robloxTerrain || !this._robloxTerrain.grid;
|
||
|
||
// === Счётчики для диагностики ===
|
||
if (!this._smoothBrushDiag) {
|
||
this._smoothBrushDiag = {
|
||
tickCount: 0, hitTerrain: 0, hitGround: 0, hitNone: 0,
|
||
applyResult: { built: 0, dirty0: 0 },
|
||
};
|
||
}
|
||
const D = this._smoothBrushDiag;
|
||
D.tickCount++;
|
||
const tickN = D.tickCount;
|
||
|
||
// === Plane-lock + rate-limit (как в Roblox Studio) ===
|
||
// При isFirst — фиксируем плоскость и позицию первого клика.
|
||
// Дальше при drag:
|
||
// 1) center.y берётся не из raycast (он растёт вверх вслед за рельефом),
|
||
// а из зафиксированного _smoothBrushLockY → кисть работает в плоскости.
|
||
// 2) Между tick'ами требуется минимальное расстояние (0.6×radius) —
|
||
// иначе одна и та же точка → cells доходят до 255 → "какаха".
|
||
if (isFirst) {
|
||
this._smoothBrushLockY = null;
|
||
this._smoothBrushLastPos = null;
|
||
}
|
||
|
||
// === Raycast — выбираем точку под курсором ===
|
||
let hit = null;
|
||
let pickSource = '';
|
||
if (!terrainEmpty) {
|
||
const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain);
|
||
hit = this.scene.pickWithRay(
|
||
this.scene.createPickingRay(this.scene.pointerX, this.scene.pointerY, null, this.camera),
|
||
pickPred,
|
||
);
|
||
if (hit && hit.hit) { pickSource = 'terrain'; D.hitTerrain++; }
|
||
}
|
||
if (!hit || !hit.hit || !hit.pickedPoint) {
|
||
const groundPick = this.scene.pick(this.scene.pointerX, this.scene.pointerY,
|
||
(m) => m.name === 'editorGround');
|
||
if (groundPick && groundPick.hit && groundPick.pickedPoint) {
|
||
hit = groundPick;
|
||
pickSource = 'ground';
|
||
D.hitGround++;
|
||
} else {
|
||
D.hitNone++;
|
||
if (isFirst || tickN % 20 === 0) {
|
||
console.log(`[SmoothBrush] tick#${tickN} isFirst=${isFirst} → NO HIT (terrainEmpty=${terrainEmpty}, pointer=${this.scene.pointerX},${this.scene.pointerY})`);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
const worldPt = hit.pickedPoint;
|
||
|
||
// === Инициализация пустого grid при первом клике sculpt/fill ===
|
||
let initialized = false;
|
||
if (terrainEmpty) {
|
||
if (!isFirst) {
|
||
if (tickN <= 5 || tickN % 30 === 0) {
|
||
console.log(`[SmoothBrush] tick#${tickN} isFirst=false, terrainEmpty=true → skip (нужен первый клик)`);
|
||
}
|
||
return;
|
||
}
|
||
if (tool !== 'sculpt' && tool !== 'draw' && tool !== 'fill') {
|
||
console.log(`[SmoothBrush] tick#${tickN} terrainEmpty + tool='${tool}' → нельзя инициализировать (используйте sculpt/draw/fill)`);
|
||
return;
|
||
}
|
||
console.log(`[SmoothBrush] tick#${tickN} INIT empty grid (tool='${tool}', hit at ${worldPt.x.toFixed(1)},${worldPt.y.toFixed(1)},${worldPt.z.toFixed(1)})`);
|
||
this._initEmptySmoothTerrain();
|
||
initialized = true;
|
||
if (!this._robloxTerrain || !this._robloxTerrain.grid) {
|
||
console.warn(`[SmoothBrush] tick#${tickN} init FAILED — grid still null`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const material = this._terrainBrush?.material || 'grass';
|
||
const radius = Math.max(3, (this._terrainBrush?.brushSize || 4) * 2.0);
|
||
// strength: slider 0..100 → реальная strength 60..400 для sculpt.
|
||
// Минимум 60 чтобы edge influence (~0.05) давал delta=3 → cells
|
||
// на краю кисти достигали threshold 128 за 40 тиков а не 128.
|
||
// Максимум 400 = мгновенно ставит cells в 255 (полная заливка).
|
||
const strengthSlider = this._terrainBrush?.strength ?? 50;
|
||
const strength = 60 + (strengthSlider / 100) * 340;
|
||
|
||
let brushType;
|
||
if (shiftKey) {
|
||
brushType = 'sculptDown';
|
||
} else {
|
||
switch (tool) {
|
||
case 'draw': brushType = 'sculptUp'; break;
|
||
case 'sculpt': brushType = 'sculptUp'; break;
|
||
case 'smooth': brushType = 'smooth'; break;
|
||
case 'paint': brushType = 'paint'; break;
|
||
case 'flatten':
|
||
if (isFirst) this._smoothFlattenTargetY = worldPt.y;
|
||
brushType = 'flatten';
|
||
break;
|
||
case 'fill': brushType = 'fill'; break;
|
||
case 'erase': brushType = 'erase'; break;
|
||
// === Plant-кисти: добавление декораций ===
|
||
case 'plantGrass': brushType = 'plantGrass'; break;
|
||
case 'plantFlower': brushType = 'plantFlower'; break;
|
||
case 'plantMushroom': brushType = 'plantMushroom'; break;
|
||
case 'plantTree': brushType = 'plantTree'; break;
|
||
default: brushType = 'sculptUp';
|
||
}
|
||
}
|
||
|
||
// === Plant-кисти обрабатываются ОТДЕЛЬНО от sculpt-логики ===
|
||
// Они НЕ модифицируют DensityGrid — добавляют thin-instance модели.
|
||
if (brushType.startsWith('plant')) {
|
||
return this._smoothBrushTickPlant(brushType, worldPt, radius, shiftKey, isFirst);
|
||
}
|
||
|
||
// === Plane-lock: для sculpt-кистей фиксируем Y первого клика ===
|
||
// Без этого при drag в одной XZ-точке raycast возвращает всё более
|
||
// высокий Y (рельеф рос на прошлых тиках) → кисть смещается ВВЕРХ →
|
||
// рельеф летит на камеру ("какаха").
|
||
// С lock'ом drag работает в горизонтальной плоскости фиксированной
|
||
// высоты первого клика, как в Roblox Studio.
|
||
const isSculptKind = brushType === 'sculptUp' || brushType === 'sculptDown';
|
||
let centerY = worldPt.y;
|
||
if (isSculptKind) {
|
||
if (this._smoothBrushLockY === null) {
|
||
this._smoothBrushLockY = worldPt.y;
|
||
}
|
||
centerY = this._smoothBrushLockY;
|
||
// Смещение от плоскости первого клика на radius×0.5
|
||
// (вверх для sculptUp, вниз для sculptDown).
|
||
if (brushType === 'sculptUp') centerY += radius * 0.5;
|
||
else centerY -= radius * 0.5;
|
||
}
|
||
|
||
// === Rate-limit: пропускаем tick если кисть не сдвинулась далеко ===
|
||
// Между тиками должно быть >= 0.6×radius по XZ. Это убивает feedback
|
||
// loop в одной точке.
|
||
if (isSculptKind && !isFirst && this._smoothBrushLastPos) {
|
||
const dx = worldPt.x - this._smoothBrushLastPos.x;
|
||
const dz = worldPt.z - this._smoothBrushLastPos.z;
|
||
const minDist = radius * 0.6;
|
||
if (dx * dx + dz * dz < minDist * minDist) {
|
||
// Кисть в той же точке — пропускаем (но НЕ для isFirst).
|
||
return;
|
||
}
|
||
}
|
||
if (isSculptKind) {
|
||
this._smoothBrushLastPos = { x: worldPt.x, z: worldPt.z };
|
||
}
|
||
|
||
const params = {
|
||
center: { x: worldPt.x, y: centerY, z: worldPt.z },
|
||
radius,
|
||
strength,
|
||
material,
|
||
targetY: this._smoothFlattenTargetY,
|
||
};
|
||
const tApply0 = performance.now();
|
||
const built = this._robloxTerrain.applyBrushAndRebuild(brushType, params);
|
||
const tApply = performance.now() - tApply0;
|
||
if (built > 0) D.applyResult.built += built;
|
||
else D.applyResult.dirty0++;
|
||
|
||
if (isFirst || tickN <= 5 || tickN % 20 === 0 || initialized || built === 0) {
|
||
const gridStats = this._robloxTerrain.grid
|
||
? `solid=${this._robloxTerrain.grid.countSolid?.() ?? '?'}`
|
||
: 'no-grid';
|
||
console.log(
|
||
`[SmoothBrush] tick#${tickN} ${brushType} slider=${strengthSlider} → strength=${strength.toFixed(0)} `
|
||
+ `pick='${pickSource}' @(${worldPt.x.toFixed(1)},${worldPt.y.toFixed(1)},${worldPt.z.toFixed(1)}) `
|
||
+ `r=${radius} mat=${material} ${gridStats} `
|
||
+ `→ built=${built} chunks in ${tApply.toFixed(0)}ms `
|
||
+ (initialized ? ' [INITIALIZED!]' : '')
|
||
+ (built === 0 ? ' [NO CHANGE]' : ''),
|
||
);
|
||
}
|
||
|
||
try { this._onSceneChange?.(); } catch (e) {}
|
||
}
|
||
|
||
/**
|
||
* Plant-кисть: расставляет/удаляет декорации (трава/цветы/грибы/деревья).
|
||
* Не трогает DensityGrid — работает только с SmoothDecoManager.
|
||
* Shift = ластик (удалить декорации в радиусе).
|
||
*/
|
||
_smoothBrushTickPlant(brushType, worldPt, radius, shiftKey, isFirst) {
|
||
// Нужен SmoothDecoManager (создаём lazy при первом plant-клике)
|
||
if (!this._smoothDecoManager) {
|
||
this._smoothDecoManager = new SmoothDecoManager(this.scene);
|
||
this._smoothDecoManager.loadAll();
|
||
}
|
||
// Rate-limit как в sculpt: пропускаем близкие тики
|
||
if (!isFirst && this._smoothBrushLastPos) {
|
||
const dx = worldPt.x - this._smoothBrushLastPos.x;
|
||
const dz = worldPt.z - this._smoothBrushLastPos.z;
|
||
const minDist = radius * 0.4; // плотнее чем sculpt (декорации мелкие)
|
||
if (dx * dx + dz * dz < minDist * minDist) return;
|
||
}
|
||
this._smoothBrushLastPos = { x: worldPt.x, z: worldPt.z };
|
||
|
||
// Shift — ластик
|
||
if (shiftKey) {
|
||
const removed = this._smoothDecoManager.removeBrushDecoInRadius(
|
||
{ x: worldPt.x, z: worldPt.z }, radius,
|
||
);
|
||
if (removed > 0) {
|
||
console.log(`[SmoothBrush] erased ${removed} decorations at (${worldPt.x.toFixed(1)},${worldPt.z.toFixed(1)})`);
|
||
// Пересинхронизировать tree-colliders в physics
|
||
if (this.physics?.setSmoothDecoTrees) {
|
||
this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders());
|
||
}
|
||
try { this._onSceneChange?.(); } catch (e) {}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Маппинг brushType → kind для SmoothDecoManager
|
||
const kindMap = {
|
||
plantGrass: 'grass',
|
||
plantFlower: 'flower',
|
||
plantMushroom: 'mushroom',
|
||
plantTree: 'tree',
|
||
};
|
||
const kind = kindMap[brushType];
|
||
if (!kind) return;
|
||
|
||
// Количество инстансов за один тик зависит от типа.
|
||
// Трава густая, деревья редко.
|
||
const countMap = { grass: 6, flower: 4, mushroom: 2, tree: 1 };
|
||
const count = countMap[kind] || 3;
|
||
|
||
// Surface-Y хелпер: raycast по smooth-terrain ИЛИ ground (y=0).
|
||
const sampleSurfaceY = (x, z) => {
|
||
if (this.physics?._sampleRobloxSurface) {
|
||
const y = this.physics._sampleRobloxSurface(x, z);
|
||
if (y !== null) return y;
|
||
}
|
||
// Fallback на ground y=0
|
||
return 0;
|
||
};
|
||
|
||
const result = this._smoothDecoManager.addBrushDeco({
|
||
kind,
|
||
center: { x: worldPt.x, y: worldPt.y, z: worldPt.z },
|
||
radius,
|
||
count,
|
||
sampleSurfaceY,
|
||
});
|
||
const added = result.added || 0;
|
||
if (added > 0) {
|
||
console.log(`[SmoothBrush] planted ${added} ${kind} at (${worldPt.x.toFixed(1)},${worldPt.z.toFixed(1)})`);
|
||
// Если посадили деревья — пересинхронизировать tree-colliders
|
||
// в physics (полная переустановка через getAllTreeColliders).
|
||
if (kind === 'tree' && this.physics?.setSmoothDecoTrees) {
|
||
this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders());
|
||
}
|
||
try { this._onSceneChange?.(); } catch (e) {}
|
||
}
|
||
}
|
||
|
||
/** Залить выделенный регион материалом. Используется инструментом
|
||
* «Заполнить» когда есть активный _terrainRegion. */
|
||
_terrainFillRegion(matId) {
|
||
const r = this._terrainRegion;
|
||
const tm = this.terrainManager;
|
||
if (!r || !tm) return 0;
|
||
const minX = Math.min(r.x0, r.x1);
|
||
const maxX = Math.max(r.x0, r.x1);
|
||
const minY = Math.min(r.y0, r.y1);
|
||
const maxY = Math.max(r.y0, r.y1);
|
||
const minZ = Math.min(r.z0, r.z1);
|
||
const maxZ = Math.max(r.z0, r.z1);
|
||
let n = 0;
|
||
for (let x = minX; x <= maxX; x++) {
|
||
for (let y = minY; y <= maxY; y++) {
|
||
for (let z = minZ; z <= maxZ; z++) {
|
||
const key = `${x},${y},${z}`;
|
||
if (tm.voxels.has(key)) continue;
|
||
tm.setVoxel(x, y, z, matId);
|
||
n++;
|
||
}
|
||
}
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/** Переместить весь регион на dx/dy/dz в voxel-клетках. */
|
||
_terrainMoveRegion(dx, dy, dz) {
|
||
const r = this._terrainRegion;
|
||
const tm = this.terrainManager;
|
||
if (!r || !tm) return 0;
|
||
if (dx === 0 && dy === 0 && dz === 0) return 0;
|
||
const minX = Math.min(r.x0, r.x1);
|
||
const maxX = Math.max(r.x0, r.x1);
|
||
const minY = Math.min(r.y0, r.y1);
|
||
const maxY = Math.max(r.y0, r.y1);
|
||
const minZ = Math.min(r.z0, r.z1);
|
||
const maxZ = Math.max(r.z0, r.z1);
|
||
// Собираем содержимое региона
|
||
const collected = [];
|
||
for (let x = minX; x <= maxX; x++) {
|
||
for (let y = minY; y <= maxY; y++) {
|
||
for (let z = minZ; z <= maxZ; z++) {
|
||
const m = tm.getVoxel(x, y, z);
|
||
if (!m) continue;
|
||
collected.push({ x, y, z, m });
|
||
}
|
||
}
|
||
}
|
||
// Удаляем из старых позиций
|
||
for (const v of collected) tm.removeVoxel(v.x, v.y, v.z);
|
||
// Ставим в новые
|
||
let n = 0;
|
||
for (const v of collected) {
|
||
tm.setVoxel(v.x + dx, v.y + dy, v.z + dz, v.m);
|
||
n++;
|
||
}
|
||
// Сдвигаем сам регион
|
||
r.x0 += dx; r.x1 += dx;
|
||
r.y0 += dy; r.y1 += dy;
|
||
r.z0 += dz; r.z1 += dz;
|
||
this._updateTerrainRegionVisual();
|
||
return n;
|
||
}
|
||
|
||
/** Двигать preview-меш под курсор. Вызывается из mousemove. */
|
||
_updateTerrainBrushPosition() {
|
||
if (this._activeTool !== 'terrain') return;
|
||
if (!this._terrainBrushPreview) return;
|
||
// === Smooth-режим: raycast по smooth-mesh, preview на surface ===
|
||
if (this._terrainBrush?.terrainMode === 'smooth' && this._robloxTerrain?.grid) {
|
||
const ray = this.scene.createPickingRay(
|
||
this.scene.pointerX, this.scene.pointerY,
|
||
null, this.camera,
|
||
);
|
||
const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain);
|
||
const hit = this.scene.pickWithRay(ray, pickPred);
|
||
if (hit && hit.hit && hit.pickedPoint) {
|
||
this._terrainBrushPreview.position.copyFrom(hit.pickedPoint);
|
||
}
|
||
return;
|
||
}
|
||
// Voxel-режим (как было)
|
||
const cell = this._pickTerrainCell(false);
|
||
if (!cell) return;
|
||
const S = TERRAIN_VOXEL_SIZE;
|
||
this._terrainBrushPreview.position.set(
|
||
(cell.x + 0.5) * S,
|
||
(cell.y + 0.5) * S,
|
||
(cell.z + 0.5) * S,
|
||
);
|
||
}
|
||
|
||
/** Публичный сеттер: выбрать тип блока для постановки. */
|
||
setActiveBlockType(blockTypeId) {
|
||
this._activeBlockType = blockTypeId;
|
||
}
|
||
|
||
/** Публичный сеттер: выбрать тип модели для постановки. */
|
||
setActiveModelType(modelTypeId) {
|
||
this._activeModelType = modelTypeId;
|
||
}
|
||
|
||
/** Тип примитива для постановки (cube/sphere/...). */
|
||
setActivePrimitiveType(typeId) {
|
||
this._activePrimitiveType = typeId;
|
||
}
|
||
|
||
getPrimitiveCount() {
|
||
return this.primitiveManager ? this.primitiveManager.getInstanceCount() : 0;
|
||
}
|
||
|
||
/** Количество блоков (для status bar). */
|
||
getBlockCount() {
|
||
return this.blockManager ? this.blockManager.count() : 0;
|
||
}
|
||
|
||
/** Количество моделей-инстансов. */
|
||
getModelCount() {
|
||
return this.modelManager ? this.modelManager.getInstanceCount() : 0;
|
||
}
|
||
|
||
/** Подписаться на изменение выделения (UI / Inspector / Hierarchy). */
|
||
setOnSelectionChange(cb) {
|
||
if (this.selection) {
|
||
// Объединяем со внутренней подпиской на gizmo
|
||
this.selection.setOnSelectionChange((sel) => {
|
||
this._updateGizmoForSelection(sel);
|
||
if (cb) cb(sel);
|
||
});
|
||
}
|
||
}
|
||
|
||
/** Текущее выделение (или null). */
|
||
getSelection() {
|
||
return this.selection?.getSelection() || null;
|
||
}
|
||
|
||
/** Выделить блок программно (например по клику в Hierarchy). */
|
||
selectBlockAt(x, y, z) {
|
||
this.selection?.selectBlockAt(x, y, z);
|
||
}
|
||
|
||
/** Выделить модель программно. */
|
||
selectModelByInstanceId(id) {
|
||
this.selection?.selectModelByInstanceId(id);
|
||
}
|
||
|
||
/** Снять выделение. */
|
||
clearSelection() {
|
||
this.selection?.clear();
|
||
}
|
||
|
||
/** Удалить выделенный объект. */
|
||
deleteSelected() {
|
||
this.selection?.deleteSelected();
|
||
}
|
||
|
||
/**
|
||
* Дублировать выделенный объект (Ctrl+D).
|
||
* Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y).
|
||
* Модель: создаёт копию со смещением +1 по X.
|
||
*/
|
||
duplicateSelected() {
|
||
const sel = this.selection?.getSelection();
|
||
if (!sel) return;
|
||
if (sel.type === 'block') {
|
||
// Ищем свободную клетку рядом
|
||
const candidates = [
|
||
[1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1], [0, 1, 0],
|
||
];
|
||
for (const [dx, dy, dz] of candidates) {
|
||
const nx = sel.gridX + dx, ny = sel.gridY + dy, nz = sel.gridZ + dz;
|
||
if (ny < 0) continue;
|
||
if (!this.blockManager.hasBlock(nx, ny, nz)) {
|
||
this.blockManager.addBlock(nx, ny, nz, sel.blockTypeId);
|
||
this.selection.selectBlockAt(nx, ny, nz);
|
||
return;
|
||
}
|
||
}
|
||
} else if (sel.type === 'model') {
|
||
// Сохраняем все нужные поля выделения до того как промис завершится
|
||
// (selection может перезатереться к моменту resolve)
|
||
const typeId = sel.modelTypeId;
|
||
const sx = sel.x, sy = sel.y, sz = sel.z;
|
||
const rotY = sel.rotationY || 0;
|
||
this.modelManager.addInstance(typeId, sx + 1, sy, sz, rotY)
|
||
.then(newId => {
|
||
if (newId != null) this.selection?.selectModelByInstanceId(newId);
|
||
})
|
||
.catch(err => {
|
||
// eslint-disable-next-line no-console
|
||
console.error('[BabylonScene] duplicate model error:', err);
|
||
});
|
||
} else if (sel.type === 'userModel') {
|
||
const typeId = sel.userModelTypeId;
|
||
const sx = sel.x, sy = sel.y, sz = sel.z;
|
||
const rotY = sel.rotationY || 0;
|
||
this.userModelManager.addInstance(typeId, sx + 1, sy, sz, rotY, {
|
||
currentUserId: this._currentUserId || null,
|
||
}).then(newId => {
|
||
if (newId != null) this.selection?.selectUserModelByInstanceId(newId);
|
||
}).catch(err => {
|
||
console.error('[BabylonScene] duplicate user model error:', err);
|
||
});
|
||
} else if (sel.type === 'primitive') {
|
||
const newId = this.primitiveManager.addInstance(sel.primitiveType, {
|
||
x: sel.x + 1, y: sel.y, z: sel.z,
|
||
sx: sel.sx, sy: sel.sy, sz: sel.sz,
|
||
color: sel.color, material: sel.material,
|
||
canCollide: sel.canCollide, visible: sel.visible,
|
||
anchored: sel.anchored,
|
||
// Копируем и спец-свойства: текстуру, параметры лампы/эмиттера.
|
||
textureAsset: sel.textureAsset || null,
|
||
brightness: sel.brightness, range: sel.range, effect: sel.effect,
|
||
});
|
||
if (newId != null) this.selection.selectPrimitiveById(newId);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Скопировать выделенный объект в буфер обмена (Ctrl+C, Фаза 5.10).
|
||
* Буфер — localStorage, поэтому переживает перезагрузку страницы
|
||
* и смену проекта (Copy/Paste между проектами).
|
||
*/
|
||
copySelected() {
|
||
const sel = this.selection?.getSelection();
|
||
if (!sel) return;
|
||
let clip = null;
|
||
if (sel.type === 'block') {
|
||
clip = { kind: 'block', blockTypeId: sel.blockTypeId };
|
||
} else if (sel.type === 'model') {
|
||
clip = {
|
||
kind: 'model', modelTypeId: sel.modelTypeId,
|
||
rotationY: sel.rotationY || 0, scale: sel.scale || 1,
|
||
};
|
||
} else if (sel.type === 'userModel') {
|
||
clip = {
|
||
kind: 'userModel', userModelTypeId: sel.userModelTypeId,
|
||
rotationY: sel.rotationY || 0,
|
||
};
|
||
} else if (sel.type === 'primitive') {
|
||
clip = {
|
||
kind: 'primitive', primitiveType: sel.primitiveType,
|
||
sx: sel.sx, sy: sel.sy, sz: sel.sz,
|
||
color: sel.color, material: sel.material,
|
||
canCollide: sel.canCollide, visible: sel.visible,
|
||
anchored: sel.anchored,
|
||
textureAsset: sel.textureAsset || null,
|
||
brightness: sel.brightness, range: sel.range, effect: sel.effect,
|
||
};
|
||
}
|
||
if (clip) {
|
||
try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); }
|
||
catch (e) { /* ignore — приватный режим / переполнение */ }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Вставить объект из буфера обмена (Ctrl+V, Фаза 5.10).
|
||
* Объект появляется у точки, куда смотрит редактор-камера.
|
||
*/
|
||
pasteFromClipboard() {
|
||
let clip;
|
||
try {
|
||
const raw = localStorage.getItem('kubikon_clipboard');
|
||
if (!raw) return;
|
||
clip = JSON.parse(raw);
|
||
} catch (e) { return; }
|
||
if (!clip || !clip.kind) return;
|
||
// Точка вставки — перед редактор-камерой (~6м по направлению взгляда).
|
||
const cam = this.camera;
|
||
let px = 0, py = 1, pz = 0;
|
||
if (cam) {
|
||
const fwd = cam.getForwardRay ? cam.getForwardRay().direction : null;
|
||
if (fwd) {
|
||
px = cam.position.x + fwd.x * 6;
|
||
pz = cam.position.z + fwd.z * 6;
|
||
}
|
||
}
|
||
if (clip.kind === 'block') {
|
||
const gx = Math.round(px), gz = Math.round(pz);
|
||
let gy = 0;
|
||
while (gy < 64 && this.blockManager?.hasBlock(gx, gy, gz)) gy++;
|
||
this.blockManager?.addBlock(gx, gy, gz, clip.blockTypeId);
|
||
this.selection?.selectBlockAt(gx, gy, gz);
|
||
} else if (clip.kind === 'model') {
|
||
this.modelManager?.addInstance(clip.modelTypeId, px, py, pz, clip.rotationY || 0)
|
||
.then(id => { if (id != null) this.selection?.selectModelByInstanceId(id); })
|
||
.catch(() => {});
|
||
} else if (clip.kind === 'userModel') {
|
||
this.userModelManager?.addInstance(
|
||
clip.userModelTypeId, px, py, pz, clip.rotationY || 0,
|
||
{ currentUserId: this._currentUserId || null },
|
||
).then(id => { if (id != null) this.selection?.selectUserModelByInstanceId(id); })
|
||
.catch(() => {});
|
||
} else if (clip.kind === 'primitive') {
|
||
const id = this.primitiveManager?.addInstance(clip.primitiveType, {
|
||
x: px, y: Math.max(py, (clip.sy || 1) / 2), z: pz,
|
||
sx: clip.sx, sy: clip.sy, sz: clip.sz,
|
||
color: clip.color, material: clip.material,
|
||
canCollide: clip.canCollide, visible: clip.visible,
|
||
anchored: clip.anchored,
|
||
textureAsset: clip.textureAsset || null,
|
||
brightness: clip.brightness, range: clip.range, effect: clip.effect,
|
||
});
|
||
if (id != null) this.selection?.selectPrimitiveById(id);
|
||
}
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
}
|
||
|
||
/**
|
||
* Поставить выделенный объект на пол (y = 0).
|
||
* Для блоков — gridY=0. Для моделей — нижняя граница на полу. Для примитивов — sy/2.
|
||
*/
|
||
alignSelectedToFloor() {
|
||
const sel = this.selection?.getSelection();
|
||
if (!sel) return;
|
||
if (sel.type === 'block') {
|
||
this.selection.moveSelectedBlock(sel.gridX, 0, sel.gridZ);
|
||
} else if (sel.type === 'model') {
|
||
// Модель: rootMesh.position.y = низ модели. y=0 = низ на полу.
|
||
this.selection.moveSelectedModel(sel.x, 0, sel.z);
|
||
} else if (sel.type === 'userModel') {
|
||
this.selection.moveSelectedUserModel(sel.x, 0, sel.z);
|
||
} else if (sel.type === 'primitive') {
|
||
// Центр примитива должен быть на высоте sy/2 чтобы низ касался пола.
|
||
const halfH = (sel.sy || 1) / 2;
|
||
this.selection.moveSelectedPrimitive(sel.x, halfH, sel.z);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* View-preset — поставить редактор-камеру в одну из стандартных позиций.
|
||
* preset: 'top' | 'front' | 'side' | 'iso'
|
||
*/
|
||
setViewPreset(preset) {
|
||
if (!this.camera) return;
|
||
const presets = {
|
||
top: { pos: [0, 40, 0.01], rot: [Math.PI / 2, 0, 0] }, // прямо сверху
|
||
front: { pos: [0, 8, -25], rot: [0, 0, 0] }, // спереди
|
||
side: { pos: [25, 8, 0], rot: [0, -Math.PI / 2, 0] }, // сбоку
|
||
iso: { pos: [15, 15, -20], rot: [Math.PI / 5, -Math.PI / 5, 0] }, // изометрия
|
||
};
|
||
const p = presets[preset];
|
||
if (!p) return;
|
||
this.camera.position = new Vector3(p.pos[0], p.pos[1], p.pos[2]);
|
||
this.camera.rotation = new Vector3(p.rot[0], p.rot[1], p.rot[2]);
|
||
}
|
||
|
||
/**
|
||
* Поставить точку спавна там где сейчас смотрит редактор-камера
|
||
* (полезно для размещения «тут начинать игру»).
|
||
* spawnPoint = (camera.x, max(0, floor(camera.y) - 1), camera.z).
|
||
*/
|
||
setSpawnAtCamera() {
|
||
if (!this.camera) return;
|
||
const p = this.camera.position;
|
||
this._spawnPoint = {
|
||
x: Math.round(p.x),
|
||
y: Math.max(0, Math.floor(p.y) - 1),
|
||
z: Math.round(p.z),
|
||
};
|
||
this._updateSpawnMarker();
|
||
this.history?.markChange();
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
}
|
||
|
||
/** Изменить позицию выделенного (используется Inspector). */
|
||
moveSelectedTo(x, y, z) {
|
||
if (!this.selection) return;
|
||
const sel = this.selection.getSelection();
|
||
if (!sel) return;
|
||
if (sel.type === 'block') {
|
||
this.selection.moveSelectedBlock(Math.round(x), Math.round(y), Math.round(z));
|
||
} else if (sel.type === 'model') {
|
||
this.selection.moveSelectedModel(x, y, z);
|
||
} else if (sel.type === 'userModel') {
|
||
this.selection.moveSelectedUserModel(x, y, z);
|
||
} else if (sel.type === 'spawn') {
|
||
this.selection.moveSelectedSpawn(x, y, z);
|
||
} else if (sel.type === 'primitive') {
|
||
this.selection.moveSelectedPrimitive(x, y, z);
|
||
}
|
||
}
|
||
|
||
/** Изменить размер выделенного примитива (Inspector). */
|
||
resizeSelectedPrimitiveTo(sx, sy, sz) {
|
||
this.selection?.resizeSelectedPrimitive(sx, sy, sz);
|
||
}
|
||
|
||
/** Изменить свойства выделенного примитива (color/material/canCollide/visible). */
|
||
setSelectedPrimitivePropsTo(patch) {
|
||
this.selection?.setSelectedPrimitiveProps(patch);
|
||
}
|
||
|
||
/** Повернуть выделенную модель (Y, в радианах). */
|
||
rotateSelectedModelTo(angleRad) {
|
||
if (!this.selection) return;
|
||
const sel = this.selection.getSelection();
|
||
if (sel?.type === 'userModel') {
|
||
this.selection.rotateSelectedUserModel(angleRad);
|
||
} else {
|
||
this.selection.rotateSelectedModel(angleRad);
|
||
}
|
||
}
|
||
|
||
/** Изменить масштаб выделенной модели. */
|
||
scaleSelectedModelTo(scale) {
|
||
if (!this.selection) return;
|
||
const sel = this.selection.getSelection();
|
||
if (sel?.type === 'userModel') {
|
||
this.selection.scaleSelectedUserModel(scale);
|
||
} else {
|
||
this.selection.scaleSelectedModel(scale);
|
||
}
|
||
}
|
||
|
||
/** Установить режим гизмо: 'select' | 'move' | 'rotate' | 'scale'. */
|
||
setGizmoMode(mode) {
|
||
if (this._gizmo) this._gizmo.setMode(mode);
|
||
}
|
||
|
||
/** Получить текущий режим гизмо. */
|
||
getGizmoMode() {
|
||
return this._gizmo ? this._gizmo.getMode() : 'select';
|
||
}
|
||
|
||
/** Установить snap-step гизмо для перемещения (1.0 / 0.5 / 0.25 / 0=off).
|
||
* Также применяется к Inspector-вводу координат моделей. */
|
||
setGizmoSnap(step) {
|
||
if (this._gizmo) this._gizmo.setSnap(step);
|
||
if (this.selection) this.selection.setSnapStep(step);
|
||
}
|
||
|
||
getGizmoSnap() {
|
||
return this._gizmo ? this._gizmo.getSnap() : 0;
|
||
}
|
||
|
||
/** Сфокусировать редактор-камеру на выделенном (двигает камеру к объекту). */
|
||
focusOnSelection() {
|
||
const sel = this.selection?.getSelection();
|
||
if (!sel) return;
|
||
let target;
|
||
if (sel.type === 'block') {
|
||
target = new Vector3(sel.gridX, sel.gridY + 0.5, sel.gridZ);
|
||
} else if (sel.type === 'model' || sel.type === 'spawn' || sel.type === 'primitive') {
|
||
target = new Vector3(sel.x, sel.y + 0.5, sel.z);
|
||
}
|
||
if (target) this._focusOnTarget(target);
|
||
}
|
||
|
||
/** Установить точку спавна игрока в режиме Play. */
|
||
setSpawnPoint(x, y, z) {
|
||
this._spawnPoint = { x, y, z };
|
||
this._updateSpawnMarker();
|
||
}
|
||
|
||
/** Установить тип модели персонажа (для Play). */
|
||
setPlayerModelType(typeId) {
|
||
if (!typeId) return;
|
||
this._playerModelType = typeId;
|
||
}
|
||
|
||
getPlayerModelType() {
|
||
return this._playerModelType;
|
||
}
|
||
|
||
/** Идёт ли сейчас режим игры. */
|
||
isPlaying() {
|
||
return this._isPlaying;
|
||
}
|
||
|
||
/**
|
||
* Переключить в режим игры. Создаём PlayerController, прячем ghost-блок,
|
||
* запоминаем позицию редактор-камеры чтобы вернуть при exit.
|
||
*/
|
||
enterPlayMode() {
|
||
if (this._isPlaying) return;
|
||
this._isPlaying = true;
|
||
// По умолчанию стандартный HUD видим в Play.
|
||
// Скрипт может скрыть через game.hud.setVisible(false).
|
||
this._setStdHudVisible(true);
|
||
this._setHotbarVisible(true);
|
||
this._setHpVisible(true);
|
||
|
||
// Включаем picking voxel-террейна — иначе камера _clampCameraToWorld
|
||
// не «видит» воксели в Ray-каст и пролетает сквозь стены.
|
||
// В редакторе остаётся выключенным (быстрее).
|
||
try { this.terrainManager?.enablePickingForCamera?.(true); } catch (e) {}
|
||
|
||
// Снимок редактор-камеры
|
||
this._editorCameraSnapshot = {
|
||
position: this.camera.position.clone(),
|
||
rotation: this.camera.rotation.clone(),
|
||
};
|
||
|
||
if (this._ghostMesh) this._ghostMesh.setEnabled(false);
|
||
this._setSpawnMarkerVisible(false);
|
||
// Триггеры — невидимые в Play, видимые в редакторе
|
||
this.primitiveManager?.setTriggersVisible(false);
|
||
|
||
// Запоминаем исходные позиции unanchored-объектов чтобы вернуть
|
||
// их при выходе из Play (физика двигает mesh.position).
|
||
this._snapshotDynamicObjects();
|
||
// Полный снимок примитивов и моделей — чтобы при Stop откатить
|
||
// ВСЕ изменения скриптов (удаления, цвет, видимость, повороты).
|
||
this._snapshotFullScene();
|
||
|
||
// Запускаем физику unanchored
|
||
this.dynamics?.start();
|
||
|
||
// Запускаем фоновую музыку и амбиент
|
||
this.audioManager?.start();
|
||
|
||
// Создаём PlayerController и стартуем
|
||
this.player = new PlayerController(this.scene, this.canvas, this.physics, this);
|
||
this.player.setModelType(this._playerModelType);
|
||
// Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck
|
||
try {
|
||
this.modalManager?.attachPlayer?.(this.player);
|
||
this.modalManager?.attachAudio?.(this.audioManager);
|
||
} catch (e) {}
|
||
this.player._jumpPowerMul = this._jumpPowerMul ?? 1;
|
||
// Применяем дефолтную камеру если задана в сцене
|
||
if (this._defaultCameraMode && this._defaultCameraMode !== this.player._cameraMode) {
|
||
this.player._cameraMode = this._defaultCameraMode;
|
||
}
|
||
// На тач-устройствах отключаем pointer-lock и mouse-камеру
|
||
if (this._touchMode) this.player.setTouchMode(true);
|
||
this.player.setOnExitRequest(() => {
|
||
// Задача 07: магазин скинов открыт → Esc закрывает его (приоритет выше модала).
|
||
if (this._skinShop?.open) {
|
||
this._closeSkinShop();
|
||
return;
|
||
}
|
||
// Задача 04: если открыт модал — первый Esc закрывает его,
|
||
// второй Esc уже выходит из Play. Так юзер не теряет состояние игры
|
||
// случайно при попытке скрыть модал.
|
||
if (this.modalManager?.isOpen?.()) {
|
||
this.modalManager.close();
|
||
return;
|
||
}
|
||
this.exitPlayMode();
|
||
if (this._onPlayChange) this._onPlayChange(false);
|
||
});
|
||
if (this._onPlayerHpChange) this.player.setOnHpChange(this._onPlayerHpChange);
|
||
if (this._onPlayerDeath) this.player.setOnDeath(this._onPlayerDeath);
|
||
this.player.start(this._spawnPoint);
|
||
|
||
// Запускаем пользовательские скрипты (этап 2.1).
|
||
// Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену,
|
||
// поэтому скрипты стартуем в следующем кадре.
|
||
this.gameRuntime = new GameRuntime(this);
|
||
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
|
||
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
|
||
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
|
||
// this.audioManager (AudioManager — ambient/music для всех проектов).
|
||
if (!this.gameAudioManager) {
|
||
this.gameAudioManager = new GameAudioManager();
|
||
}
|
||
// GD-уровень (Этап 5.1): автоматически обрабатывает GD-порталы, шипы, финиш, монеты.
|
||
// Юзер просто ставит объекты из палитры (категории "GD-порталы" и "GD-объекты") в редакторе.
|
||
if (!this.gdLevelManager) {
|
||
this.gdLevelManager = new GdLevelManager(this);
|
||
this.gdLevelManager.setOnPortalEnter((newMode, prevMode) => {
|
||
try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdPortalEnter', data: { newMode, prevMode } }); } catch (e) {}
|
||
try { this.gameAudioManager?.playSfx?.('flip'); } catch (e) {}
|
||
});
|
||
this.gdLevelManager.setOnDeath((info) => {
|
||
try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdDeath', data: info }); } catch (e) {}
|
||
try { this.gameAudioManager?.playSfx?.('death'); } catch (e) {}
|
||
// Респавн игрока через teleport на spawnPoint
|
||
try {
|
||
const sp = this._spawnPoint || { x: 0, y: 2, z: 0 };
|
||
this.player.teleport(sp.x, sp.y, sp.z);
|
||
// Сбросить vy чтобы не нести инерцию из шипа
|
||
if (this.player) this.player._vy = 0;
|
||
} catch (e) {}
|
||
});
|
||
this.gdLevelManager.setOnFinish((info) => {
|
||
const stats = this.gdLevelManager.getCoinsStats();
|
||
try {
|
||
this.gameRuntime?.routeGlobalEvent?.('message', {
|
||
name: 'gdFinish',
|
||
data: { ...info, coinsCollected: stats.collected, coinsTotal: stats.total },
|
||
});
|
||
} catch (e) {}
|
||
try { this.gameAudioManager?.playSfx?.('level_complete'); } catch (e) {}
|
||
});
|
||
this.gdLevelManager.setOnCoinCollected((info) => {
|
||
try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdCoinCollected', data: info }); } catch (e) {}
|
||
try { this.gameAudioManager?.playSfx?.('coin'); } catch (e) {}
|
||
});
|
||
}
|
||
this.gdLevelManager.start();
|
||
// Этапы G1/G2: skybox+параллакс+декоративная трава для GD-уровней.
|
||
// Откладываем на setTimeout — primitiveManager.instances наполняется
|
||
// не сразу при enterPlayMode (load.primitives асинхронный).
|
||
// GD-проект определяется флагом settings.isGd (см. serialize/loadFromState).
|
||
// Fallback для старых проектов БЕЗ флага — реальные id GD-уровней:
|
||
// - 295: GD 2.0 sandbox
|
||
// - 296..306: L1-L11 (эпоха 1 + L11 легаси)
|
||
// - 350..358: L12-L20 (эпоха 2)
|
||
// Раньше было только 296..315 — L12-L20 (id 350..358) НЕ попадали,
|
||
// и веб-плеер не активировал GD-инфру (шипы конусы вместо .glb,
|
||
// нет skybox/forest на заднем фоне). На APK работало правильно.
|
||
const _pid = Number(this._currentProjectId);
|
||
const isGd = (typeof this._isGdProject === 'boolean')
|
||
? this._isGdProject
|
||
: ((_pid >= 296 && _pid <= 315)
|
||
|| (_pid >= 350 && _pid <= 358)
|
||
|| _pid === 295);
|
||
console.log(`[GD-gfx] currentProjectId=${this._currentProjectId}, isGd=${isGd}, flag=${this._isGdProject}`);
|
||
if (isGd) {
|
||
// Ширина уровня — по самому правому cube-блоку
|
||
let levelWidth = 1000;
|
||
if (this.blockManager && this.blockManager.blocks) {
|
||
for (const key of this.blockManager.blocks.keys()) {
|
||
const x = parseInt(String(key).split(',')[0], 10);
|
||
if (Number.isFinite(x) && x > levelWidth) levelWidth = x;
|
||
}
|
||
}
|
||
setTimeout(() => {
|
||
try {
|
||
if (!this.gdSkybox) {
|
||
this.gdSkybox = new GdSkybox();
|
||
const cam = this.player?.camera || this.scene.activeCamera;
|
||
this.gdSkybox.attach(this.scene, cam);
|
||
console.log('[GD-gfx] skybox attached');
|
||
}
|
||
if (!this.gdGroundSkin) {
|
||
this.gdGroundSkin = new GdGroundSkin();
|
||
this.gdGroundSkin.attach(this.scene, levelWidth, this._shadowGenerator, this);
|
||
console.log('[GD-gfx] groundSkin attached, width=', levelWidth);
|
||
}
|
||
// Эпоха по project_id. L11 = 306 (легаси). L12-L20 = 350-358.
|
||
const pid = Number(this._currentProjectId) || 296;
|
||
const GD_PID_TO_EPOCH = {
|
||
296:1, 297:1, 298:1, 299:1, 300:1, 301:1, 302:1, 303:1, 304:1, 305:1,
|
||
306:2, 350:2, 351:2, 352:2, 353:2, 354:2, 355:2, 356:2, 357:2, 358:2,
|
||
};
|
||
const epoch = GD_PID_TO_EPOCH[pid] || 1;
|
||
if (!this.gdSpikes) {
|
||
this.gdSpikes = new GdSpikes();
|
||
this.gdSpikes.attach(this.scene, this, epoch);
|
||
}
|
||
if (!this.gdStartArch) {
|
||
this.gdStartArch = new GdStartArch();
|
||
this.gdStartArch.attach(this.scene, epoch);
|
||
}
|
||
if (!this.gdPortalArch) {
|
||
this.gdPortalArch = new GdPortalArch();
|
||
this.gdPortalArch.attach(this.scene, this, this._currentUserId);
|
||
}
|
||
if (!this.gdDiamond) {
|
||
this.gdDiamond = new GdDiamond();
|
||
this.gdDiamond.attach(this.scene, this);
|
||
}
|
||
if (!this.gdFinish) {
|
||
this.gdFinish = new GdFinish();
|
||
this.gdFinish.attach(this.scene, this, epoch);
|
||
}
|
||
if (!this.gdForest) {
|
||
this.gdForest = new GdForest();
|
||
this.gdForest.attach(this.scene, levelWidth, epoch);
|
||
}
|
||
if (!this.gdPlayerCube) {
|
||
this.gdPlayerCube = new GdPlayerCube();
|
||
this.gdPlayerCube.attach(this.scene, this);
|
||
}
|
||
if (!this.gdPlayerModeSkin) {
|
||
// Задержка 600мс — даём скрипту уровня применить базовый cube-skin,
|
||
// чтобы _origTexture при первой смене режима содержала правильную текстуру.
|
||
setTimeout(() => {
|
||
this.gdPlayerModeSkin = new GdPlayerModeSkin();
|
||
this.gdPlayerModeSkin.attach(this.scene, this, this._currentUserId);
|
||
}, 600);
|
||
}
|
||
if (!this.gdPlayerTrail) {
|
||
this.gdPlayerTrail = new GdPlayerTrail();
|
||
this.gdPlayerTrail.attach(this.scene, this, this._currentProjectId, this._currentUserId);
|
||
}
|
||
if (!this.gdPostFx) {
|
||
this.gdPostFx = new GdPostFx();
|
||
const cam = this.player?.camera || this.scene.activeCamera;
|
||
this.gdPostFx.attach(this.scene, cam, this);
|
||
}
|
||
// Тени отключены — делаем через GdGroundSkin (fake shadows)
|
||
this._enableGdShadows();
|
||
} catch (e) { console.warn('[BabylonScene] GD-graphics attach failed', e); }
|
||
}, 50);
|
||
}
|
||
if (this._onScriptLog) this.gameRuntime.setOnLog(this._onScriptLog);
|
||
if (this._onScriptHud) this.gameRuntime.setOnHud(this._onScriptHud);
|
||
if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair);
|
||
// eslint-disable-next-line no-console
|
||
console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts);
|
||
// Старт через requestAnimationFrame — даём Babylon собрать сцену
|
||
requestAnimationFrame(() => {
|
||
if (this._isPlaying) this.gameRuntime?.start(this._scripts || []);
|
||
});
|
||
|
||
// === Оружие ===
|
||
if (!this.weapons) this.weapons = new WeaponSystem(this);
|
||
if (this._onAmmoChange) this.weapons.setOnAmmoChange(this._onAmmoChange);
|
||
// Подключаем зомби-логику к попаданиям пули
|
||
this.weapons.setOnHit((hit) => {
|
||
if (hit?.mesh && this.zombieManager) {
|
||
this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25);
|
||
}
|
||
if (this._onWeaponHit) {
|
||
try { this._onWeaponHit(hit); } catch (e) {}
|
||
}
|
||
});
|
||
this.weapons.start();
|
||
|
||
// === Зомби-система ===
|
||
if (!this.zombieManager) this.zombieManager = new ZombieManager(this);
|
||
if (!this.spawnerManager) this.spawnerManager = new ZombieSpawnerManager(this, this.zombieManager);
|
||
this.zombieManager.start();
|
||
this.spawnerManager.start();
|
||
|
||
// === NPC-система (Фаза 4.1) — управляемые скриптом персонажи ===
|
||
if (!this.npcManager) this.npcManager = new NpcManager(this);
|
||
this.npcManager.start();
|
||
|
||
// === Связи объектов (Фаза 5, Constraints) ===
|
||
if (!this.constraintManager) this.constraintManager = new ConstraintManager(this);
|
||
this.constraintManager.start();
|
||
|
||
// === Лучи и следы (Фаза 5.2 — Beam/Trail) ===
|
||
if (!this.beamManager) this.beamManager = new BeamManager(this);
|
||
this.beamManager.start();
|
||
|
||
// === 3D-звук (Фаза 5.5 — позиционный звук) ===
|
||
if (!this.soundManager) this.soundManager = new SoundManager(this);
|
||
this.soundManager.start();
|
||
// Регистрируем gameplay-объекты: зомби и спавнеры.
|
||
// Применяем defaults + текущие gameplayParams из инспектора.
|
||
if (this.modelManager) {
|
||
for (const data of this.modelManager.instances.values()) {
|
||
const gp = data.gameplay;
|
||
if (!gp) continue;
|
||
const params = { ...(gp.defaultParams || {}), ...(data.gameplayParams || {}) };
|
||
if (gp.isZombie) {
|
||
this.zombieManager.registerExisting(data.instanceId, params);
|
||
} else if (gp.isZombieSpawner) {
|
||
this.spawnerManager.register(data.instanceId, params);
|
||
}
|
||
}
|
||
}
|
||
// Снаряжаем оружие из активного слота инвентаря
|
||
const active = this.inventory?.getActive?.();
|
||
if (active) this.weapons.equip(active);
|
||
|
||
// Замораживаем world-matrix у всех статичных GLB-моделей
|
||
// (не зомби и не спавнеры). Деревья, дома, камни не двигаются —
|
||
// Babylon не должен пересчитывать их матрицы каждый кадр.
|
||
try { this.modelManager?.freezeStaticModels?.(); } catch (e) {}
|
||
try { this.primitiveManager?.freezeStaticPrimitives?.(); } catch (e) {}
|
||
|
||
// ОПТИМИЗАЦИЯ ОТКЛЮЧЕНА: octree селекшн.
|
||
// Octree создаётся один раз и не «знает» о мешах добавленных позже —
|
||
// даже с alwaysSelectAsActiveMesh новые меши (трейсеры выстрелов,
|
||
// debris-кубы при смерти, динамические объекты) фактически выпадают
|
||
// из активного списка → невидимы. Стандартный frustum-culling Babylon
|
||
// дешёвый сам по себе для нашей сцены, octree больше вреда чем пользы.
|
||
}
|
||
|
||
/** Заглушка для совместимости — раньше пересоздавала octree. */
|
||
setActiveMeshesDirty() {
|
||
// no-op
|
||
}
|
||
|
||
/** Установить колбэк логов от скриптов (для Console-панели UI). */
|
||
setOnScriptLog(cb) {
|
||
this._onScriptLog = cb;
|
||
if (this.gameRuntime) this.gameRuntime.setOnLog(cb);
|
||
}
|
||
|
||
/** Колбэк команд HUD от скриптов (для GameHud React-компонента). */
|
||
setOnScriptHud(cb) {
|
||
this._onScriptHud = cb;
|
||
if (this.gameRuntime) this.gameRuntime.setOnHud(cb);
|
||
}
|
||
|
||
/** Колбэк смены прицела из скрипта (game.player.crosshair = 'cross'). */
|
||
setOnScriptCrosshair(cb) {
|
||
this._onScriptCrosshair = cb;
|
||
if (this.gameRuntime) this.gameRuntime.setOnCrosshairChange(cb);
|
||
}
|
||
|
||
// ============================================================
|
||
// Таймер прохождения (для лидерборда)
|
||
// ============================================================
|
||
/** cb({state: 'start'|'stop'|'submit', timeMs}) */
|
||
setOnTimer(cb) { this._onTimer = cb; }
|
||
|
||
_timerStart() {
|
||
this._timerStartedAt = performance.now();
|
||
this._timerRunning = true;
|
||
if (this._onTimer) try { this._onTimer({ state: 'start', timeMs: 0 }); } catch (e) {}
|
||
}
|
||
_timerStop() {
|
||
if (!this._timerRunning) return;
|
||
const ms = Math.round(performance.now() - (this._timerStartedAt || 0));
|
||
this._timerRunning = false;
|
||
if (this._onTimer) try { this._onTimer({ state: 'stop', timeMs: ms }); } catch (e) {}
|
||
}
|
||
_timerSubmit() {
|
||
if (!this._timerRunning && !this._timerStartedAt) return;
|
||
const ms = Math.round(performance.now() - (this._timerStartedAt || 0));
|
||
this._timerRunning = false;
|
||
if (this._onTimer) try { this._onTimer({ state: 'submit', timeMs: ms }); } catch (e) {}
|
||
}
|
||
/** Получить текущее время таймера в мс (или 0 если не запущен). */
|
||
getTimerMs() {
|
||
if (!this._timerRunning || !this._timerStartedAt) return 0;
|
||
return Math.round(performance.now() - this._timerStartedAt);
|
||
}
|
||
isTimerRunning() { return !!this._timerRunning; }
|
||
|
||
/** PERF-METRICS: получить и сбросить накопленные метрики за окно. */
|
||
flushPerfMetrics() {
|
||
const m = this._perfMetrics;
|
||
if (!m) return null;
|
||
const out = {
|
||
render_ms_avg: m.render_count ? (m.render_ms_sum / m.render_count) : 0,
|
||
physics_ms_avg: m.physics_count ? (m.physics_ms_sum / m.physics_count) : 0,
|
||
script_ms_avg: m.script_count ? (m.script_ms_sum / m.script_count) : 0,
|
||
idle_ms_avg: m.idle_count ? (m.idle_ms_sum / m.idle_count) : 0,
|
||
render_count: m.render_count,
|
||
physics_count: m.physics_count,
|
||
script_count: m.script_count,
|
||
};
|
||
m.render_ms_sum = 0; m.render_count = 0;
|
||
m.physics_ms_sum = 0; m.physics_count = 0;
|
||
m.script_ms_sum = 0; m.script_count = 0;
|
||
m.idle_ms_sum = 0; m.idle_count = 0;
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Поставить render-loop на паузу.
|
||
* Используется когда Babylon canvas не виден (активен таб скрипта),
|
||
* чтобы освободить CPU/GPU и Monaco не лагал.
|
||
* НЕ останавливает Play-режим — только рендер.
|
||
*/
|
||
pauseRendering() { this._renderingPaused = true; }
|
||
resumeRendering() { this._renderingPaused = false; }
|
||
isRenderingPaused() { return !!this._renderingPaused; }
|
||
|
||
/**
|
||
* Создать эффект частиц в точке. Вызывается из GameRuntime._spawnParticles
|
||
* (через game.scene.spawnParticles в скриптах).
|
||
*
|
||
* payload: { type, position: {x,y,z}, duration, count, color }
|
||
*/
|
||
_spawnParticleEffect(payload) {
|
||
if (!payload || !this.scene) return;
|
||
const pos = payload.position || { x: 0, y: 0, z: 0 };
|
||
const type = payload.type || 'sparks';
|
||
const duration = Math.max(0.1, Math.min(20, Number(payload.duration) || 1.5));
|
||
const countMul = Math.max(0.1, Math.min(10, Number(payload.count) || 1));
|
||
|
||
// Кэшируем текстуру частицы — один раз на сцену
|
||
if (!this._particleTex) {
|
||
const tex = new DynamicTexture('particleTex', { width: 64, height: 64 }, this.scene, false);
|
||
const ctx = tex.getContext();
|
||
const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
|
||
grad.addColorStop(0, 'rgba(255,255,255,1)');
|
||
grad.addColorStop(0.4, 'rgba(255,255,255,0.6)');
|
||
grad.addColorStop(1, 'rgba(255,255,255,0)');
|
||
ctx.fillStyle = grad;
|
||
ctx.fillRect(0, 0, 64, 64);
|
||
tex.update();
|
||
tex.hasAlpha = true;
|
||
this._particleTex = tex;
|
||
}
|
||
|
||
// MOBILE-OPT (этап 1): на мобильном уменьшаем кол-во частиц в 2 раза
|
||
const baseCount = this._isMobileMode ? 40 : 80;
|
||
const ps = new ParticleSystem('p_' + Date.now(),
|
||
Math.floor(baseCount * countMul), this.scene);
|
||
ps.particleTexture = this._particleTex;
|
||
ps.emitter = new Vector3(pos.x, pos.y, pos.z);
|
||
ps.minEmitBox = new Vector3(-0.1, -0.1, -0.1);
|
||
ps.maxEmitBox = new Vector3(0.1, 0.1, 0.1);
|
||
ps.blendMode = ParticleSystem.BLENDMODE_ADD;
|
||
|
||
const customColor = payload.color && /^#[0-9a-fA-F]{6}$/.test(payload.color)
|
||
? payload.color : null;
|
||
this._configureParticleSystem(ps, type, customColor, countMul);
|
||
|
||
ps.start();
|
||
// Авто-стоп: для explosion почти сразу (это burst), для остальных = duration
|
||
const stopAt = type === 'explosion' ? 0.05 : duration;
|
||
const disposeAt = stopAt + (ps.maxLifeTime || 1) + 0.3;
|
||
setTimeout(() => { try { ps.stop(); } catch (e) {} }, stopAt * 1000);
|
||
// dispose(false) — particleTexture расшарена (_particleTex), не удалять.
|
||
setTimeout(() => { try { ps.dispose(false); } catch (e) {} }, disposeAt * 1000);
|
||
}
|
||
|
||
/**
|
||
* Настроить параметры ParticleSystem под тип эффекта.
|
||
* Общий конфигуратор для разового эффекта (_spawnParticleEffect) и
|
||
* постоянного эмиттера-объекта (createEmitterParticles).
|
||
*/
|
||
_configureParticleSystem(ps, type, customColor, countMul = 1) {
|
||
const hexToColor4 = (hex, a = 1) => {
|
||
const r = parseInt(hex.substr(1, 2), 16) / 255;
|
||
const g = parseInt(hex.substr(3, 2), 16) / 255;
|
||
const b = parseInt(hex.substr(5, 2), 16) / 255;
|
||
return new Color4(r, g, b, a);
|
||
};
|
||
|
||
switch (type) {
|
||
case 'fire':
|
||
ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.6, 0.1, 1);
|
||
ps.color2 = customColor ? hexToColor4(customColor, 0.8) : new Color4(1, 0.2, 0, 1);
|
||
ps.colorDead = new Color4(0.2, 0, 0, 0);
|
||
ps.minSize = 0.2; ps.maxSize = 0.5;
|
||
ps.minLifeTime = 0.4; ps.maxLifeTime = 1.0;
|
||
ps.emitRate = 80;
|
||
ps.gravity = new Vector3(0, 1.2, 0);
|
||
ps.direction1 = new Vector3(-0.4, 1.5, -0.4);
|
||
ps.direction2 = new Vector3(0.4, 2.0, 0.4);
|
||
ps.minEmitPower = 0.5; ps.maxEmitPower = 1.2;
|
||
break;
|
||
case 'smoke':
|
||
ps.color1 = new Color4(0.4, 0.4, 0.4, 0.6);
|
||
ps.color2 = new Color4(0.2, 0.2, 0.2, 0.4);
|
||
ps.colorDead = new Color4(0, 0, 0, 0);
|
||
ps.minSize = 0.4; ps.maxSize = 1.2;
|
||
ps.minLifeTime = 1.5; ps.maxLifeTime = 3;
|
||
ps.emitRate = 40;
|
||
ps.gravity = new Vector3(0, 0.5, 0);
|
||
ps.direction1 = new Vector3(-0.3, 1, -0.3);
|
||
ps.direction2 = new Vector3(0.3, 1.5, 0.3);
|
||
ps.minEmitPower = 0.3; ps.maxEmitPower = 0.7;
|
||
ps.blendMode = ParticleSystem.BLENDMODE_STANDARD;
|
||
break;
|
||
case 'sparks':
|
||
ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 1, 0.4, 1);
|
||
ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.6, 0, 1);
|
||
ps.colorDead = new Color4(0.5, 0.2, 0, 0);
|
||
ps.minSize = 0.05; ps.maxSize = 0.15;
|
||
ps.minLifeTime = 0.3; ps.maxLifeTime = 0.8;
|
||
ps.emitRate = 200;
|
||
ps.gravity = new Vector3(0, -8, 0);
|
||
ps.direction1 = new Vector3(-3, 4, -3);
|
||
ps.direction2 = new Vector3(3, 7, 3);
|
||
ps.minEmitPower = 1; ps.maxEmitPower = 3;
|
||
break;
|
||
case 'magic':
|
||
ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(0.6, 0.3, 1, 1);
|
||
ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(0.3, 0.6, 1, 1);
|
||
ps.colorDead = new Color4(0.2, 0, 0.5, 0);
|
||
ps.minSize = 0.15; ps.maxSize = 0.35;
|
||
ps.minLifeTime = 1; ps.maxLifeTime = 2.2;
|
||
ps.emitRate = 60;
|
||
ps.gravity = new Vector3(0, 0.3, 0);
|
||
ps.direction1 = new Vector3(-1, 1, -1);
|
||
ps.direction2 = new Vector3(1, 2, 1);
|
||
ps.minEmitPower = 0.5; ps.maxEmitPower = 1.5;
|
||
break;
|
||
case 'explosion':
|
||
ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.7, 0.2, 1);
|
||
ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.3, 0, 1);
|
||
ps.colorDead = new Color4(0.2, 0, 0, 0);
|
||
ps.minSize = 0.3; ps.maxSize = 0.8;
|
||
ps.minLifeTime = 0.4; ps.maxLifeTime = 1.0;
|
||
ps.emitRate = 0;
|
||
ps.manualEmitCount = Math.floor(120 * countMul);
|
||
ps.gravity = new Vector3(0, -3, 0);
|
||
ps.direction1 = new Vector3(-5, -1, -5);
|
||
ps.direction2 = new Vector3(5, 5, 5);
|
||
ps.minEmitPower = 2; ps.maxEmitPower = 6;
|
||
break;
|
||
case 'confetti':
|
||
ps.color1 = new Color4(1, 0.3, 0.3, 1);
|
||
ps.color2 = new Color4(0.3, 0.6, 1, 1);
|
||
ps.colorDead = new Color4(0, 0, 0, 0);
|
||
ps.minSize = 0.1; ps.maxSize = 0.25;
|
||
ps.minLifeTime = 1.5; ps.maxLifeTime = 3;
|
||
ps.emitRate = 100;
|
||
ps.gravity = new Vector3(0, -3, 0);
|
||
ps.direction1 = new Vector3(-3, 5, -3);
|
||
ps.direction2 = new Vector3(3, 8, 3);
|
||
ps.minEmitPower = 1; ps.maxEmitPower = 3;
|
||
break;
|
||
default:
|
||
ps.color1 = new Color4(1, 1, 1, 1);
|
||
ps.color2 = new Color4(0.7, 0.7, 0.7, 0.5);
|
||
ps.colorDead = new Color4(0, 0, 0, 0);
|
||
ps.minSize = 0.1; ps.maxSize = 0.3;
|
||
ps.minLifeTime = 0.5; ps.maxLifeTime = 1.5;
|
||
ps.emitRate = 60;
|
||
ps.direction1 = new Vector3(-1, 1, -1);
|
||
ps.direction2 = new Vector3(1, 2, 1);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Создать ПОСТОЯННУЮ систему частиц для эмиттера-объекта (костёр и т.п.).
|
||
* Не имеет авто-стопа — горит пока объект существует. Возвращает ps.
|
||
*/
|
||
createEmitterParticles(type, position, color) {
|
||
if (!this.scene) return null;
|
||
if (!this._particleTex) {
|
||
const tex = new DynamicTexture('particleTex', { width: 64, height: 64 }, this.scene, false);
|
||
const ctx = tex.getContext();
|
||
const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
|
||
grad.addColorStop(0, 'rgba(255,255,255,1)');
|
||
grad.addColorStop(0.4, 'rgba(255,255,255,0.6)');
|
||
grad.addColorStop(1, 'rgba(255,255,255,0)');
|
||
ctx.fillStyle = grad;
|
||
ctx.fillRect(0, 0, 64, 64);
|
||
tex.update();
|
||
tex.hasAlpha = true;
|
||
this._particleTex = tex;
|
||
}
|
||
const baseCount = this._isMobileMode ? 60 : 120;
|
||
const ps = new ParticleSystem('emitter_' + Date.now(), baseCount, this.scene);
|
||
ps.particleTexture = this._particleTex;
|
||
ps.emitter = new Vector3(position.x, position.y, position.z);
|
||
ps.minEmitBox = new Vector3(-0.15, -0.1, -0.15);
|
||
ps.maxEmitBox = new Vector3(0.15, 0.1, 0.15);
|
||
ps.blendMode = ParticleSystem.BLENDMODE_ADD;
|
||
const customColor = color && /^#[0-9a-fA-F]{6}$/.test(color) ? color : null;
|
||
// explosion как постоянный эффект не имеет смысла → fire
|
||
const effType = type === 'explosion' ? 'fire' : type;
|
||
this._configureParticleSystem(ps, effType, customColor, 1);
|
||
ps.start();
|
||
return ps;
|
||
}
|
||
|
||
/**
|
||
* Запустить ОДИН скрипт без Play-режима (отладочный запуск из редактора).
|
||
* Если runtime уже есть — переиспользуем, иначе создаём.
|
||
*/
|
||
startSoloScript(scriptId) {
|
||
const all = this._scripts || [];
|
||
const sc = all.find(s => s.id === scriptId);
|
||
if (!sc) return false;
|
||
if (!this.gameRuntime) {
|
||
this.gameRuntime = new GameRuntime(this);
|
||
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
|
||
if (!this.gameAudioManager) {
|
||
this.gameAudioManager = new GameAudioManager();
|
||
}
|
||
if (this._onScriptLog) this.gameRuntime.setOnLog(this._onScriptLog);
|
||
if (this._onScriptHud) this.gameRuntime.setOnHud(this._onScriptHud);
|
||
if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair);
|
||
}
|
||
this.gameRuntime.startSolo(sc);
|
||
return true;
|
||
}
|
||
|
||
/** Остановить отладочный (solo) запуск. */
|
||
stopSoloScript() {
|
||
if (this.gameRuntime && this.gameRuntime.isSolo?.()) {
|
||
this.gameRuntime.stop();
|
||
// Если не в Play-режиме — освобождаем runtime
|
||
if (!this._isPlaying) {
|
||
this.gameRuntime = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
isSoloRunning() {
|
||
return !!this.gameRuntime?.isSolo?.();
|
||
}
|
||
getSoloScriptId() {
|
||
return this.gameRuntime?.getSoloScriptId?.() || null;
|
||
}
|
||
|
||
/** Получить все скрипты проекта. */
|
||
getScripts() { return [...this._scripts]; }
|
||
|
||
/** Заменить все скрипты (используется при load/edit). */
|
||
setScripts(scripts) {
|
||
this._scripts = Array.isArray(scripts) ? scripts.slice() : [];
|
||
}
|
||
|
||
/** Установить код одного скрипта по id. Если id нет — создать новый. */
|
||
upsertScript(id, code, target = undefined) {
|
||
const i = this._scripts.findIndex(s => s.id === id);
|
||
if (i >= 0) {
|
||
this._scripts[i] = {
|
||
...this._scripts[i],
|
||
code,
|
||
...(target !== undefined ? { target } : {}),
|
||
};
|
||
} else {
|
||
this._scripts.push({
|
||
id: id || `script_${Date.now()}`,
|
||
code,
|
||
target: target !== undefined ? target : null,
|
||
});
|
||
}
|
||
// Скрипты — часть сцены: фиксируем в истории, иначе undo откатит
|
||
// _scripts к снапшоту, снятому до создания скрипта, и скрипт пропадёт.
|
||
this.history?.markChange();
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
}
|
||
|
||
/** Удалить скрипт по id. */
|
||
removeScript(id) {
|
||
this._scripts = this._scripts.filter(s => s.id !== id);
|
||
this.history?.markChange();
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
}
|
||
|
||
/**
|
||
* Зарегистрировать колбэк для уведомлений об изменении режима Play
|
||
* (вызывается когда player сам инициирует exit, например по Esc).
|
||
* KubikonEditor подписывается чтобы синхронизировать React-state.
|
||
*/
|
||
setOnPlayChange(cb) {
|
||
this._onPlayChange = cb;
|
||
}
|
||
|
||
/**
|
||
* Колбэк изменения сцены (любая модификация блоков/моделей).
|
||
* Используется KubikonEditor для dirty-tracking → auto-save.
|
||
* Сами обработчики на blockManager/modelManager привязаны в init() —
|
||
* они дёргают и history.markChange() и this._onSceneChange.
|
||
*/
|
||
setOnSceneChange(cb) {
|
||
this._onSceneChange = cb;
|
||
}
|
||
|
||
/** Колбэк изменения GUI-элементов (для перерисовки React-overlay). */
|
||
setOnGuiChange(cb) {
|
||
this._onGuiChange = cb;
|
||
}
|
||
|
||
/** Подключить API для пользовательских моделей (Kubikon3DService).
|
||
* Нужно дважды: setApi для самого UserModelManager (getUserModel)
|
||
* и сохранить _userModelsApi для incrementModelUses в _handlePlaceModel. */
|
||
setUserModelsApi(api) {
|
||
this._userModelsApi = api;
|
||
if (this.userModelManager && api) {
|
||
this.userModelManager.setApi(api);
|
||
}
|
||
}
|
||
|
||
/** Передать id текущего пользователя — для запросов к приватным моделям. */
|
||
setCurrentUserId(userId) {
|
||
this._currentUserId = userId;
|
||
}
|
||
|
||
/** Передать id текущего проекта — для game.save.* эндпоинтов (savegame API).
|
||
* Без этого скрипты не смогут сохранять прогресс. */
|
||
setCurrentProjectId(projectId) {
|
||
this._currentProjectId = projectId;
|
||
}
|
||
|
||
/** Колбэк изменения видимости стандартного HUD (HP-бар, hotbar, ...).
|
||
* Редактор/плеер подписываются и реактивно скрывают/показывают элементы.
|
||
* Скрипт зовёт game.hud.setVisible(false) → этот колбэк сработает. */
|
||
setOnStdHudVisibilityChange(cb) {
|
||
this._onStdHudVisibilityChange = cb;
|
||
}
|
||
_setStdHudVisible(visible) {
|
||
this._stdHudVisible = !!visible;
|
||
try { this._onStdHudVisibilityChange?.(this._stdHudVisible); } catch (e) {}
|
||
}
|
||
/** Задача 03: отдельный контроль хотбара (5 слотов инвентаря снизу).
|
||
* Дёргается из game.hud.setHotbarVisible(bool). */
|
||
setOnHotbarVisibilityChange(cb) {
|
||
this._onHotbarVisibilityChange = cb;
|
||
}
|
||
_setHotbarVisible(visible) {
|
||
this._hotbarVisible = !!visible;
|
||
try { this._onHotbarVisibilityChange?.(this._hotbarVisible); } catch (e) {}
|
||
}
|
||
/** Задача 03: отдельный контроль HP-индикатора (полоска слева сверху).
|
||
* Дёргается из game.hud.setHpVisible(bool). */
|
||
setOnHpVisibilityChange(cb) {
|
||
this._onHpVisibilityChange = cb;
|
||
}
|
||
_setHpVisible(visible) {
|
||
this._hpVisible = !!visible;
|
||
try { this._onHpVisibilityChange?.(this._hpVisible); } catch (e) {}
|
||
}
|
||
|
||
/** Колбэк смены cursor-режима (ui/game) скриптом через game.input.setCursorMode.
|
||
* Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */
|
||
setOnCursorModeChange(cb) {
|
||
this._onCursorModeChange = cb;
|
||
}
|
||
|
||
/** Пересобрать spatial-индекс физики для user-моделей.
|
||
* Вызывается из SelectionManager при изменении canCollide / anchored /
|
||
* position / rotation / scale. */
|
||
_syncUserModelColliders() {
|
||
try { this.physics?.setSpatialDirty?.(); } catch (e) {}
|
||
}
|
||
|
||
/** Инвалидировать пользовательскую модель после её редактирования.
|
||
* Сбрасывает кэш + пересоздаёт все инстансы этой модели в сцене с
|
||
* новой геометрией. Вызывается из KubikonEditor.jsx после закрытия
|
||
* редактора модели (когда editingUserModelId != null). */
|
||
async refreshUserModel(userModelId) {
|
||
if (!this.userModelManager) return 0;
|
||
const rebuilt = await this.userModelManager.invalidateModel(userModelId, {
|
||
rebuild: true,
|
||
currentUserId: this._currentUserId || null,
|
||
});
|
||
// Тени для свежесозданных мешей
|
||
if (rebuilt > 0) {
|
||
for (const inst of this.userModelManager.instances.values()) {
|
||
if (inst.userModelId === userModelId) {
|
||
for (const m of inst.meshes) {
|
||
try { this.addShadowCaster(m); } catch (e) {}
|
||
}
|
||
}
|
||
}
|
||
this._syncUserModelColliders();
|
||
}
|
||
return rebuilt;
|
||
}
|
||
|
||
/** Колбэк изменения инвентаря (для hot-bar React). */
|
||
setOnInventoryChange(cb) {
|
||
this._onInventoryChange = cb;
|
||
}
|
||
|
||
/** Колбэк изменения патронов оружия (для GUI). */
|
||
setOnAmmoChange(cb) {
|
||
this._onAmmoChange = cb;
|
||
if (this.weapons) this.weapons.setOnAmmoChange(cb);
|
||
}
|
||
|
||
/** Колбэк попадания пули (для логики урона зомби и др.). */
|
||
setOnWeaponHit(cb) {
|
||
this._onWeaponHit = cb;
|
||
if (this.weapons) this.weapons.setOnHit(cb);
|
||
}
|
||
|
||
/** Колбэк изменения HP игрока. */
|
||
setOnPlayerHpChange(cb) {
|
||
this._onPlayerHpChange = cb;
|
||
if (this.player) this.player.setOnHpChange(cb);
|
||
}
|
||
|
||
/** Колбэк смерти игрока. */
|
||
setOnPlayerDeath(cb) {
|
||
this._onPlayerDeath = cb;
|
||
if (this.player) this.player.setOnDeath(cb);
|
||
}
|
||
|
||
/** Колбэк Escape в редакторе (для возврата в инструмент «Выделить»). */
|
||
setOnEditorEscape(cb) {
|
||
this._onEditorEscape = cb;
|
||
}
|
||
|
||
getInventoryState() {
|
||
return this.inventory ? this.inventory.serialize() : { slots: [], activeIndex: 0 };
|
||
}
|
||
|
||
setActiveInventorySlot(index) {
|
||
// Phase 6.4: фиксируем что было активно ДО смены -- для onUnequipped.
|
||
const wasActive = this.inventory?.getActive?.();
|
||
this.inventory?.setActive(index);
|
||
// Если в Play — пересменяем оружие
|
||
if (this._isPlaying && this.weapons) {
|
||
const active = this.inventory?.getActive?.();
|
||
if (active && active.kind === 'weapon') {
|
||
this.weapons.equip(active);
|
||
} else {
|
||
this.weapons.unequip();
|
||
}
|
||
// Сообщаем мультиплееру о смене оружия — чтобы remote-клиенты
|
||
// увидели в руке нашей модели правильный GLB.
|
||
if (this._mpSync) {
|
||
const modelId = (active && active.kind === 'weapon')
|
||
? (active.modelTypeId || '')
|
||
: '';
|
||
try { this._mpSync.sendWeapon(modelId); } catch (e) {}
|
||
}
|
||
// Phase 6.4: события onEquipped / onUnequipped для кастомных tool.
|
||
try {
|
||
if (wasActive && wasActive !== active) {
|
||
const tool = {
|
||
kind: wasActive.kind, modelTypeId: wasActive.modelTypeId, name: wasActive.name,
|
||
};
|
||
if (wasActive.params && wasActive.params._customToolId) {
|
||
tool.customToolId = wasActive.params._customToolId;
|
||
}
|
||
this.gameRuntime?.routeGlobalEvent?.('toolUnequipped', { tool });
|
||
}
|
||
if (active && active !== wasActive) {
|
||
const tool = {
|
||
kind: active.kind, modelTypeId: active.modelTypeId, name: active.name,
|
||
};
|
||
if (active.params && active.params._customToolId) {
|
||
tool.customToolId = active.params._customToolId;
|
||
}
|
||
this.gameRuntime?.routeGlobalEvent?.('toolEquipped', { tool });
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
addInventoryItem(item) {
|
||
return this.inventory?.add(item) ?? -1;
|
||
}
|
||
|
||
/** API для UI: создать/изменить/удалить GUI-элемент. Делегирует в GuiManager. */
|
||
createGuiElement(type, opts) {
|
||
return this.guiManager?.create(type, opts);
|
||
}
|
||
updateGuiElement(id, patch) {
|
||
this.guiManager?.update(id, patch);
|
||
}
|
||
removeGuiElement(id) {
|
||
// Если был выделен — снять выделение
|
||
if (this.selection?._selection?.type === 'gui' && this.selection._selection.id === id) {
|
||
this.selection.clearSelection?.();
|
||
}
|
||
this.guiManager?.remove(id);
|
||
}
|
||
renameGuiElement(id, name) {
|
||
this.guiManager?.rename(id, name);
|
||
}
|
||
moveGuiElementZ(id, direction) {
|
||
this.guiManager?.moveZ(id, direction);
|
||
}
|
||
getGuiElements() {
|
||
return this.guiManager ? this.guiManager.getAll() : [];
|
||
}
|
||
|
||
// ===== Задача 07: встроенный магазин скинов (React-оверлей) =====
|
||
// Состояние держим тут; React-компонент SkinShopOverlay поллит getSkinShopState().
|
||
_ensureSkinShopState() {
|
||
if (!this._skinShop) {
|
||
this._skinShop = {
|
||
open: false,
|
||
rev: 0, // ревизия — React видит изменение
|
||
data: { all: [], unlocked: [], current: null, coins: 0, manifestFull: [] },
|
||
buyResult: null, // последний результат покупки {slug, ok, reason}
|
||
};
|
||
}
|
||
return this._skinShop;
|
||
}
|
||
/** Снимок состояния магазина для React (поллинг через rAF). */
|
||
getSkinShopState() { return this._skinShop || null; }
|
||
/** Открыть/закрыть магазин (из скрипта или клавиши B). */
|
||
_openSkinShop() {
|
||
const s = this._ensureSkinShopState();
|
||
// Отключён в проекте? (скрипт всё равно может открыть через API —
|
||
// shopVisible:false запрещает только клавишу B, см. toggleSkinShop).
|
||
s.open = true; s.rev++;
|
||
}
|
||
_closeSkinShop() {
|
||
const s = this._ensureSkinShopState();
|
||
s.open = false; s.rev++;
|
||
}
|
||
toggleSkinShop() {
|
||
const s = this._ensureSkinShopState();
|
||
if (s.open) { this._closeSkinShop(); return; }
|
||
// Клавиша B открывает магазин только если он включён в проекте.
|
||
if (this._skinsConfig && this._skinsConfig.shopVisible === false) return;
|
||
this._openSkinShop();
|
||
}
|
||
/** Данные скинов от GameRuntime (манифест + unlocked + coins). */
|
||
_setSkinShopData(data) {
|
||
const s = this._ensureSkinShopState();
|
||
s.data = { ...s.data, ...data };
|
||
s.rev++;
|
||
}
|
||
_onSkinBuyResult(res) {
|
||
const s = this._ensureSkinShopState();
|
||
s.buyResult = { ...res, t: (this.scene?.getEngine?.()?.getFps?.() || 0) };
|
||
s.rev++;
|
||
}
|
||
/** Намерение купить/надеть скин — шлём в runtime (он списывает рублики). */
|
||
requestBuySkin(slug, price) {
|
||
const rt = this.gameRuntime;
|
||
if (!rt) return;
|
||
try { rt._handleCommand?.(null, 'player.buySkin', { slug, price: price || 0 }); } catch (e) {}
|
||
}
|
||
/** Данные из загруженных проектом кастомных .glb-скинов (data-URL по slug). */
|
||
getAssetDataUrl(slug) {
|
||
try {
|
||
// Кастомные скины хранят dataUrl прямо в _skinsConfig.customGlbs.
|
||
const list = this._skinsConfig?.customGlbs || [];
|
||
const rec = list.find(g => g && g.slug === slug);
|
||
if (rec && rec.dataUrl) return rec.dataUrl;
|
||
} catch (e) {}
|
||
return null;
|
||
}
|
||
_onPlayerSkinChanged(slug) {
|
||
const s = this._ensureSkinShopState();
|
||
if (s.data) { s.data.current = slug; s.rev++; }
|
||
}
|
||
|
||
// ===== Библиотека пользовательских картинок (этап 3.6) =====
|
||
|
||
/** Список картинок проекта [{id, name, dataUrl}]. */
|
||
getAssets() {
|
||
return this.assetManager ? this.assetManager.list() : [];
|
||
}
|
||
|
||
/** Загрузить картинку из File. Возвращает Promise<{ok, id?, error?}>. */
|
||
addAssetFromFile(file) {
|
||
if (!this.assetManager) return Promise.resolve({ ok: false, error: 'нет менеджера' });
|
||
return this.assetManager.addFromFile(file).then((res) => {
|
||
if (res.ok && this._onSceneChange) this._onSceneChange();
|
||
return res;
|
||
});
|
||
}
|
||
|
||
renameAsset(id, name) {
|
||
if (!this.assetManager) return;
|
||
this.assetManager.rename(id, name);
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
}
|
||
|
||
/**
|
||
* Удалить картинку. Снимает её с примитивов/GUI, которые на неё ссылались —
|
||
* иначе остались бы «висячие» ссылки на несуществующий ассет.
|
||
*/
|
||
removeAsset(id) {
|
||
if (!this.assetManager) return;
|
||
this.assetManager.remove(id);
|
||
// Снять текстуру с примитивов, использовавших этот ассет.
|
||
if (this.primitiveManager) {
|
||
for (const data of this.primitiveManager.instances.values()) {
|
||
if (data.textureAsset === id) {
|
||
this.primitiveManager.updateInstance(data.id, { textureAsset: null });
|
||
}
|
||
}
|
||
}
|
||
// Снять картинку с GUI-элементов image.
|
||
if (this.guiManager) {
|
||
for (const el of this.guiManager.getAll()) {
|
||
if (el.imageAsset === id) {
|
||
this.guiManager.update(el.id, { imageAsset: null });
|
||
}
|
||
}
|
||
}
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
}
|
||
|
||
// ===== Библиотека пользовательских звуков (Фаза 5.5) =====
|
||
|
||
/** Список звуков проекта [{id, name, dataUrl}]. */
|
||
getSounds() {
|
||
return this.soundLibrary ? this.soundLibrary.list() : [];
|
||
}
|
||
|
||
/** Загрузить звук из File. Возвращает Promise<{ok, id?, error?}>. */
|
||
addSoundFromFile(file) {
|
||
if (!this.soundLibrary) return Promise.resolve({ ok: false, error: 'нет библиотеки' });
|
||
return this.soundLibrary.addFromFile(file).then((res) => {
|
||
if (res.ok && this._onSceneChange) this._onSceneChange();
|
||
return res;
|
||
});
|
||
}
|
||
|
||
renameSound(id, name) {
|
||
if (!this.soundLibrary) return;
|
||
this.soundLibrary.rename(id, name);
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
}
|
||
|
||
removeSound(id) {
|
||
if (!this.soundLibrary) return;
|
||
this.soundLibrary.remove(id);
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
}
|
||
|
||
// ===== Библиотека импортированных .glb-моделей (Фаза 5.8) =====
|
||
|
||
/** Список импортированных моделей [{id, name, dataUrl}]. */
|
||
getGlbModels() {
|
||
return this.glbLibrary ? this.glbLibrary.list() : [];
|
||
}
|
||
|
||
/** Загрузить .glb из File. Возвращает Promise<{ok, id?, error?}>. */
|
||
addGlbFromFile(file) {
|
||
if (!this.glbLibrary) return Promise.resolve({ ok: false, error: 'нет библиотеки' });
|
||
return this.glbLibrary.addFromFile(file).then((res) => {
|
||
if (res.ok && this._onSceneChange) this._onSceneChange();
|
||
return res;
|
||
});
|
||
}
|
||
|
||
renameGlb(id, name) {
|
||
if (!this.glbLibrary) return;
|
||
this.glbLibrary.rename(id, name);
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
}
|
||
|
||
removeGlb(id) {
|
||
if (!this.glbLibrary) return;
|
||
this.glbLibrary.remove(id);
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
}
|
||
|
||
/**
|
||
* Колбэк после постановки нового объекта (блока/модели/примитива).
|
||
* Используется KubikonEditor чтобы переключить activeTool на 'select'
|
||
* и дать пользователю сразу таскать поставленный объект.
|
||
*/
|
||
setOnPostPlace(cb) {
|
||
this._onPostPlace = cb;
|
||
}
|
||
|
||
/**
|
||
* Сохранить позиции всех unanchored объектов перед стартом физики.
|
||
* При exitPlayMode они возвращаются на эти позиции.
|
||
*/
|
||
_snapshotDynamicObjects() {
|
||
this._dynamicSnapshot = [];
|
||
if (this.blockManager) {
|
||
// Запоминаем позиции unanchored блоков (mesh-position).
|
||
// Сами блоки ОСТАЮТСЯ в blockManager.blocks Map, иначе вся остальная
|
||
// логика (сериализация, удаление, выделение) сломается.
|
||
// PhysicsAABB при Play фильтрует hasBlock через metadata.anchored
|
||
// и не считает unanchored клетку статичным препятствием.
|
||
for (const mesh of this.blockManager.blocks.values()) {
|
||
if (mesh.metadata?.anchored === false) {
|
||
this._dynamicSnapshot.push({
|
||
kind: 'block',
|
||
mesh,
|
||
x: mesh.position.x, y: mesh.position.y, z: mesh.position.z,
|
||
rotX: mesh.rotation?.x || 0,
|
||
rotY: mesh.rotation?.y || 0,
|
||
rotZ: mesh.rotation?.z || 0,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
if (this.primitiveManager) {
|
||
for (const data of this.primitiveManager.instances.values()) {
|
||
if (data.anchored === false) {
|
||
this._dynamicSnapshot.push({
|
||
kind: 'primitive', data,
|
||
x: data.x, y: data.y, z: data.z,
|
||
rotX: data.mesh?.rotation?.x || 0,
|
||
rotY: data.mesh?.rotation?.y || 0,
|
||
rotZ: data.mesh?.rotation?.z || 0,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
if (this.modelManager) {
|
||
for (const data of this.modelManager.instances.values()) {
|
||
if (data.anchored === false) {
|
||
this._dynamicSnapshot.push({
|
||
kind: 'model', data,
|
||
x: data.x, y: data.y, z: data.z,
|
||
rotX: data.rootMesh?.rotation?.x || 0,
|
||
rotY: data.rootMesh?.rotation?.y || 0,
|
||
rotZ: data.rootMesh?.rotation?.z || 0,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
_restoreDynamicObjects() {
|
||
if (!this._dynamicSnapshot) return;
|
||
for (const snap of this._dynamicSnapshot) {
|
||
if (snap.kind === 'block' && snap.mesh) {
|
||
snap.mesh.position.x = snap.x;
|
||
snap.mesh.position.y = snap.y;
|
||
snap.mesh.position.z = snap.z;
|
||
if (snap.mesh.rotation) {
|
||
snap.mesh.rotation.x = snap.rotX || 0;
|
||
snap.mesh.rotation.y = snap.rotY || 0;
|
||
snap.mesh.rotation.z = snap.rotZ || 0;
|
||
}
|
||
snap.mesh.setEnabled(true);
|
||
} else if (snap.kind === 'primitive' && snap.data) {
|
||
snap.data.x = snap.x; snap.data.y = snap.y; snap.data.z = snap.z;
|
||
if (snap.data.mesh) {
|
||
snap.data.mesh.position.set(snap.x, snap.y, snap.z);
|
||
if (snap.data.mesh.rotation) {
|
||
snap.data.mesh.rotation.x = snap.rotX || 0;
|
||
snap.data.mesh.rotation.y = snap.rotY || 0;
|
||
snap.data.mesh.rotation.z = snap.rotZ || 0;
|
||
}
|
||
snap.data.mesh.setEnabled(snap.data.visible !== false);
|
||
}
|
||
} else if (snap.kind === 'model' && snap.data) {
|
||
snap.data.x = snap.x; snap.data.y = snap.y; snap.data.z = snap.z;
|
||
if (snap.data.rootMesh) {
|
||
snap.data.rootMesh.position.set(snap.x, snap.y, snap.z);
|
||
if (snap.data.rootMesh.rotation) {
|
||
snap.data.rootMesh.rotation.x = snap.rotX || 0;
|
||
snap.data.rootMesh.rotation.y = snap.rotY || 0;
|
||
snap.data.rootMesh.rotation.z = snap.rotZ || 0;
|
||
}
|
||
snap.data.rootMesh.setEnabled(true);
|
||
}
|
||
}
|
||
}
|
||
this._dynamicSnapshot = null;
|
||
}
|
||
|
||
/**
|
||
* Полный снимок сцены перед Play — примитивы и модели целиком.
|
||
* При exitPlayMode сцена восстанавливается ровно к этому состоянию:
|
||
* вернутся удалённые скриптом объекты, откатятся цвет/видимость/
|
||
* коллизия/поворот, исчезнут заспавненные скриптом объекты.
|
||
*
|
||
* Зачем: скрипты игр меняют сцену деструктивно (game.self.delete,
|
||
* setColor, tween rotationY и т.д.). Без полного отката после
|
||
* Stop→Play сцена остаётся «использованной» — собранные монетки
|
||
* не появляются, открытая дверь остаётся открытой. Это как Stop
|
||
* в Roblox Studio: сцена возвращается к авторскому виду.
|
||
*
|
||
* Блоки СЮДА НЕ входят — их скрипты практически не меняют, а полная
|
||
* пересборка тысяч блоков дорогая. Падающие unanchored-блоки и так
|
||
* откатываются через _restoreDynamicObjects (позиции).
|
||
*/
|
||
_snapshotFullScene() {
|
||
this._fullSceneSnapshot = null;
|
||
try {
|
||
const snap = {};
|
||
if (this.primitiveManager) {
|
||
snap.primitives = this.primitiveManager.serialize();
|
||
}
|
||
if (this.modelManager) {
|
||
snap.models = this.modelManager.serialize();
|
||
}
|
||
this._fullSceneSnapshot = snap;
|
||
} catch (e) {
|
||
console.warn('[BabylonScene] _snapshotFullScene не удался:', e);
|
||
this._fullSceneSnapshot = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Восстановить сцену из полного снимка после Play.
|
||
* Пересоздаёт примитивы и модели точь-в-точь (id сохраняются —
|
||
* addInstance принимает opts.id, поэтому скрипты на объектах после
|
||
* рестарта снова найдут свои target.id).
|
||
*/
|
||
_restoreFullScene() {
|
||
if (!this._fullSceneSnapshot) return;
|
||
const snap = this._fullSceneSnapshot;
|
||
this._fullSceneSnapshot = null;
|
||
try {
|
||
// Сбрасываем выделение — loadFromArray диспозит старые mesh,
|
||
// selection не должен держать мёртвую ссылку.
|
||
try { this.selection?.clear?.(); } catch (e) {}
|
||
if (this.primitiveManager && Array.isArray(snap.primitives)) {
|
||
this.primitiveManager.loadFromArray(snap.primitives);
|
||
}
|
||
if (this.modelManager && Array.isArray(snap.models)) {
|
||
// loadFromArray у моделей async (модели грузятся с диска) —
|
||
// не ждём, восстановление догрузится в фоне.
|
||
Promise.resolve(this.modelManager.loadFromArray(snap.models))
|
||
.catch((e) => console.warn('[BabylonScene] откат моделей:', e));
|
||
}
|
||
} catch (e) {
|
||
console.warn('[BabylonScene] _restoreFullScene не удался:', e);
|
||
}
|
||
}
|
||
|
||
/** Поменять anchored у выделенного объекта. */
|
||
setSelectedAnchored(anchored) {
|
||
this.selection?.setSelectedAnchored(anchored);
|
||
}
|
||
|
||
/** === Окружение / Время суток / Аудио / Вода === */
|
||
setEnvironmentPreset(preset) { this.environment?.setPreset(preset); }
|
||
setTimeOfDay(hour) { this.environment?.setTimeOfDay(hour); }
|
||
setCycleDuration(dayMin, nightMin) { this.environment?.setCycleDuration(dayMin, nightMin); }
|
||
setFog(enabled, color, density) { this.environment?.setFog(enabled, color, density); }
|
||
getEnvironmentState() { return this.environment?.serialize() || null; }
|
||
|
||
setAmbientAudio(opts) { this.audioManager?.setAmbient(opts); }
|
||
setMusicAudio(opts) { this.audioManager?.setMusic(opts); }
|
||
getAudioState() { return this.audioManager?.serialize() || null; }
|
||
|
||
/** Доступные пресеты амбиента/музыки для UI. */
|
||
getAudioPresets() {
|
||
return { ambient: AMBIENT_PRESETS || [], music: MUSIC_PRESETS || [] };
|
||
}
|
||
|
||
/** Доступные модели игрока (категория «Персонажи»). */
|
||
getPlayerOptions() {
|
||
// Импорт MODEL_TYPES сложен из engine, поэтому берём через _playerOptionsCache
|
||
return this._playerOptionsCache || [];
|
||
}
|
||
setPlayerOptions(list) { this._playerOptionsCache = list; }
|
||
|
||
/** Обновить пресет амбиента/музыки и обновить selection если открыт. */
|
||
setSoundProps(patch) {
|
||
if (!patch) return;
|
||
if (patch.ambientId !== undefined) {
|
||
this.audioManager?.setAmbient({ preset: patch.ambientId });
|
||
}
|
||
if (patch.musicId !== undefined) {
|
||
this.audioManager?.setMusic({ preset: patch.musicId });
|
||
}
|
||
if (this.selection?._selection?.type === 'sound') {
|
||
this.selection.selectSound();
|
||
}
|
||
}
|
||
|
||
/** Обновить тип персонажа / силу прыжка / прицел. */
|
||
setPlayerProps(patch) {
|
||
if (!patch) return;
|
||
if (patch.playerModelType) {
|
||
this.setPlayerModelType(patch.playerModelType);
|
||
}
|
||
if (typeof patch.jumpPower === 'number' && patch.jumpPower > 0) {
|
||
this.setPlayerJumpPower(patch.jumpPower);
|
||
}
|
||
if (typeof patch.crosshair === 'string') {
|
||
this.setCrosshair(patch.crosshair);
|
||
}
|
||
if (this.selection?._selection?.type === 'player') {
|
||
this.selection.selectPlayer();
|
||
}
|
||
}
|
||
|
||
/** Поменять mass у выделенного объекта. */
|
||
setSelectedMass(mass) {
|
||
this.selection?.setSelectedMass(mass);
|
||
}
|
||
|
||
/** Поменять свойства модели (canCollide / visible). */
|
||
setSelectedModelProps(patch) {
|
||
if (!this.selection) return;
|
||
const sel = this.selection.getSelection();
|
||
if (sel?.type === 'userModel') {
|
||
this.selection.setSelectedUserModelProps(patch);
|
||
return;
|
||
}
|
||
this.selection.setSelectedModelProps(patch);
|
||
}
|
||
|
||
/** Поменять свойства блока (canCollide / visible). */
|
||
setSelectedBlockProps(patch) {
|
||
this.selection?.setSelectedBlockProps(patch);
|
||
}
|
||
|
||
/** === Папки/группы === */
|
||
createFolder(name = 'Новая папка', parentId = null) {
|
||
return this.folderManager?.createFolder(name, parentId) ?? null;
|
||
}
|
||
renameFolder(id, name) { this.folderManager?.renameFolder(id, name); }
|
||
|
||
/** Переименовать скрипт по id. Имя сохраняется в поле name. */
|
||
renameScript(id, name) {
|
||
const i = this._scripts.findIndex(s => s.id === id);
|
||
if (i < 0) return false;
|
||
this._scripts[i] = { ...this._scripts[i], name: String(name || '').trim() || null };
|
||
this.history?.markChange();
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
return true;
|
||
}
|
||
|
||
/** Переименовать инстанс модели. */
|
||
renameModel(instanceId, name) {
|
||
const data = this.modelManager?.instances?.get(instanceId);
|
||
if (!data) return false;
|
||
data.name = String(name || '').trim() || null;
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
this.modelManager?._notifyChange?.();
|
||
return true;
|
||
}
|
||
|
||
/** Переименовать примитив. */
|
||
renamePrimitive(id, name) {
|
||
const data = this.primitiveManager?.instances?.get(id);
|
||
if (!data) return false;
|
||
data.name = String(name || '').trim() || null;
|
||
if (this._onSceneChange) this._onSceneChange();
|
||
this.primitiveManager?._notifyChange?.();
|
||
return true;
|
||
}
|
||
removeFolder(id, deleteContent = false) { this.folderManager?.removeFolder(id, deleteContent); }
|
||
setFolderVisible(id, visible) { this.folderManager?.setVisible(id, visible); }
|
||
assignToFolder(kind, ref, folderId) { this.folderManager?.assignToFolder(kind, ref, folderId); }
|
||
/** Положить выделенное в указанную папку (или null = в корень). */
|
||
assignSelectionToFolder(folderId) {
|
||
const sel = this.selection?.getSelection();
|
||
if (!sel) return;
|
||
if (sel.type === 'block') {
|
||
this.folderManager?.assignToFolder('block', { x: sel.gridX, y: sel.gridY, z: sel.gridZ }, folderId);
|
||
} else if (sel.type === 'model') {
|
||
this.folderManager?.assignToFolder('model', sel.instanceId, folderId);
|
||
} else if (sel.type === 'primitive') {
|
||
this.folderManager?.assignToFolder('primitive', sel.id, folderId);
|
||
}
|
||
}
|
||
|
||
/** Undo. */
|
||
undo() {
|
||
return this.history?.undo();
|
||
}
|
||
|
||
/** Redo. */
|
||
redo() {
|
||
return this.history?.redo();
|
||
}
|
||
|
||
/** Можно ли откатиться. */
|
||
canUndo() {
|
||
return !!this.history?.canUndo();
|
||
}
|
||
|
||
/** Можно ли вернуть. */
|
||
canRedo() {
|
||
return !!this.history?.canRedo();
|
||
}
|
||
|
||
/**
|
||
* Захватить превью-скриншот сцены как data URL (PNG, base64).
|
||
* Используется для иконки проекта в «Мои игры».
|
||
* size — размер квадратного превью в пикселях (по умолчанию 256).
|
||
*/
|
||
captureThumbnail(size = 256) {
|
||
if (!this.canvas) return null;
|
||
try {
|
||
// Простейший способ — взять текущий canvas-buffer и масштабировать его
|
||
// в новый offscreen canvas размера size×size.
|
||
const out = document.createElement('canvas');
|
||
out.width = size;
|
||
out.height = size;
|
||
const ctx = out.getContext('2d');
|
||
// Чёрная заливка на случай прозрачности
|
||
ctx.fillStyle = '#1a1410';
|
||
ctx.fillRect(0, 0, size, size);
|
||
// Принудительный рендер чтобы backbuffer был свежим
|
||
if (this.scene) this.scene.render();
|
||
// Сохраняем сохраняя пропорции — рисуем по короткой стороне
|
||
const sw = this.canvas.width, sh = this.canvas.height;
|
||
const minSide = Math.min(sw, sh);
|
||
const sx = (sw - minSide) / 2;
|
||
const sy = (sh - minSide) / 2;
|
||
ctx.drawImage(this.canvas, sx, sy, minSide, minSide, 0, 0, size, size);
|
||
return out.toDataURL('image/jpeg', 0.7); // JPEG-70% — ~10-30 КБ
|
||
} catch (e) {
|
||
// eslint-disable-next-line no-console
|
||
console.error('[BabylonScene] thumbnail error:', e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// === СОХРАНЕНИЕ И ЗАГРУЗКА ===========================================
|
||
|
||
/**
|
||
* Сериализовать сцену в JSON-объект для сохранения в БД.
|
||
* Включает: блоки, модели, точку спавна, позицию редактор-камеры.
|
||
*/
|
||
/**
|
||
* Подготовить мини-карту для уже загруженного проекта (когда нет
|
||
* GeneratorParams). Считаем bbox реальных voxel'ов и сохраняем в
|
||
* window.__lastGenSize, чтобы MinimapOverlay масштабировался правильно.
|
||
* MinimapOverlay должна сама уметь рендерить real-data fallback.
|
||
*/
|
||
_setupMinimapForLoadedProject() {
|
||
if (!this.terrainManager || !this.terrainManager.voxels) return;
|
||
const voxels = this.terrainManager.voxels;
|
||
if (voxels.size === 0) return;
|
||
// Считаем bbox по X/Z. Берём max(|x|, |z|) как halfSize.
|
||
let maxAbs = 0;
|
||
for (const key of voxels.keys()) {
|
||
const lastComma = key.lastIndexOf(',');
|
||
const midComma = key.lastIndexOf(',', lastComma - 1);
|
||
const x = parseInt(key.slice(0, midComma), 10);
|
||
const z = parseInt(key.slice(lastComma + 1), 10);
|
||
const ax = Math.abs(x);
|
||
const az = Math.abs(z);
|
||
if (ax > maxAbs) maxAbs = ax;
|
||
if (az > maxAbs) maxAbs = az;
|
||
}
|
||
// maxAbs — в voxel-units. size для мини-карты = halfSize × 1.1 (запас).
|
||
const size = Math.ceil(maxAbs * 1.1);
|
||
window.__lastGenSize = size;
|
||
// Если ничего не было сгенерировано — НЕ ставим __lastGenParams.
|
||
// MinimapOverlay использует fallback на real-data top-down рендер.
|
||
console.log(`[BabylonScene] minimap configured for loaded project: half-size=${size} voxel-units (${(size * 2 * 0.25).toFixed(0)}м)`);
|
||
}
|
||
|
||
/**
|
||
* Подготовить мини-карту для гладкого ландшафта (RobloxTerrain).
|
||
*
|
||
* Воксельная миникарта (_setupMinimapForLoadedProject) читает
|
||
* terrainManager.voxels — для smooth terrain их нет. Здесь публикуем
|
||
* ссылку на density-grid в window.__robloxMinimapGrid, а MinimapOverlay
|
||
* сам строит top-down heightmap из неё.
|
||
*
|
||
* Ставим _terrainStreamingEnabled=true чтобы MinimapOverlay стал visible
|
||
* (он показывается по этому флагу), даже если у гладкого ландшафта
|
||
* нет настоящего streaming-режима.
|
||
*/
|
||
_setupMinimapForRobloxTerrain() {
|
||
const grid = this._robloxTerrain?.grid;
|
||
if (!grid) return;
|
||
// CELL_SIZE=4м. Полная ширина карты в метрах.
|
||
const worldM = grid.size.x * 4;
|
||
// MinimapOverlay масштаб: getWorldViewM() = size*2*0.25.
|
||
// Чтобы worldViewM == worldM → size = worldM*2.
|
||
window.__lastGenSize = worldM * 2;
|
||
window.__lastGenParams = null; // не procedural-режим
|
||
window.__robloxMinimapGrid = grid; // density-grid для real-data рендера
|
||
this._terrainStreamingEnabled = true; // делает MinimapOverlay видимым
|
||
// ВАЖНО: _worldHalf по умолчанию 40 — на больших гладких картах это
|
||
// зажимало зомби/мобов в крошечный квадрат ±40 (они телепортировались
|
||
// к центру и проваливались). Подгоняем под реальный размер карты.
|
||
const half = Math.ceil(worldM / 2);
|
||
if (this._worldHalf < half) {
|
||
this._worldHalf = half;
|
||
console.log(`[BabylonScene] _worldHalf -> ${half} (под размер гладкого ландшафта)`);
|
||
}
|
||
console.log(`[BabylonScene] minimap configured for RobloxTerrain: ${worldM}м (grid ${grid.size.x}×${grid.size.y}×${grid.size.z})`);
|
||
}
|
||
|
||
/**
|
||
* Снять ТОЧНУЮ карту высот гладкого ландшафта (RobloxTerrain).
|
||
*
|
||
* Зачем: density-grid квантует высоту по 4м, а Surface Nets рендерит
|
||
* сглаженную поверхность между ячейками — реальная видимая высота
|
||
* отличается от грубой оценки по grid. Чтобы корректно ставить
|
||
* объекты/блоки на землю, нужна высота РЕАЛЬНОГО меша.
|
||
*
|
||
* Метод raycast'ит сверху-вниз по мешам RobloxTerrain в сетке точек
|
||
* (шаг step метров) и возвращает объект с картой высот. Используется
|
||
* билд-скриптами игр для точного размещения.
|
||
*
|
||
* @param {number} step шаг сетки в метрах (по умолчанию 2)
|
||
* @returns {object|null} { format, origin, worldSize, step, cols, rows, heights[] }
|
||
* heights — плоский массив (rows × cols), значение = Y поверхности или null.
|
||
*/
|
||
exportRobloxHeightmap(step = 2) {
|
||
const rt = this._robloxTerrain;
|
||
if (!rt || !rt.grid) {
|
||
console.warn('[exportRobloxHeightmap] нет гладкого ландшафта');
|
||
return null;
|
||
}
|
||
const grid = rt.grid;
|
||
const CS = 4; // CELL_SIZE
|
||
// Мировые границы карты по grid.
|
||
const minWX = grid.origin.x * CS;
|
||
const minWZ = grid.origin.z * CS;
|
||
const worldX = grid.size.x * CS;
|
||
const worldZ = grid.size.z * CS;
|
||
const cols = Math.ceil(worldX / step) + 1;
|
||
const rows = Math.ceil(worldZ / step) + 1;
|
||
|
||
// Предикат: только меши гладкого ландшафта.
|
||
const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain);
|
||
const heights = new Array(cols * rows).fill(null);
|
||
let hit = 0;
|
||
const t0 = performance.now();
|
||
for (let r = 0; r < rows; r++) {
|
||
const wz = minWZ + r * step;
|
||
for (let c = 0; c < cols; c++) {
|
||
const wx = minWX + c * step;
|
||
const ray = new Ray(new Vector3(wx, 5000, wz), new Vector3(0, -1, 0), 10000);
|
||
const pick = this.scene.pickWithRay(ray, pickPred);
|
||
if (pick && pick.hit && pick.pickedPoint) {
|
||
heights[r * cols + c] = +pick.pickedPoint.y.toFixed(3);
|
||
hit++;
|
||
}
|
||
}
|
||
}
|
||
const dt = (performance.now() - t0).toFixed(0);
|
||
console.log(`[exportRobloxHeightmap] ${cols}×${rows} точек, ${hit} попаданий, ${dt}мс`);
|
||
|
||
return {
|
||
format: 'roblox-heightmap-v1',
|
||
origin: { x: minWX, z: minWZ },
|
||
worldSize: { x: worldX, z: worldZ },
|
||
gridOrigin: { ...grid.origin },
|
||
gridSize: { ...grid.size },
|
||
step,
|
||
cols,
|
||
rows,
|
||
heights,
|
||
};
|
||
}
|
||
|
||
serialize() {
|
||
// Принадлежность объектов папкам — серилизуется в их собственных
|
||
// данных (folderId), а сами папки в отдельном массиве.
|
||
const blocksWithFolders = this.blockManager ? this.blockManager.serialize() : [];
|
||
// BlockManager.serialize не знает про folderId — добавляем его поверх.
|
||
if (this.blockManager) {
|
||
for (const item of blocksWithFolders) {
|
||
const mesh = this.blockManager.blocks.get(`${item.x},${item.y},${item.z}`);
|
||
item.folderId = mesh?.metadata?.folderId ?? null;
|
||
}
|
||
}
|
||
const modelsWithFolders = this.modelManager ? this.modelManager.serialize() : [];
|
||
if (this.modelManager) {
|
||
// Дописываем instanceId + folderId поверх стандартной сериализации
|
||
// (которая уже включает type/x/y/z/rotationY/anchored/canCollide/visible/mass)
|
||
const live = Array.from(this.modelManager.instances.values());
|
||
for (let i = 0; i < modelsWithFolders.length && i < live.length; i++) {
|
||
modelsWithFolders[i].instanceId = live[i].instanceId;
|
||
modelsWithFolders[i].folderId = live[i].folderId ?? null;
|
||
}
|
||
}
|
||
const primitivesWithFolders = this.primitiveManager ? this.primitiveManager.getAll() : [];
|
||
if (this.primitiveManager) {
|
||
for (let i = 0; i < primitivesWithFolders.length; i++) {
|
||
const id = primitivesWithFolders[i].id;
|
||
const data = this.primitiveManager.instances.get(id);
|
||
primitivesWithFolders[i].folderId = data?.folderId ?? null;
|
||
}
|
||
}
|
||
// Terrain: RLE-формат для больших карт (×25 меньше чем legacy array).
|
||
// На карте 250м: ~1.5МБ вместо ~38МБ.
|
||
let terrainData;
|
||
if (this.terrainManager) {
|
||
const voxelCount = this.terrainManager.voxels?.size ?? 0;
|
||
if (voxelCount > 5000 && typeof this.terrainManager.serializeRLE === 'function') {
|
||
terrainData = this.terrainManager.serializeRLE();
|
||
} else {
|
||
terrainData = this.terrainManager.serialize();
|
||
}
|
||
} else {
|
||
terrainData = [];
|
||
}
|
||
// Roblox smooth terrain — отдельная подсистема, сериализуется параллельно
|
||
let robloxTerrainData = null;
|
||
if (this._robloxTerrain && this._robloxTerrain.grid) {
|
||
try {
|
||
robloxTerrainData = this._robloxTerrain.serialize();
|
||
// Декорации сохраняем двумя способами:
|
||
// - decoParams (seed + density) — для процедурной генерации
|
||
// - decoInstances (полные матрицы) — для ручных правок plant-кистью
|
||
// При load — приоритет у decoInstances если они есть.
|
||
if (this._smoothDecoParams) {
|
||
robloxTerrainData.decoParams = this._smoothDecoParams;
|
||
}
|
||
if (this._smoothDecoManager) {
|
||
const stats = this._smoothDecoManager.getStats?.();
|
||
if (stats && stats.total > 0) {
|
||
try {
|
||
robloxTerrainData.decoInstances = this._smoothDecoManager.serialize();
|
||
} catch (e) {
|
||
console.warn('smoothDeco serialize failed:', e);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { console.warn('robloxTerrain serialize failed:', e); }
|
||
}
|
||
|
||
return {
|
||
version: 1,
|
||
scene: {
|
||
blocks: blocksWithFolders,
|
||
models: modelsWithFolders,
|
||
primitives: primitivesWithFolders,
|
||
// Этап 5: пользовательские воксельные модели (созданные через
|
||
// ModelEditorScreen). Каждая запись: { type:'user:42', x,y,z, ry }.
|
||
userModels: this.userModelManager ? this.userModelManager.serialize() : [],
|
||
terrain: terrainData,
|
||
robloxTerrain: robloxTerrainData,
|
||
decorations: this.decoManager ? this.decoManager.serialize() : [],
|
||
folders: this.folderManager ? this.folderManager.serialize() : [],
|
||
gui: this.guiManager ? this.guiManager.serialize() : [],
|
||
inventory: this.inventory ? this.inventory.serialize() : null,
|
||
spawnPoint: { ...this._spawnPoint },
|
||
playerModelType: this._playerModelType,
|
||
skins: this._skinsConfig ? {
|
||
default: this._skinsConfig.default || null,
|
||
unlocked: this._skinsConfig.unlocked || [],
|
||
shopVisible: this._skinsConfig.shopVisible !== false,
|
||
coins: this._skinsConfig.coins || 0,
|
||
customGlbs: this._skinsConfig.customGlbs || [],
|
||
} : undefined,
|
||
worldSize: this._worldHalf * 2,
|
||
floorEnabled: this._floorEnabled !== false,
|
||
jumpPowerMul: this._jumpPowerMul ?? 1,
|
||
cameraMode: this._defaultCameraMode || 'third',
|
||
crosshair: this._crosshair || 'dot',
|
||
shadowQuality: this._shadowQuality || 'soft',
|
||
environment: this.environment ? this.environment.serialize() : null,
|
||
audio: this.audioManager ? this.audioManager.serialize() : null,
|
||
// Библиотека пользовательских картинок (текстуры/GUI-image).
|
||
assets: this.assetManager ? this.assetManager.serialize() : [],
|
||
// Библиотека пользовательских звуков (Фаза 5.5).
|
||
sounds: this.soundLibrary ? this.soundLibrary.serialize() : [],
|
||
// Импортированные .glb-модели (Фаза 5.8).
|
||
glbModels: this.glbLibrary ? this.glbLibrary.serialize() : [],
|
||
// ЭТАП 2.1: пропускаем demo-скрипт (он добавляется автоматически
|
||
// при загрузке если у проекта нет своих скриптов).
|
||
scripts: this._scripts
|
||
.filter(s => s.id !== 'demo')
|
||
.map(s => ({
|
||
id: s.id,
|
||
code: s.code,
|
||
target: s.target || null,
|
||
name: s.name || null,
|
||
})),
|
||
},
|
||
editorCamera: this.camera ? {
|
||
position: { x: this.camera.position.x, y: this.camera.position.y, z: this.camera.position.z },
|
||
rotation: { x: this.camera.rotation.x, y: this.camera.rotation.y, z: this.camera.rotation.z },
|
||
} : null,
|
||
settings: {
|
||
// GD-проект (Geometry Dash) — включает GD-визуал в Play.
|
||
// Раньше определялось по диапазону id (296-395), но диапазон
|
||
// зарезервирован с запасом — обычные проекты туда попадали.
|
||
// Теперь — явный флаг в данных проекта.
|
||
isGd: this._isGdProject === true,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Восстановить сцену из ранее сохранённого state.
|
||
* Очищает существующие блоки/модели, создаёт заново.
|
||
* Возвращает promise (async из-за загрузки моделей).
|
||
*/
|
||
async loadFromState(state) {
|
||
if (!state || !state.scene) return;
|
||
|
||
// Флаг GD-проекта — из settings. Если в данных проекта флага нет
|
||
// (старые проекты до введения флага) — _isGdProject останется
|
||
// undefined, и enterPlayMode сделает fallback на диапазон id.
|
||
if (state.settings && typeof state.settings.isGd === 'boolean') {
|
||
this._isGdProject = state.settings.isGd;
|
||
}
|
||
|
||
// Библиотека пользовательских картинок — грузим РАНЬШЕ примитивов,
|
||
// чтобы при создании примитива с textureAsset текстура уже была доступна.
|
||
if (this.assetManager) {
|
||
this.assetManager.load(Array.isArray(state.scene.assets) ? state.scene.assets : []);
|
||
}
|
||
// Библиотека пользовательских звуков (Фаза 5.5).
|
||
if (this.soundLibrary) {
|
||
this.soundLibrary.load(Array.isArray(state.scene.sounds) ? state.scene.sounds : []);
|
||
}
|
||
// Импортированные .glb-модели (Фаза 5.8) — грузим РАНЬШЕ моделей,
|
||
// чтобы при addInstance('glb:N') библиотека была готова.
|
||
if (this.glbLibrary) {
|
||
this.glbLibrary.load(Array.isArray(state.scene.glbModels) ? state.scene.glbModels : []);
|
||
}
|
||
|
||
// Размер пола — пересоздаём пол если у проекта он другой
|
||
if (typeof state.scene.worldSize === 'number' && state.scene.worldSize > 0) {
|
||
this.setWorldSize(state.scene.worldSize);
|
||
}
|
||
if (typeof state.scene.floorEnabled === 'boolean') {
|
||
this.setFloorEnabled(state.scene.floorEnabled);
|
||
}
|
||
if (typeof state.scene.jumpPowerMul === 'number' && state.scene.jumpPowerMul > 0) {
|
||
this.setPlayerJumpPower(state.scene.jumpPowerMul);
|
||
}
|
||
if (typeof state.scene.crosshair === 'string') {
|
||
this.setCrosshair(state.scene.crosshair);
|
||
}
|
||
// Камера по умолчанию ('first' / 'third' / 'front'). Применяется при enterPlayMode.
|
||
if (typeof state.scene.cameraMode === 'string') {
|
||
this._defaultCameraMode = state.scene.cameraMode;
|
||
}
|
||
// Качество теней
|
||
if (state.scene.shadowQuality) {
|
||
this.setShadowQuality(state.scene.shadowQuality);
|
||
}
|
||
|
||
// Блоки — синхронно
|
||
if (this.blockManager && Array.isArray(state.scene.blocks)) {
|
||
this.blockManager.loadFromArray(state.scene.blocks);
|
||
}
|
||
|
||
// Террейн (voxel-ландшафт). Поддерживаем 2 формата:
|
||
// 1. Legacy: terrain = [{x,y,z,m}, ...] — старые проекты
|
||
// 2. RLE-v1: terrain = {format:'rle-v1', palette, chunks:{base64}}
|
||
// — новый формат для больших карт (×25 меньше)
|
||
const ts = state.scene.terrain;
|
||
// Прогресс-индикатор: глобальный объект, KubikonEditor.jsx читает в polling
|
||
const setProgress = (percent, label) => {
|
||
if (typeof window !== 'undefined') {
|
||
window.__kubikonLoadProgress = { percent, label, ts: performance.now() };
|
||
}
|
||
};
|
||
setProgress(2, 'Подготовка сцены…');
|
||
if (this.terrainManager && ts) {
|
||
const tLoad0 = performance.now();
|
||
if (Array.isArray(ts)) {
|
||
// Legacy формат
|
||
const tCount = ts.length;
|
||
console.log(`[BabylonScene] LOAD terrain (legacy): ${tCount} voxels`);
|
||
await this.terrainManager.loadFromArray(ts, (loaded, total) => {
|
||
if (total > 5000 && loaded % 10000 < 2001) {
|
||
console.log(`[BabylonScene] terrain: ${loaded}/${total}`);
|
||
// 5-40% — заливка вокселей
|
||
setProgress(5 + Math.floor((loaded / total) * 35), `Размещение блоков: ${loaded.toLocaleString()} / ${total.toLocaleString()}`);
|
||
}
|
||
});
|
||
} else if (ts.format === 'rle-v1' && typeof this.terrainManager.loadFromRLE === 'function') {
|
||
// RLE формат
|
||
const chunkCount = Object.keys(ts.chunks || {}).length;
|
||
console.log(`[BabylonScene] LOAD terrain (RLE): ${chunkCount} chunks, palette=${(ts.palette || []).length - 1}`);
|
||
await this.terrainManager.loadFromRLE(ts, (loaded, total) => {
|
||
if (loaded % 16 === 0) {
|
||
console.log(`[BabylonScene] terrain RLE: chunk ${loaded}/${total}`);
|
||
// 5-40% — распаковка RLE
|
||
setProgress(5 + Math.floor((loaded / total) * 35), `Распаковка карты: ${loaded} / ${total} чанков`);
|
||
}
|
||
});
|
||
} else {
|
||
console.warn('[BabylonScene] unknown terrain format:', ts);
|
||
}
|
||
setProgress(75, 'Сборка геометрии регионов…');
|
||
const tLoad1 = performance.now();
|
||
const finalVoxelCount = this.terrainManager.voxels?.size ?? 0;
|
||
const regionCount = this.terrainManager.getRegionCount?.() ?? 0;
|
||
console.log(`[BabylonScene] LOAD done in ${(tLoad1 - tLoad0).toFixed(0)}ms: ${finalVoxelCount} voxels → ${regionCount} regions`);
|
||
|
||
// Shadow-load в VoxelWorld — ТОЛЬКО для маленьких карт.
|
||
// На больших (>30K voxels) это лишняя нагрузка (память + время).
|
||
// Если когда-то перейдём на новый рендер — сами решим shadow-load заново.
|
||
if (Array.isArray(ts) && finalVoxelCount > 0 && finalVoxelCount < 30000) {
|
||
try {
|
||
this.voxelWorld.loadLegacyTerrain(ts);
|
||
const s = this.voxelWorld.stats();
|
||
console.log(`[VoxelWorld] shadow-loaded: ${s.totalVoxels} voxels in ${s.totalChunks} chunks`);
|
||
} catch (e) {
|
||
console.warn('[VoxelWorld] shadow-load failed:', e);
|
||
}
|
||
} else if (finalVoxelCount >= 30000) {
|
||
console.log(`[VoxelWorld] shadow-load SKIPPED (${finalVoxelCount} voxels > 30000 — экономим память)`);
|
||
}
|
||
|
||
// === АВТО-STREAMING для загруженных больших проектов ===
|
||
if (regionCount > 0) {
|
||
this._terrainStreamingEnabled = true;
|
||
// Адаптивный radius (2026-05-27 увеличен): на средних картах
|
||
// 1-3M voxels старый radius 28-32м был слишком мал для
|
||
// замкнутых объектов (вулкан) — дальние регионы стенки не
|
||
// материализуются, игрок видит «полупрозрачные» стены.
|
||
let radius = 80;
|
||
if (finalVoxelCount > 5_000_000) radius = 40;
|
||
else if (finalVoxelCount > 3_000_000) radius = 55;
|
||
else if (finalVoxelCount > 1_000_000) radius = 70;
|
||
this._terrainStreamingRadius = radius;
|
||
this._terrainStreamingLastUpdate = 0;
|
||
// Автотуман для скрытия границы streaming. Без него видно
|
||
// резкий обрыв террейна на радиусе.
|
||
//
|
||
// Density подбираем по editor-radius: чем больше radius,
|
||
// тем дальше начало тумана. Для radius=72м: 0.005 — туман
|
||
// едва заметен в ближнем плане, но прячет обрыв на 70м.
|
||
try {
|
||
if (this.scene) {
|
||
const camY = this.camera?.position.y || 0;
|
||
const heightBonus = Math.max(0, Math.min(20, camY * 0.3));
|
||
const effectiveRadius = Math.min(60, this._terrainStreamingRadius * 1.3 + heightBonus);
|
||
// Эмпирически: fogDensity ≈ 0.5/radius работает.
|
||
// На radius=72м → 0.007 (туман на 70-90м)
|
||
// На radius=40м → 0.0125 (туман на 40-60м, как раньше)
|
||
const density = Math.max(0.004, Math.min(0.014, 0.5 / effectiveRadius));
|
||
this.scene.fogMode = 2; // FOGMODE_EXP
|
||
this.scene.fogColor = new Color3(0.55, 0.7, 0.85); // светло-голубой
|
||
this.scene.fogDensity = density;
|
||
}
|
||
} catch (e) {}
|
||
// Сразу первый pass с editor-radius формулой (см. render loop).
|
||
if (this.camera) {
|
||
const camY = this.camera.position.y || 0;
|
||
const heightBonus = Math.max(0, Math.min(20, camY * 0.3));
|
||
const editorRadius = Math.min(60, this._terrainStreamingRadius * 1.3 + heightBonus);
|
||
const r = this.terrainManager.updateStreaming(
|
||
this.camera.position.x, this.camera.position.z,
|
||
editorRadius,
|
||
);
|
||
console.log(`[BabylonScene] auto-streaming ON: ${r.enabled}/${r.total} regions visible (editor radius=${editorRadius.toFixed(0)}m, play radius=${this._terrainStreamingRadius}m)`);
|
||
}
|
||
this._setupMinimapForLoadedProject();
|
||
} else {
|
||
console.log(`[BabylonScene] streaming NOT enabled (regionCount=0). Карта маленькая или region-split не сработал.`);
|
||
}
|
||
}
|
||
|
||
// === Загрузка Roblox smooth terrain (параллельная подсистема) ===
|
||
// Если в проекте есть robloxTerrain — создаём менеджер и загружаем grid.
|
||
const rts = state.scene.robloxTerrain;
|
||
if (rts && rts.format === 'robloxterrain-v1') {
|
||
try {
|
||
setProgress(90, 'Загрузка гладкого ландшафта…');
|
||
if (!this._robloxTerrain) {
|
||
this._robloxTerrain = new RobloxTerrain(this.scene);
|
||
if (this.physics?.setRobloxTerrain) {
|
||
this.physics.setRobloxTerrain(this._robloxTerrain);
|
||
}
|
||
}
|
||
this._robloxTerrain.loadFromState(rts);
|
||
// Сразу материализуем chunks вокруг камеры
|
||
const camX = this.camera?.position.x || 0;
|
||
const camZ = this.camera?.position.z || 0;
|
||
const r = this._robloxTerrain.updateStreaming(camX, camZ, 99999, { maxBuild: 9999 });
|
||
const stats = this._robloxTerrain.getStats();
|
||
console.log(`[BabylonScene] LOAD robloxTerrain: ${r.built} chunks, ${stats.triangles} triangles`);
|
||
// Включаем мини-карту для гладкого ландшафта — MinimapOverlay
|
||
// показывается по флагу _terrainStreamingEnabled, а heightmap
|
||
// строит из density-grid (window.__robloxMinimapGrid).
|
||
this._setupMinimapForRobloxTerrain();
|
||
// Если есть RobloxTerrain — отключаем baseplate-floor чтобы
|
||
// не создавал ложных коллизий под smooth-ландшафтом.
|
||
// НО только если рельеф большой (≥500 cells) — иначе baseplate
|
||
// нужен для визуального ориентирования и для plant-decos
|
||
// которые ставятся на y=0.
|
||
if (stats.solidCells >= 500) {
|
||
try { this.setFloorEnabled(false); } catch (e) {}
|
||
}
|
||
// === Загрузка smooth-decorations ===
|
||
// Приоритет 1: decoInstances (точные матрицы, ручные правки plant-кистью)
|
||
// Приоритет 2: decoParams (seed-based процедурная генерация)
|
||
if (rts.decoInstances && rts.decoInstances.items?.length > 0) {
|
||
try {
|
||
if (!this._smoothDecoManager) {
|
||
this._smoothDecoManager = new SmoothDecoManager(this.scene);
|
||
}
|
||
const r = await this._smoothDecoManager.loadFromState(rts.decoInstances);
|
||
const total = r?.total ?? 0;
|
||
const tc = r?.treeColliders ?? [];
|
||
console.log(`[BabylonScene] LOAD smooth decorations (instances): ${total} (${tc.length} tree colliders)`);
|
||
if (this.physics?.setSmoothDecoTrees) {
|
||
this.physics.setSmoothDecoTrees(tc);
|
||
}
|
||
} catch (err) {
|
||
console.error('[BabylonScene] smooth decoInstances load failed:', err);
|
||
}
|
||
} else if (rts.decoParams) {
|
||
try {
|
||
const dp = rts.decoParams;
|
||
this._smoothDecoParams = dp;
|
||
if (!this._smoothDecoManager) {
|
||
this._smoothDecoManager = new SmoothDecoManager(this.scene);
|
||
}
|
||
await this._smoothDecoManager.loadAll();
|
||
const decoGen = new WorldGenerator(dp.genParams);
|
||
const sampleSurfaceY = (x, z) => {
|
||
if (!this.physics?._sampleRobloxSurface) return null;
|
||
return this.physics._sampleRobloxSurface(x, z);
|
||
};
|
||
const sampleBiomeId = (x, z) => {
|
||
const b = decoGen.sampleBiome(x * 4, z * 4);
|
||
return b?.id;
|
||
};
|
||
const r2 = this._smoothDecoManager.placeDecorations({
|
||
sampleSurfaceY, sampleBiomeId, bbox: dp.bbox,
|
||
densityFlowers: dp.flowersDensity,
|
||
densityGrass: dp.grassDensity,
|
||
densityTrees: dp.treesDensity ?? 0,
|
||
seed: dp.seed,
|
||
});
|
||
console.log(`[BabylonScene] LOAD smooth decorations (params): ${r2.total} instances`);
|
||
if (this.physics?.setSmoothDecoTrees && r2.treeColliders) {
|
||
this.physics.setSmoothDecoTrees(r2.treeColliders);
|
||
}
|
||
} catch (err) {
|
||
console.error('[BabylonScene] smooth decorations load failed:', err);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[BabylonScene] robloxTerrain load failed:', err);
|
||
}
|
||
}
|
||
|
||
setProgress(95, 'Размещение декораций…');
|
||
// Этап 6: загрузка decorations (цветы/грибы/трава мини-вокселями).
|
||
if (this.decoManager && Array.isArray(state.scene.decorations)) {
|
||
this.decoManager.loadFromArray(state.scene.decorations);
|
||
// Этап D LOD: первый pass streaming для деко.
|
||
// maxBuild=2 — строим только 2 ближайших chunk сразу (карта появляется
|
||
// мгновенно). Остальные доступные подгружаются по 2-4 за тик
|
||
// (200мс), весь видимый набор за 1-2 секунды. UI не блокируется.
|
||
if (this.camera && this.decoManager.updateStreaming) {
|
||
const decoRadius = Math.max(18, (this._terrainStreamingRadius || 40) * 0.35);
|
||
const r = this.decoManager.updateStreaming(
|
||
this.camera.position.x, this.camera.position.z, decoRadius,
|
||
{ maxBuild: 2 },
|
||
);
|
||
console.log(`[BabylonScene] deco streaming ON: ${r.visible}/${r.total} chunks visible (radius=${decoRadius.toFixed(0)}m)`);
|
||
}
|
||
}
|
||
|
||
// Модели — асинхронно (GLB подгружаются)
|
||
if (this.modelManager && Array.isArray(state.scene.models)) {
|
||
await this.modelManager.loadFromArray(state.scene.models);
|
||
}
|
||
|
||
// Этап 5: пользовательские воксельные модели — асинхронно
|
||
// (model_data грузится через API). Каждый item: {type:'user:42', x,y,z,ry,scale,canCollide,...}.
|
||
if (this.userModelManager && Array.isArray(state.scene.userModels)
|
||
&& state.scene.userModels.length > 0) {
|
||
const loaded = await this.userModelManager.loadFromArray(
|
||
state.scene.userModels,
|
||
{ currentUserId: this._currentUserId },
|
||
);
|
||
console.log(`[BabylonScene] user models loaded: ${loaded}/${state.scene.userModels.length}`);
|
||
// Регистрируем коллайдеры в физике
|
||
this._syncUserModelColliders();
|
||
}
|
||
|
||
// ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже —
|
||
// PlayerController.start() её ждёт, но если предзагрузить сейчас,
|
||
// на enterPlayMode она будет в кэше Babylon и стартует мгновенно.
|
||
// ВАЖНО: R15-скины ('skin_*') — отдельная система (characters/<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);
|
||
}
|
||
// Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле)
|
||
if (this.blockManager && Array.isArray(state.scene.blocks)) {
|
||
for (const b of state.scene.blocks) {
|
||
if (b.folderId == null) continue;
|
||
const mesh = this.blockManager.blocks.get(`${b.x},${b.y},${b.z}`);
|
||
if (mesh && mesh.metadata) mesh.metadata.folderId = b.folderId;
|
||
}
|
||
}
|
||
if (this.modelManager && Array.isArray(state.scene.models)) {
|
||
// ModelManager.loadFromArray генерирует новые instanceId,
|
||
// поэтому folderId восстанавливаем по индексу (порядку).
|
||
const arr = state.scene.models;
|
||
const liveIds = Array.from(this.modelManager.instances.keys());
|
||
for (let i = 0; i < arr.length && i < liveIds.length; i++) {
|
||
if (arr[i].folderId == null) continue;
|
||
const data = this.modelManager.instances.get(liveIds[i]);
|
||
if (data) data.folderId = arr[i].folderId;
|
||
}
|
||
}
|
||
if (this.primitiveManager && Array.isArray(state.scene.primitives)) {
|
||
// primitiveManager после loadFromArray генерирует новые id, поэтому
|
||
// восстановим folderId по индексу (порядку) — он совпадает.
|
||
const arr = state.scene.primitives;
|
||
const liveIds = Array.from(this.primitiveManager.instances.keys());
|
||
for (let i = 0; i < arr.length && i < liveIds.length; i++) {
|
||
if (arr[i].folderId == null) continue;
|
||
const data = this.primitiveManager.instances.get(liveIds[i]);
|
||
if (data) data.folderId = arr[i].folderId;
|
||
}
|
||
}
|
||
// После расстановки folderId — применим эффективную видимость папок
|
||
if (this.folderManager) {
|
||
for (const f of this.folderManager.getAll()) {
|
||
this.folderManager._applyVisibility(f.id, this.folderManager._effectiveVisible(f.id));
|
||
}
|
||
}
|
||
|
||
// Зарегистрировать все объекты как shadow casters
|
||
this.refreshAllShadows();
|
||
|
||
// Точка спавна
|
||
if (state.scene.spawnPoint) {
|
||
this._spawnPoint = { ...state.scene.spawnPoint };
|
||
this._updateSpawnMarker();
|
||
}
|
||
// === Авто-fix спавна для smooth terrain ===
|
||
// Если есть RobloxTerrain и spawnPoint оказался НИЖЕ поверхности —
|
||
// поднимаем его, чтобы игрок не провалился в момент нажатия "Запустить".
|
||
// Иначе raycast pickWithRay возвращает hit В ОБОИХ направлениях (mesh
|
||
// обволакивает AABB), физика застревает в UNSTUCK-цикле и игрок падает
|
||
// в бездну.
|
||
try {
|
||
if (this._robloxTerrain
|
||
&& (this._robloxTerrain.getStats?.().solidCells ?? 0) > 0
|
||
&& this.physics?._sampleRobloxSurface) {
|
||
const surfaceY = this.physics._sampleRobloxSurface(this._spawnPoint.x, this._spawnPoint.z);
|
||
if (surfaceY !== null && this._spawnPoint.y < surfaceY + 1) {
|
||
const newY = surfaceY + 2;
|
||
console.log(`[BabylonScene] spawn auto-lifted: y ${this._spawnPoint.y.toFixed(2)} → ${newY.toFixed(2)} (surface=${surfaceY.toFixed(2)})`);
|
||
this._spawnPoint.y = newY;
|
||
this._updateSpawnMarker();
|
||
} else if (surfaceY === null) {
|
||
console.warn('[BabylonScene] spawn auto-lift: no surface found under spawn');
|
||
}
|
||
}
|
||
} catch (e) { console.warn('[BabylonScene] spawn auto-lift failed:', e); }
|
||
// Тип модели персонажа.
|
||
// Миграция: старые проекты сохраняли Kenney-модель ('character-a..g').
|
||
// Теперь стандарт — R15-скин bacon-hair. Если в проекте старая
|
||
// Kenney-модель — форсим bacon-hair. Явно выбранные 'skin_*' не трогаем.
|
||
if (state.scene.playerModelType) {
|
||
const pmt = state.scene.playerModelType;
|
||
if (pmt.startsWith('character-')) {
|
||
this._playerModelType = 'skin_bacon-hair';
|
||
} else {
|
||
this._playerModelType = pmt;
|
||
}
|
||
}
|
||
// Задача 07: конфиг скинов проекта { default, unlocked, shopVisible, coins, customGlbs }.
|
||
if (state.scene.skins && typeof state.scene.skins === 'object') {
|
||
this._skinsConfig = {
|
||
default: state.scene.skins.default || null,
|
||
unlocked: Array.isArray(state.scene.skins.unlocked) ? state.scene.skins.unlocked.slice() : [],
|
||
shopVisible: state.scene.skins.shopVisible !== false,
|
||
coins: Number.isFinite(state.scene.skins.coins) ? state.scene.skins.coins : 0,
|
||
customGlbs: Array.isArray(state.scene.skins.customGlbs) ? state.scene.skins.customGlbs.slice() : [],
|
||
};
|
||
// Стартовый скин из skins.default имеет приоритет над playerModelType.
|
||
if (this._skinsConfig.default) {
|
||
const d = this._skinsConfig.default;
|
||
this._playerModelType = d.startsWith('character-') || d.startsWith('skin_') || d.startsWith('customskin:')
|
||
? d : ('skin_' + d);
|
||
}
|
||
} else {
|
||
this._skinsConfig = null;
|
||
}
|
||
// Пользовательские скрипты
|
||
if (Array.isArray(state.scene.scripts)) {
|
||
this._scripts = state.scene.scripts
|
||
.filter(s => s && typeof s.code === 'string')
|
||
.map(s => ({
|
||
id: s.id || `script_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||
code: s.code,
|
||
target: s.target || null,
|
||
name: s.name || null,
|
||
}));
|
||
}
|
||
// Окружение (время суток, скайбокс, туман)
|
||
if (state.scene.environment && this.environment) {
|
||
this.environment.load(state.scene.environment);
|
||
}
|
||
// Аудио (фоновая музыка/амбиент)
|
||
if (state.scene.audio && this.audioManager) {
|
||
this.audioManager.load(state.scene.audio);
|
||
}
|
||
|
||
// Редактор-камера
|
||
if (state.editorCamera && this.camera) {
|
||
const c = state.editorCamera;
|
||
if (c.position) this.camera.position = new Vector3(c.position.x, c.position.y, c.position.z);
|
||
if (c.rotation) this.camera.rotation = new Vector3(c.rotation.x, c.rotation.y, c.rotation.z);
|
||
}
|
||
// Финальный прогресс — UI скроет overlay
|
||
if (typeof window !== 'undefined') {
|
||
window.__kubikonLoadProgress = { percent: 100, label: 'Готово!', ts: performance.now() };
|
||
}
|
||
}
|
||
|
||
/** Выйти из режима игры — восстановить редактор-камеру. */
|
||
exitPlayMode() {
|
||
if (!this._isPlaying) return;
|
||
this._isPlaying = false;
|
||
// Задача 04: закрываем любой активный модал чтобы не «висел» при стопе
|
||
try { this.modalManager?._instantClose?.(); } catch (e) {}
|
||
// Сбрасываем таймер прохождения
|
||
this._timerRunning = false;
|
||
this._timerStartedAt = null;
|
||
// Отключаем picking voxel-террейна обратно (нужно только в play).
|
||
try { this.terrainManager?.enablePickingForCamera?.(false); } catch (e) {}
|
||
// Phase 6.4: чистим скрипт-созданные кастомные tool из инвентаря,
|
||
// чтобы они не накапливались между Play. Обычное оружие остаётся.
|
||
if (this.inventory) {
|
||
for (let i = 0; i < this.inventory.slots.length; i++) {
|
||
const s = this.inventory.slots[i];
|
||
if (s && s.params && s.params._customToolId) {
|
||
this.inventory.removeSlot(i);
|
||
}
|
||
}
|
||
}
|
||
// Размораживаем world-matrix статичных моделей — в редакторе
|
||
// пользователь может двигать их через гизмо.
|
||
try { this.modelManager?.unfreezeStaticModels?.(); } catch (e) {}
|
||
try { this.primitiveManager?.unfreezeStaticPrimitives?.(); } catch (e) {}
|
||
// Возвращаем все примитивы в видимое состояние (LOD-cull сбрасывается)
|
||
if (this.primitiveManager) {
|
||
for (const data of this.primitiveManager.instances.values()) {
|
||
const m = data.mesh;
|
||
if (m && m._kubikonPrimCulled === true) {
|
||
m.setEnabled(data.visible !== false);
|
||
m._kubikonPrimCulled = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Останавливаем пользовательские скрипты ПЕРЕД уничтожением player'а,
|
||
// чтобы скрипты не успели потрогать player в момент disposal.
|
||
if (this.gameRuntime) {
|
||
this.gameRuntime.stop();
|
||
this.gameRuntime = null;
|
||
}
|
||
|
||
// Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением)
|
||
if (this.gdLevelManager) {
|
||
this.gdLevelManager.stop();
|
||
}
|
||
// Этап G1: убрать skybox/параллакс (откатывает fog/clearColor)
|
||
if (this.gdSkybox) {
|
||
try { this.gdSkybox.dispose(); } catch (e) {}
|
||
this.gdSkybox = null;
|
||
}
|
||
// Этап G2: убрать декоративную траву + neon-edge
|
||
if (this.gdGroundSkin) {
|
||
try { this.gdGroundSkin.dispose(); } catch (e) {}
|
||
this.gdGroundSkin = null;
|
||
}
|
||
// Этап G3: убрать кастомные шипы, вернуть оригинальные конусы
|
||
if (this.gdSpikes) {
|
||
try { this.gdSpikes.dispose(); } catch (e) {}
|
||
this.gdSpikes = null;
|
||
}
|
||
// Этап G4: убрать стартовую арку
|
||
if (this.gdStartArch) {
|
||
try { this.gdStartArch.dispose(); } catch (e) {}
|
||
this.gdStartArch = null;
|
||
}
|
||
// Этап G5: убрать финиш-ворота
|
||
if (this.gdFinish) {
|
||
try { this.gdFinish.dispose(); } catch (e) {}
|
||
this.gdFinish = null;
|
||
}
|
||
// Этап G6: убрать деревья/кусты
|
||
if (this.gdForest) {
|
||
try { this.gdForest.dispose(); } catch (e) {}
|
||
this.gdForest = null;
|
||
}
|
||
// Этап G7: снять эффекты с куба игрока
|
||
if (this.gdPlayerCube) {
|
||
try { this.gdPlayerCube.dispose(); } catch (e) {}
|
||
this.gdPlayerCube = null;
|
||
}
|
||
// Этап G8: trail-частицы
|
||
if (this.gdPlayerTrail) {
|
||
try { this.gdPlayerTrail.dispose(); } catch (e) {}
|
||
this.gdPlayerTrail = null;
|
||
}
|
||
// Этап G9: пост-обработка (bloom/vignette/освещение)
|
||
if (this.gdPostFx) {
|
||
try { this.gdPostFx.dispose(); } catch (e) {}
|
||
this.gdPostFx = null;
|
||
}
|
||
|
||
// Выключаем оружие
|
||
if (this.weapons) {
|
||
this.weapons.stop();
|
||
}
|
||
// Выключаем зомби и спавнеры
|
||
if (this.spawnerManager) this.spawnerManager.stop();
|
||
if (this.zombieManager) this.zombieManager.stop();
|
||
// Выключаем NPC (удаляет их модели и UI).
|
||
if (this.npcManager) this.npcManager.stop();
|
||
// Выключаем связи объектов.
|
||
if (this.constraintManager) this.constraintManager.stop();
|
||
// Выключаем лучи и следы.
|
||
if (this.beamManager) this.beamManager.stop();
|
||
// Выключаем 3D-звук (останавливает активные звуки).
|
||
if (this.soundManager) this.soundManager.stop();
|
||
|
||
if (this.player) {
|
||
this.player.stop();
|
||
this.player = null;
|
||
}
|
||
|
||
// Возвращаем визуальный маркер спавна
|
||
this._setSpawnMarkerVisible(true);
|
||
this.primitiveManager?.setTriggersVisible(true);
|
||
|
||
// Останавливаем физику и возвращаем объекты на исходные позиции
|
||
this.dynamics?.stop();
|
||
this._restoreDynamicObjects();
|
||
// Полный откат сцены: пересоздаём примитивы и модели из снимка —
|
||
// возвращаются удалённые скриптом объекты, откатываются цвет/
|
||
// видимость/коллизия/повороты, исчезают заспавненные объекты.
|
||
this._restoreFullScene();
|
||
|
||
// Останавливаем фоновый звук
|
||
this.audioManager?.stop();
|
||
|
||
// Восстанавливаем редактор-камеру
|
||
const snap = this._editorCameraSnapshot;
|
||
// Создаём новую UniversalCamera-редактор (наша старая была уничтожена когда
|
||
// PlayerController сделал scene.activeCamera = playerCamera).
|
||
// На самом деле она НЕ уничтожилась — мы просто переключали activeCamera.
|
||
// Возвращаем её обратно.
|
||
this.scene.activeCamera = this.camera;
|
||
if (snap) {
|
||
this.camera.position = snap.position;
|
||
this.camera.rotation = snap.rotation;
|
||
}
|
||
this._editorCameraSnapshot = null;
|
||
}
|
||
|
||
dispose() {
|
||
if (this._resizeHandler) {
|
||
window.removeEventListener('resize', this._resizeHandler);
|
||
this._resizeHandler = null;
|
||
}
|
||
if (this._ro) {
|
||
this._ro.disconnect();
|
||
this._ro = null;
|
||
}
|
||
for (const { target, type, fn, opts } of this._listeners) {
|
||
target.removeEventListener(type, fn, opts);
|
||
}
|
||
this._listeners = [];
|
||
if (this.player) {
|
||
this.player.stop();
|
||
this.player = null;
|
||
}
|
||
if (this.history) {
|
||
this.history.dispose();
|
||
this.history = null;
|
||
}
|
||
if (this._gizmo) {
|
||
try { this._gizmo.dispose(); } catch (e) { /* ignore */ }
|
||
this._gizmo = null;
|
||
}
|
||
if (this._gizmoLayer) {
|
||
try { this._gizmoLayer.dispose(); } catch (e) { /* ignore */ }
|
||
this._gizmoLayer = null;
|
||
}
|
||
if (this.selection) {
|
||
this.selection.dispose();
|
||
this.selection = null;
|
||
}
|
||
if (this.blockManager) {
|
||
this.blockManager.dispose();
|
||
this.blockManager = null;
|
||
}
|
||
if (this.modelManager) {
|
||
this.modelManager.dispose();
|
||
this.modelManager = null;
|
||
}
|
||
if (this.primitiveManager) {
|
||
this.primitiveManager.dispose();
|
||
this.primitiveManager = null;
|
||
}
|
||
if (this.folderManager) {
|
||
this.folderManager.dispose();
|
||
this.folderManager = null;
|
||
}
|
||
if (this.guiManager) {
|
||
this.guiManager.clear();
|
||
this.guiManager = null;
|
||
}
|
||
if (this.inventory) {
|
||
this.inventory.clear();
|
||
this.inventory = null;
|
||
}
|
||
if (this.dynamics) {
|
||
this.dynamics.dispose();
|
||
this.dynamics = null;
|
||
}
|
||
if (this.audioManager) {
|
||
this.audioManager.dispose();
|
||
this.audioManager = null;
|
||
}
|
||
if (this.assetManager) {
|
||
this.assetManager.dispose();
|
||
this.assetManager = null;
|
||
}
|
||
if (this.soundLibrary) {
|
||
this.soundLibrary.dispose();
|
||
this.soundLibrary = null;
|
||
}
|
||
if (this.glbLibrary) {
|
||
this.glbLibrary.dispose();
|
||
this.glbLibrary = null;
|
||
}
|
||
this.environment = null;
|
||
this.physics = null;
|
||
if (this.scene) {
|
||
this.scene.dispose();
|
||
this.scene = null;
|
||
}
|
||
if (this.engine) {
|
||
this.engine.dispose();
|
||
this.engine = null;
|
||
}
|
||
}
|
||
}
|